krz/hutch-stats

Server-side utility for calculating contributions for sourcehut users. contributions server sourcehut stats

Commit 4390684995

4390684995c9933f213faec2cb20f39af6d506d2

parent: daaeba8be8

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-12 16:43 UTC

add temporary self-graph indexing priority

Layout: unified · split

API.md +5
@@ -44,6 +44,7 @@ Contribution ranges:
44 44
45- Contribution read endpoints return zero-filled days, so clients do not need to patch missing dates. 45- Contribution read endpoints return zero-filled days, so clients do not need to patch missing dates.
46- Public contribution reads also register the actor for background indexing. A first lookup may therefore return an empty graph while the scheduler catches up. 46- Public contribution reads also register the actor for background indexing. A first lookup may therefore return an empty graph while the scheduler catches up.
47- Clients may add `prioritize_self=true` on contribution read endpoints to explicitly request temporary indexing priority for the signed-in user's own graph.
47- Incremental indexing and one-year backfill are separate. An actor can be recently indexed before the retained one-year window is fully filled in. 48- Incremental indexing and one-year backfill are separate. An actor can be recently indexed before the retained one-year window is fully filled in.
48- The service only retains and backfills the most recent 365 days of activity. 49- The service only retains and backfills the most recent 365 days of activity.
49 50
@@ -101,6 +102,7 @@ Query parameters:
101- `year` integer, optional 102- `year` integer, optional
102- `from` string `YYYY-MM-DD`, optional 103- `from` string `YYYY-MM-DD`, optional
103- `to` string `YYYY-MM-DD`, optional 104- `to` string `YYYY-MM-DD`, optional
105- `prioritize_self` boolean, optional
104 106
105Rules: 107Rules:
106 108
@@ -112,6 +114,7 @@ Behavior notes:
112 114
113- This endpoint resolves aliases to a canonical actor before querying data. 115- This endpoint resolves aliases to a canonical actor before querying data.
114- This endpoint also registers the actor for background indexing and updates the actor's `last_requested_at` timestamp. 116- This endpoint also registers the actor for background indexing and updates the actor's `last_requested_at` timestamp.
117- When `prioritize_self=true`, registration also applies a temporary scheduler boost so that due polls for that actor run ahead of the normal due queue.
115- The response is always immediate; it does not wait for SourceHut polling to finish. 118- The response is always immediate; it does not wait for SourceHut polling to finish.
116- One-year backfill runs in bounded background batches and may take multiple scheduler passes to complete. 119- One-year backfill runs in bounded background batches and may take multiple scheduler passes to complete.
117 120
@@ -211,10 +214,12 @@ Query parameters:
211- `year` integer, optional 214- `year` integer, optional
212- `from` string `YYYY-MM-DD`, optional 215- `from` string `YYYY-MM-DD`, optional
213- `to` string `YYYY-MM-DD`, optional 216- `to` string `YYYY-MM-DD`, optional
217- `prioritize_self` boolean, optional
214 218
215Behavior notes: 219Behavior notes:
216 220
217- This endpoint has the same actor-registration and alias-resolution behavior as the calendar endpoint. 221- This endpoint has the same actor-registration and alias-resolution behavior as the calendar endpoint.
222- When `prioritize_self=true`, registration also applies the same temporary scheduler boost as the calendar endpoint.
218- This endpoint returns immediately and does not block on SourceHut polling. 223- This endpoint returns immediately and does not block on SourceHut polling.
219- This endpoint also reflects whether the retained one-year history window has been fully backfilled yet. 224- This endpoint also reflects whether the retained one-year history window has been fully backfilled yet.
220 225
README.md +2
@@ -175,6 +175,8 @@ Scheduled polling only runs when `ENABLE_SCHEDULER=true`. The scheduler seeds `D
175 175
176The scheduler now drains actors gradually instead of polling every tracked actor on every pass. It only claims due actors, up to `DISCOVERY_BATCH_SIZE` per run, then reschedules indexed actors with `INDEXED_ACTOR_REPOLL_SECONDS` and failed actors with backoff based on `DISCOVERY_ERROR_BACKOFF_SECONDS`. 176The scheduler now drains actors gradually instead of polling every tracked actor on every pass. It only claims due actors, up to `DISCOVERY_BATCH_SIZE` per run, then reschedules indexed actors with `INDEXED_ACTOR_REPOLL_SECONDS` and failed actors with backoff based on `DISCOVERY_ERROR_BACKOFF_SECONDS`.
177 177
178Clients can explicitly signal that a public contribution read is for the signed-in user's own graph by sending `prioritize_self=true` on the read request. That temporarily boosts the actor to the front of the due queue for the next indexing pass, then clears the boost after the poll completes.
179
178## Bulk Enqueue Without Immediate Indexing 180## Bulk Enqueue Without Immediate Indexing
179 181
180To durably queue a large username list without polling it immediately: 182To durably queue a large username list without polling it immediately:
alembic/versions/20260412_0007_actor_priority_boost.py added +29
@@ -0,0 +1,29 @@
1"""temporary actor priority boost marker"""
2
3from __future__ import annotations
4
5from alembic import op
6import sqlalchemy as sa
7from sqlalchemy import inspect
8
9
10revision = "20260412_0007"
11down_revision = "20260411_0006"
12branch_labels = None
13depends_on = None
14
15
16def _column_names(table_name: str) -> set[str]:
17 return {column["name"] for column in inspect(op.get_bind()).get_columns(table_name)}
18
19
20def upgrade() -> None:
21 columns = _column_names("tracked_actors")
22 if "priority_boosted_at" not in columns:
23 op.add_column("tracked_actors", sa.Column("priority_boosted_at", sa.DateTime(timezone=True), nullable=True))
24
25
26def downgrade() -> None:
27 columns = _column_names("tracked_actors")
28 if "priority_boosted_at" in columns:
29 op.drop_column("tracked_actors", "priority_boosted_at")
src/srht_contrib/api/routes_contributions.py +4 −2
@@ -41,13 +41,14 @@ def get_contributions(
41 year: int | None = Query(default=None, ge=1970, le=3000), 41 year: int | None = Query(default=None, ge=1970, le=3000),
42 from_date: str | None = Query(default=None, alias="from"), 42 from_date: str | None = Query(default=None, alias="from"),
43 to_date: str | None = Query(default=None, alias="to"), 43 to_date: str | None = Query(default=None, alias="to"),
44 prioritize_self: bool = Query(default=False),
44 poller: PollerService = Depends(get_poller), 45 poller: PollerService = Depends(get_poller),
45 db: Session = Depends(get_db), 46 db: Session = Depends(get_db),
46 actor_identity_resolver: ActorIdentityResolver = Depends(get_actor_identity_resolver), 47 actor_identity_resolver: ActorIdentityResolver = Depends(get_actor_identity_resolver),
47) -> ContributionCalendarResponse: 48) -> ContributionCalendarResponse:
48 start, end = _resolve_range(year, from_date, to_date) 49 start, end = _resolve_range(year, from_date, to_date)
49 canonical_actor = actor_identity_resolver.canonicalize(actor, db=db) 50 canonical_actor = actor_identity_resolver.canonicalize(actor, db=db)
50 poller.track_actor_request(db, canonical_actor) 51 poller.track_actor_request(db, canonical_actor, prioritize=prioritize_self)
51 response = ContributionAggregator().build_calendar(db, canonical_actor, start, end) 52 response = ContributionAggregator().build_calendar(db, canonical_actor, start, end)
52 db.commit() 53 db.commit()
53 return response 54 return response
@@ -59,13 +60,14 @@ def get_contribution_stats(
59 year: int | None = Query(default=None, ge=1970, le=3000), 60 year: int | None = Query(default=None, ge=1970, le=3000),
60 from_date: str | None = Query(default=None, alias="from"), 61 from_date: str | None = Query(default=None, alias="from"),
61 to_date: str | None = Query(default=None, alias="to"), 62 to_date: str | None = Query(default=None, alias="to"),
63 prioritize_self: bool = Query(default=False),
62 poller: PollerService = Depends(get_poller), 64 poller: PollerService = Depends(get_poller),
63 db: Session = Depends(get_db), 65 db: Session = Depends(get_db),
64 actor_identity_resolver: ActorIdentityResolver = Depends(get_actor_identity_resolver), 66 actor_identity_resolver: ActorIdentityResolver = Depends(get_actor_identity_resolver),
65) -> ContributionStatsResponse: 67) -> ContributionStatsResponse:
66 start, end = _resolve_range(year, from_date, to_date) 68 start, end = _resolve_range(year, from_date, to_date)
67 canonical_actor = actor_identity_resolver.canonicalize(actor, db=db) 69 canonical_actor = actor_identity_resolver.canonicalize(actor, db=db)
68 poller.track_actor_request(db, canonical_actor) 70 poller.track_actor_request(db, canonical_actor, prioritize=prioritize_self)
69 response = ContributionAggregator().build_stats(db, canonical_actor, start, end) 71 response = ContributionAggregator().build_stats(db, canonical_actor, start, end)
70 db.commit() 72 db.commit()
71 return response 73 return response
src/srht_contrib/jobs/poller.py +15 −1
@@ -65,6 +65,8 @@ class PollerService:
65 ) 65 )
66 ) 66 )
67 .order_by( 67 .order_by(
68 TrackedActor.priority_boosted_at.is_not(None).desc(),
69 TrackedActor.priority_boosted_at.desc(),
68 TrackedActor.next_poll_after.is_(None).desc(), 70 TrackedActor.next_poll_after.is_(None).desc(),
69 TrackedActor.next_poll_after, 71 TrackedActor.next_poll_after,
70 TrackedActor.queued_for_discovery_at, 72 TrackedActor.queued_for_discovery_at,
@@ -90,7 +92,14 @@ class PollerService:
90 logger.info("Pruned %s contribution events older than %s days", deleted, RETENTION_DAYS) 92 logger.info("Pruned %s contribution events older than %s days", deleted, RETENTION_DAYS)
91 return results 93 return results
92 94
93 def track_actor_request(self, db: Session, actor: str, *, update_last_requested: bool = True) -> TrackedActor: 95 def track_actor_request(
96 self,
97 db: Session,
98 actor: str,
99 *,
100 update_last_requested: bool = True,
101 prioritize: bool = False,
102 ) -> TrackedActor:
94 tracked_actor = db.scalar(select(TrackedActor).where(TrackedActor.actor == actor)) 103 tracked_actor = db.scalar(select(TrackedActor).where(TrackedActor.actor == actor))
95 now = datetime.now(tz=UTC) 104 now = datetime.now(tz=UTC)
96 if tracked_actor is None: 105 if tracked_actor is None:
@@ -111,6 +120,9 @@ class PollerService:
111 tracked_actor.next_poll_after = now 120 tracked_actor.next_poll_after = now
112 if update_last_requested: 121 if update_last_requested:
113 tracked_actor.last_requested_at = now 122 tracked_actor.last_requested_at = now
123 if prioritize:
124 tracked_actor.priority_boosted_at = now
125 tracked_actor.next_poll_after = now
114 db.flush() 126 db.flush()
115 return tracked_actor 127 return tracked_actor
116 128
@@ -207,11 +219,13 @@ class PollerService:
207 tracked_actor.discovery_state = "indexed" 219 tracked_actor.discovery_state = "indexed"
208 tracked_actor.last_polled_at = now 220 tracked_actor.last_polled_at = now
209 tracked_actor.next_poll_after = now + timedelta(seconds=self.settings.indexed_actor_repoll_seconds) 221 tracked_actor.next_poll_after = now + timedelta(seconds=self.settings.indexed_actor_repoll_seconds)
222 tracked_actor.priority_boosted_at = None
210 elif status == "error": 223 elif status == "error":
211 tracked_actor.discovery_state = "error" 224 tracked_actor.discovery_state = "error"
212 tracked_actor.next_poll_after = now + timedelta( 225 tracked_actor.next_poll_after = now + timedelta(
213 seconds=self.settings.discovery_error_backoff_seconds * max(tracked_actor.poll_attempts, 1) 226 seconds=self.settings.discovery_error_backoff_seconds * max(tracked_actor.poll_attempts, 1)
214 ) 227 )
228 tracked_actor.priority_boosted_at = None
215 db.add(tracked_actor) 229 db.add(tracked_actor)
216 db.flush() 230 db.flush()
217 231
src/srht_contrib/models.py +1
@@ -69,6 +69,7 @@ class TrackedActor(Base):
69 is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) 69 is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
70 discovery_state: Mapped[str] = mapped_column(String(32), nullable=False, default="queued") 70 discovery_state: Mapped[str] = mapped_column(String(32), nullable=False, default="queued")
71 queued_for_discovery_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) 71 queued_for_discovery_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
72 priority_boosted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
72 next_poll_after: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) 73 next_poll_after: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
73 last_claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) 74 last_claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
74 poll_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0) 75 poll_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
tests/test_contributions_api.py +45
@@ -7,6 +7,11 @@ from srht_contrib.main import create_app
7from srht_contrib.models import ContributionEvent, TrackedActor 7from srht_contrib.models import ContributionEvent, TrackedActor
8 8
9 9
10class _Closable:
11 def close(self) -> None:
12 return None
13
14
10def test_read_only_contribution_routes_are_public_and_write_routes_require_api_key(settings, db_engine, session_factory) -> None: 15def test_read_only_contribution_routes_are_public_and_write_routes_require_api_key(settings, db_engine, session_factory) -> None:
11 app = create_app(settings, engine=db_engine, session_factory=session_factory) 16 app = create_app(settings, engine=db_engine, session_factory=session_factory)
12 with TestClient(app) as open_client: 17 with TestClient(app) as open_client:
@@ -72,6 +77,24 @@ def test_public_read_registers_actor_for_lazy_indexing(client: TestClient, db_se
72 assert tracked_actor is not None 77 assert tracked_actor is not None
73 assert tracked_actor.is_active is True 78 assert tracked_actor.is_active is True
74 assert tracked_actor.last_requested_at is not None 79 assert tracked_actor.last_requested_at is not None
80 assert tracked_actor.priority_boosted_at is None
81
82
83class RecordingPriorityPoller:
84 def __init__(self) -> None:
85 service = type("Service", (), {"client": _Closable()})()
86 self.todo_service = service
87 self.git_service = service
88 self.calls: list[tuple[str, bool]] = []
89
90 def track_actor_request(self, db, actor: str, *, update_last_requested: bool = True, prioritize: bool = False):
91 self.calls.append((actor, prioritize))
92
93 def poll_all(self, db, actor: str) -> int:
94 return 0
95
96 def poll_tracked_actors(self, db, default_actor: str | None = None) -> dict[str, int]:
97 return {}
75 98
76 99
77def test_contribution_stats_api(client: TestClient, db_session) -> None: 100def test_contribution_stats_api(client: TestClient, db_session) -> None:
@@ -148,3 +171,25 @@ def test_contribution_routes_use_settings_backed_alias_resolution(settings, db_e
148 171
149 assert response.status_code == 200 172 assert response.status_code == 200
150 assert response.json()["actor"] == "~ccleberg" 173 assert response.json()["actor"] == "~ccleberg"
174
175
176def test_contribution_route_passes_explicit_priority_signal(settings, db_engine, session_factory) -> None:
177 poller = RecordingPriorityPoller()
178 app = create_app(settings, engine=db_engine, session_factory=session_factory, poller=poller)
179
180 with TestClient(app) as client:
181 response = client.get("/api/contributions/~ccleberg?from=2026-03-28&to=2026-03-30&prioritize_self=true")
182
183 assert response.status_code == 200
184 assert poller.calls == [("~ccleberg", True)]
185
186
187def test_contribution_stats_route_keeps_non_prioritized_registration_by_default(settings, db_engine, session_factory) -> None:
188 poller = RecordingPriorityPoller()
189 app = create_app(settings, engine=db_engine, session_factory=session_factory, poller=poller)
190
191 with TestClient(app) as client:
192 response = client.get("/api/contributions/~ccleberg/stats?from=2026-03-28&to=2026-03-30")
193
194 assert response.status_code == 200
195 assert poller.calls == [("~ccleberg", False)]
tests/test_ingestion.py +88
@@ -711,6 +711,94 @@ def test_poll_tracked_actors_limits_to_due_batch_size(db_session) -> None:
711 assert actors["~c"].poll_attempts == 0 711 assert actors["~c"].poll_attempts == 0
712 712
713 713
714def test_track_actor_request_prioritize_marks_actor_boosted_and_due_now(db_session) -> None:
715 settings = make_settings(INDEXED_ACTOR_REPOLL_SECONDS=3600)
716 poller = PollerService(todo_service=RecordingTodoService(events_by_call=[[]]), git_service=EmptyGitService(), settings=settings)
717 future_due = datetime.now(tz=UTC) + timedelta(hours=2)
718 db_session.add(
719 TrackedActor(
720 actor="~self",
721 is_active=True,
722 discovery_state="indexed",
723 queued_for_discovery_at=datetime.now(tz=UTC) - timedelta(hours=1),
724 next_poll_after=future_due,
725 recent_backfill_status="completed",
726 )
727 )
728 db_session.commit()
729
730 tracked_actor = poller.track_actor_request(db_session, "~self", prioritize=True)
731
732 assert tracked_actor.priority_boosted_at is not None
733 assert tracked_actor.next_poll_after is not None
734 assert tracked_actor.next_poll_after <= tracked_actor.priority_boosted_at
735
736
737def test_poll_tracked_actors_prioritizes_boosted_due_actor_first(db_session) -> None:
738 settings = make_settings(DISCOVERY_BATCH_SIZE=1, INDEXED_ACTOR_REPOLL_SECONDS=3600)
739 todo_service = RecordingTodoService(events_by_call=[[]])
740 poller = PollerService(todo_service=todo_service, git_service=EmptyGitService(), settings=settings)
741
742 now = datetime.now(tz=UTC)
743 db_session.add_all(
744 [
745 TrackedActor(
746 actor="~normal",
747 is_active=True,
748 discovery_state="queued",
749 queued_for_discovery_at=now - timedelta(minutes=10),
750 next_poll_after=now - timedelta(minutes=10),
751 recent_backfill_status="completed",
752 ),
753 TrackedActor(
754 actor="~self",
755 is_active=True,
756 discovery_state="queued",
757 queued_for_discovery_at=now - timedelta(minutes=1),
758 next_poll_after=now - timedelta(minutes=1),
759 priority_boosted_at=now,
760 recent_backfill_status="completed",
761 ),
762 ]
763 )
764 db_session.commit()
765
766 results = poller.poll_tracked_actors(db_session)
767
768 assert list(results) == ["~self"]
769 remaining = {
770 actor.actor: actor.discovery_state
771 for actor in db_session.scalars(select(TrackedActor).order_by(TrackedActor.actor)).all()
772 }
773 assert remaining["~self"] == "indexed"
774 assert remaining["~normal"] == "queued"
775
776
777def test_successful_poll_clears_temporary_priority_boost(db_session) -> None:
778 settings = make_settings(INDEXED_ACTOR_REPOLL_SECONDS=3600)
779 poller = PollerService(todo_service=RecordingTodoService(events_by_call=[[]]), git_service=EmptyGitService(), settings=settings)
780 now = datetime.now(tz=UTC)
781 db_session.add(
782 TrackedActor(
783 actor="~self",
784 is_active=True,
785 discovery_state="queued",
786 queued_for_discovery_at=now - timedelta(minutes=1),
787 next_poll_after=now - timedelta(minutes=1),
788 priority_boosted_at=now - timedelta(seconds=30),
789 recent_backfill_status="completed",
790 )
791 )
792 db_session.commit()
793
794 poller.poll_all(db_session, "~self")
795
796 tracked_actor = db_session.scalar(select(TrackedActor).where(TrackedActor.actor == "~self"))
797 assert tracked_actor is not None
798 assert tracked_actor.discovery_state == "indexed"
799 assert tracked_actor.priority_boosted_at is None
800
801
714def test_enqueue_actors_staggers_without_polling(tmp_path, monkeypatch) -> None: 802def test_enqueue_actors_staggers_without_polling(tmp_path, monkeypatch) -> None:
715 database_path = tmp_path / "enqueue.db" 803 database_path = tmp_path / "enqueue.db"
716 username_path = tmp_path / "srht_usernames.txt" 804 username_path = tmp_path / "srht_usernames.txt"
tests/test_migrations.py +8 −1
@@ -122,7 +122,14 @@ def test_alembic_upgrade_adopts_legacy_schema(tmp_path) -> None:
122 assert "discovered_repositories" in inspector.get_table_names() 122 assert "discovered_repositories" in inspector.get_table_names()
123 assert "tracked_actors" in inspector.get_table_names() 123 assert "tracked_actors" in inspector.get_table_names()
124 assert "service_backfill_states" in inspector.get_table_names() 124 assert "service_backfill_states" in inspector.get_table_names()
125 assert {"discovery_state", "queued_for_discovery_at", "next_poll_after", "last_claimed_at", "poll_attempts"} <= tracked_actor_columns 125 assert {
126 "discovery_state",
127 "queued_for_discovery_at",
128 "priority_boosted_at",
129 "next_poll_after",
130 "last_claimed_at",
131 "poll_attempts",
132 } <= tracked_actor_columns
126 133
127 134
128def test_alembic_prefers_database_url_from_environment(tmp_path, monkeypatch) -> None: 135def test_alembic_prefers_database_url_from_environment(tmp_path, monkeypatch) -> None:
tests/test_polling_api.py +2 −2
@@ -21,7 +21,7 @@ class InsertingPoller:
21 self.git_service = service 21 self.git_service = service
22 self.tracked_poll_calls: list[str] = [] 22 self.tracked_poll_calls: list[str] = []
23 23
24 def track_actor_request(self, db, actor: str, *, update_last_requested: bool = True): 24 def track_actor_request(self, db, actor: str, *, update_last_requested: bool = True, prioritize: bool = False):
25 tracked_actor = db.scalar(select(TrackedActor).where(TrackedActor.actor == actor)) 25 tracked_actor = db.scalar(select(TrackedActor).where(TrackedActor.actor == actor))
26 if tracked_actor is None: 26 if tracked_actor is None:
27 tracked_actor = TrackedActor(actor=actor, is_active=True) 27 tracked_actor = TrackedActor(actor=actor, is_active=True)
@@ -61,7 +61,7 @@ class FailingPoller:
61 self.todo_service = service 61 self.todo_service = service
62 self.git_service = service 62 self.git_service = service
63 63
64 def track_actor_request(self, db, actor: str, *, update_last_requested: bool = True): 64 def track_actor_request(self, db, actor: str, *, update_last_requested: bool = True, prioritize: bool = False):
65 tracked_actor = db.scalar(select(TrackedActor).where(TrackedActor.actor == actor)) 65 tracked_actor = db.scalar(select(TrackedActor).where(TrackedActor.actor == actor))
66 if tracked_actor is None: 66 if tracked_actor is None:
67 tracked_actor = TrackedActor(actor=actor, is_active=True) 67 tracked_actor = TrackedActor(actor=actor, is_active=True)