krz/hutch-stats

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

Commit 01c1b50541

01c1b50541b0dc1d42cbdaa90052f6b94ceba20c

parent: f0393a0b8d

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-11 23:45 UTC

feat: prioritize recent-window backfill before full history

Layout: unified · split

API.md +16
@@ -45,6 +45,7 @@ Contribution ranges:
4545- Contribution read endpoints return zero-filled days, so clients do not need to patch missing dates.
4646- Public contribution reads also register the actor for background indexing. A first lookup may therefore return an empty graph while the scheduler catches up.
4747- Incremental indexing and historical backfill are separate. An actor can be recently indexed without being fully backfilled yet.
48- The service prioritizes a recent visible history window first, then continues deep-history backfill afterward.
4849
4950Background polling:
5051
@@ -112,6 +113,7 @@ Behavior notes:
112113- This endpoint also registers the actor for background indexing and updates the actor's `last_requested_at` timestamp.
113114- The response is always immediate; it does not wait for SourceHut polling to finish.
114115- Historical backfill runs in bounded background batches and may take multiple scheduler passes to complete.
116- The recent visible window is prioritized before full-history backfill so clients can show a useful graph sooner.
115117
116118Example by year:
117119
@@ -135,6 +137,9 @@ Response `200 OK`:
135137 "is_indexed": false,
136138 "last_polled_at": null,
137139 "indexing_state": "pending",
140 "is_recent_window_backfilled": false,
141 "recent_backfill_state": "in_progress",
142 "recent_backfill_completed_at": null,
138143 "is_backfilled": false,
139144 "backfill_state": "in_progress",
140145 "backfill_completed_at": null,
@@ -153,6 +158,9 @@ Response fields:
153158- `is_indexed` boolean: whether the service has already completed at least one successful recent/incremental poll for this actor
154159- `last_polled_at` string or `null`: most recent successful poll time, if any
155160- `indexing_state` string: one of `pending`, `indexed`, or `error`
161- `is_recent_window_backfilled` boolean: whether the prioritized recent history window has completed backfill
162- `recent_backfill_state` string: one of `pending`, `in_progress`, `completed`, or `error`
163- `recent_backfill_completed_at` string or `null`: when recent-window backfill completed, if it has
156164- `is_backfilled` boolean: whether historical backfill has completed for this actor
157165- `backfill_state` string: one of `pending`, `in_progress`, `completed`, or `error`
158166- `backfill_completed_at` string or `null`: when full historical backfill completed, if it has
@@ -169,6 +177,8 @@ Indexing state semantics:
169177
170178Backfill state semantics:
171179
180- recent-window fields:
181 - represent the prioritized visible-history window for client UX
172182- `pending`: the actor has not started historical backfill yet
173183- `in_progress`: historical backfill is actively progressing in bounded background batches
174184- `completed`: historical backfill has completed for all supported services
@@ -226,6 +236,9 @@ Response `200 OK`:
226236 "is_indexed": true,
227237 "last_polled_at": "2026-04-11T18:05:00Z",
228238 "indexing_state": "indexed",
239 "is_recent_window_backfilled": true,
240 "recent_backfill_state": "completed",
241 "recent_backfill_completed_at": "2026-04-11T18:02:00Z",
229242 "is_backfilled": false,
230243 "backfill_state": "in_progress",
231244 "backfill_completed_at": null,
@@ -245,6 +258,9 @@ Response fields:
245258- `is_indexed` boolean
246259- `last_polled_at` string or `null`
247260- `indexing_state` string
261- `is_recent_window_backfilled` boolean
262- `recent_backfill_state` string
263- `recent_backfill_completed_at` string or `null`
248264- `is_backfilled` boolean
249265- `backfill_state` string
250266- `backfill_completed_at` string or `null`
README.md +8 −1
@@ -14,7 +14,7 @@ The current V1 is intentionally narrow and production-oriented:
1414
1515## What It Does
1616
17The service collects SourceHut activity from one or more sr.ht GraphQL services, turns those records into a canonical event shape, aggregates activity by day, and returns zero-filled calendar ranges so the client never has to patch missing dates. It performs both recent incremental polling and bounded historical backfill.
17The service collects SourceHut activity from one or more sr.ht GraphQL services, turns those records into a canonical event shape, aggregates activity by day, and returns zero-filled calendar ranges so the client never has to patch missing dates. It performs recent incremental polling, prioritizes a recent visible-history window for faster UX, and then continues bounded historical backfill.
1818
1919Example use cases:
2020
@@ -208,6 +208,9 @@ Example response:
208208 "is_indexed": true,
209209 "last_polled_at": "2026-04-11T18:05:00Z",
210210 "indexing_state": "indexed",
211 "is_recent_window_backfilled": true,
212 "recent_backfill_state": "completed",
213 "recent_backfill_completed_at": "2026-04-11T18:02:00Z",
211214 "is_backfilled": false,
212215 "backfill_state": "in_progress",
213216 "backfill_completed_at": null,
@@ -235,6 +238,9 @@ Example response:
235238 "is_indexed": true,
236239 "last_polled_at": "2026-04-11T18:05:00Z",
237240 "indexing_state": "indexed",
241 "is_recent_window_backfilled": true,
242 "recent_backfill_state": "completed",
243 "recent_backfill_completed_at": "2026-04-11T18:02:00Z",
238244 "is_backfilled": false,
239245 "backfill_state": "in_progress",
240246 "backfill_completed_at": null,
@@ -326,6 +332,7 @@ The SourceHut-specific assumptions are isolated to the service modules:
326332- `git.sr.ht` polling assumes the actor's repositories are discoverable through the SourceHut GraphQL API
327333- scheduled polling runs in-process, so it is not a distributed scheduler
328334- newly requested actors are indexed asynchronously, so the first public read may be empty until a scheduler or manual poll runs
335- the recent visible-history window is prioritized first, but deep-history backfill can still take many scheduler passes for active users
329336- full historical backfill can take many scheduler passes for active users because it runs in bounded batches
330337- alias management is config-driven; there is no alias CRUD API yet
331338- current deployment model is trusted-operator V1, not a public multi-tenant service
alembic/versions/20260411_0004_recent_backfill_scope.py added +89
@@ -0,0 +1,89 @@
1"""recent backfill scope and actor fields"""
2
3from __future__ import annotations
4
5from alembic import op
6import sqlalchemy as sa
7from sqlalchemy import inspect
8
9
10revision = "20260411_0004"
11down_revision = "20260411_0003"
12branch_labels = None
13depends_on = None
14
15
16def _table_names() -> set[str]:
17 return set(inspect(op.get_bind()).get_table_names())
18
19
20def _column_names(table_name: str) -> set[str]:
21 return {column["name"] for column in inspect(op.get_bind()).get_columns(table_name)}
22
23
24def _service_backfill_needs_upgrade() -> bool:
25 if "service_backfill_states" not in _table_names():
26 return False
27 columns = _column_names("service_backfill_states")
28 if "scope" not in columns:
29 return True
30 unique_constraints = {
31 constraint["name"]
32 for constraint in inspect(op.get_bind()).get_unique_constraints("service_backfill_states")
33 }
34 return "uq_service_backfill_state_actor_service_scope" not in unique_constraints
35
36
37def _upgrade_service_backfill_states() -> None:
38 op.execute(
39 sa.text(
40 """
41 CREATE TABLE service_backfill_states__alembic_new (
42 id INTEGER NOT NULL PRIMARY KEY,
43 actor VARCHAR(255) NOT NULL,
44 service VARCHAR(32) NOT NULL,
45 scope VARCHAR(16) NOT NULL,
46 cursor_json JSON,
47 status VARCHAR(32) NOT NULL,
48 started_at DATETIME,
49 completed_at DATETIME,
50 last_error TEXT,
51 updated_at DATETIME NOT NULL,
52 CONSTRAINT uq_service_backfill_state_actor_service_scope UNIQUE (actor, service, scope)
53 )
54 """
55 )
56 )
57 op.execute(
58 sa.text(
59 """
60 INSERT INTO service_backfill_states__alembic_new
61 (id, actor, service, scope, cursor_json, status, started_at, completed_at, last_error, updated_at)
62 SELECT id, actor, service, 'full', cursor_json, status, started_at, completed_at, last_error, updated_at
63 FROM service_backfill_states
64 """
65 )
66 )
67 op.execute(sa.text("DROP TABLE service_backfill_states"))
68 op.execute(sa.text("ALTER TABLE service_backfill_states__alembic_new RENAME TO service_backfill_states"))
69
70
71def upgrade() -> None:
72 if "tracked_actors" in _table_names():
73 columns = _column_names("tracked_actors")
74 if "recent_backfill_status" not in columns:
75 op.add_column("tracked_actors", sa.Column("recent_backfill_status", sa.String(length=32), nullable=True))
76 op.execute(sa.text("UPDATE tracked_actors SET recent_backfill_status = 'pending' WHERE recent_backfill_status IS NULL"))
77 if "recent_backfill_started_at" not in columns:
78 op.add_column("tracked_actors", sa.Column("recent_backfill_started_at", sa.DateTime(timezone=True), nullable=True))
79 if "recent_backfill_completed_at" not in columns:
80 op.add_column("tracked_actors", sa.Column("recent_backfill_completed_at", sa.DateTime(timezone=True), nullable=True))
81 if "last_recent_backfill_error" not in columns:
82 op.add_column("tracked_actors", sa.Column("last_recent_backfill_error", sa.Text(), nullable=True))
83
84 if _service_backfill_needs_upgrade():
85 _upgrade_service_backfill_states()
86
87
88def downgrade() -> None:
89 pass
src/srht_contrib/jobs/poller.py +128 −45
@@ -18,6 +18,9 @@ from srht_contrib.utils.repositories import canonicalize_repository_name
1818
1919logger = logging.getLogger(__name__)
2020SYNC_OVERLAP = timedelta(hours=24)
21RECENT_BACKFILL_DAYS = 365
22RECENT_BACKFILL_BATCHES_PER_SERVICE = 5
23FULL_BACKFILL_BATCHES_PER_SERVICE = 1
2124
2225
2326class PollerService:
@@ -63,7 +66,12 @@ class PollerService:
6366 def track_actor_request(self, db: Session, actor: str, *, update_last_requested: bool = True) -> TrackedActor:
6467 tracked_actor = db.scalar(select(TrackedActor).where(TrackedActor.actor == actor))
6568 if tracked_actor is None:
66 tracked_actor = TrackedActor(actor=actor, is_active=True, backfill_status="pending")
69 tracked_actor = TrackedActor(
70 actor=actor,
71 is_active=True,
72 recent_backfill_status="pending",
73 backfill_status="pending",
74 )
6775 db.add(tracked_actor)
6876
6977 tracked_actor.is_active = True
@@ -177,32 +185,98 @@ class PollerService:
177185
178186 def _run_backfill_batches(self, db: Session, actor: str) -> int:
179187 tracked_actor = self.track_actor_request(db, actor, update_last_requested=False)
180 if tracked_actor.backfill_status == "completed":
188 if tracked_actor.recent_backfill_status == "completed" and tracked_actor.backfill_status == "completed":
181189 return 0
182190
183 if tracked_actor.backfill_started_at is None:
184 tracked_actor.backfill_started_at = datetime.now(tz=UTC)
185 tracked_actor.backfill_status = "in_progress"
186 tracked_actor.last_backfill_error = None
191 now = datetime.now(tz=UTC)
192 recent_since = now - timedelta(days=RECENT_BACKFILL_DAYS)
187193 db.add(tracked_actor)
188194 db.flush()
189195 total_inserted = 0
190196
191 services = [
192 (self.todo_service.service_name, self.todo_service.fetch_backfill_batch),
193 (self.git_service.service_name, self.git_service.fetch_backfill_batch),
197 recent_services = [
198 (self.todo_service.service_name, self.todo_service.fetch_recent_backfill_batch),
199 (self.git_service.service_name, self.git_service.fetch_recent_backfill_batch),
194200 ]
195 all_complete = True
201 if tracked_actor.recent_backfill_status != "completed":
202 if tracked_actor.recent_backfill_started_at is None:
203 tracked_actor.recent_backfill_started_at = now
204 tracked_actor.recent_backfill_status = "in_progress"
205 tracked_actor.last_recent_backfill_error = None
206 total_inserted += self._run_backfill_scope(
207 db,
208 actor=actor,
209 scope="recent",
210 services=recent_services,
211 batches_per_service=RECENT_BACKFILL_BATCHES_PER_SERVICE,
212 since=recent_since,
213 )
214 recent_statuses = db.scalars(
215 select(ServiceBackfillState.status)
216 .where(ServiceBackfillState.actor == actor)
217 .where(ServiceBackfillState.scope == "recent")
218 ).all()
219 if recent_statuses and all(status == "completed" for status in recent_statuses):
220 tracked_actor.recent_backfill_status = "completed"
221 tracked_actor.recent_backfill_completed_at = datetime.now(tz=UTC)
222 tracked_actor.last_recent_backfill_error = None
223 db.add(tracked_actor)
224 db.flush()
225
226 if tracked_actor.recent_backfill_status == "completed" and tracked_actor.backfill_status != "completed":
227 if tracked_actor.backfill_started_at is None:
228 tracked_actor.backfill_started_at = now
229 tracked_actor.backfill_status = "in_progress"
230 tracked_actor.last_backfill_error = None
231 total_inserted += self._run_backfill_scope(
232 db,
233 actor=actor,
234 scope="full",
235 services=[
236 (self.todo_service.service_name, self.todo_service.fetch_backfill_batch),
237 (self.git_service.service_name, self.git_service.fetch_backfill_batch),
238 ],
239 batches_per_service=FULL_BACKFILL_BATCHES_PER_SERVICE,
240 since=None,
241 )
242 full_statuses = db.scalars(
243 select(ServiceBackfillState.status)
244 .where(ServiceBackfillState.actor == actor)
245 .where(ServiceBackfillState.scope == "full")
246 ).all()
247 if full_statuses and all(status == "completed" for status in full_statuses):
248 tracked_actor.backfill_status = "completed"
249 tracked_actor.backfill_completed_at = datetime.now(tz=UTC)
250 tracked_actor.last_backfill_error = None
251 db.add(tracked_actor)
252 db.flush()
253
254 return total_inserted
255
256 def _run_backfill_scope(
257 self,
258 db: Session,
259 *,
260 actor: str,
261 scope: str,
262 services,
263 batches_per_service: int,
264 since: datetime | None,
265 ) -> int:
266 tracked_actor = self.track_actor_request(db, actor, update_last_requested=False)
267 total_inserted = 0
196268 for service_name, fetcher in services:
197269 state = db.scalar(
198270 select(ServiceBackfillState)
199271 .where(ServiceBackfillState.actor == actor)
200272 .where(ServiceBackfillState.service == service_name)
273 .where(ServiceBackfillState.scope == scope)
201274 )
202275 if state is None:
203276 state = ServiceBackfillState(
204277 actor=actor,
205278 service=service_name,
279 scope=scope,
206280 cursor_json=None,
207281 status="pending",
208282 started_at=None,
@@ -216,47 +290,56 @@ class PollerService:
216290 if state.status == "completed":
217291 continue
218292
219 all_complete = False
220293 if state.started_at is None:
221294 state.started_at = datetime.now(tz=UTC)
222295 state.status = "in_progress"
223296 state.updated_at = datetime.now(tz=UTC)
224 try:
225 result = fetcher(actor=actor, cursor_state=state.cursor_json)
226 inserted = self._insert_events(db, result.events)
227 total_inserted += inserted
228 state.cursor_json = copy.deepcopy(result.cursor_state)
229 state.last_error = None
230 state.updated_at = datetime.now(tz=UTC)
231 if result.complete:
232 state.status = "completed"
233 state.completed_at = datetime.now(tz=UTC)
234 logger.info("Backfill complete for service=%s actor=%s inserted=%s", service_name, actor, inserted)
235 else:
236 logger.info("Backfill batch complete for service=%s actor=%s inserted=%s", service_name, actor, inserted)
237 except Exception as exc:
238 state.status = "error"
239 state.last_error = str(exc)
240 state.updated_at = datetime.now(tz=UTC)
241 tracked_actor.backfill_status = "error"
242 tracked_actor.last_backfill_error = str(exc)
243 db.add(state)
244 db.add(tracked_actor)
245 db.flush()
246 raise
297
298 for _ in range(batches_per_service):
299 try:
300 if since is None:
301 result = fetcher(actor=actor, cursor_state=state.cursor_json)
302 else:
303 result = fetcher(actor=actor, cursor_state=state.cursor_json, since=since)
304 inserted = self._insert_events(db, result.events)
305 total_inserted += inserted
306 state.cursor_json = copy.deepcopy(result.cursor_state)
307 state.last_error = None
308 state.updated_at = datetime.now(tz=UTC)
309 if result.complete:
310 state.status = "completed"
311 state.completed_at = datetime.now(tz=UTC)
312 logger.info(
313 "Backfill complete for scope=%s service=%s actor=%s inserted=%s",
314 scope,
315 service_name,
316 actor,
317 inserted,
318 )
319 break
320 logger.info(
321 "Backfill batch complete for scope=%s service=%s actor=%s inserted=%s",
322 scope,
323 service_name,
324 actor,
325 inserted,
326 )
327 except Exception as exc:
328 state.status = "error"
329 state.last_error = str(exc)
330 state.updated_at = datetime.now(tz=UTC)
331 if scope == "recent":
332 tracked_actor.recent_backfill_status = "error"
333 tracked_actor.last_recent_backfill_error = str(exc)
334 else:
335 tracked_actor.backfill_status = "error"
336 tracked_actor.last_backfill_error = str(exc)
337 db.add(state)
338 db.add(tracked_actor)
339 db.flush()
340 raise
247341
248342 db.add(state)
249343 db.flush()
250344
251 completed = db.scalars(
252 select(ServiceBackfillState.status).where(ServiceBackfillState.actor == actor)
253 ).all()
254 if completed and all(status == "completed" for status in completed):
255 tracked_actor.backfill_status = "completed"
256 tracked_actor.backfill_completed_at = datetime.now(tz=UTC)
257 tracked_actor.last_backfill_error = None
258 elif tracked_actor.backfill_status != "error":
259 tracked_actor.backfill_status = "in_progress"
260 db.add(tracked_actor)
261 db.flush()
262345 return total_inserted
src/srht_contrib/models.py +6 −1
@@ -71,6 +71,10 @@ class TrackedActor(Base):
7171 last_polled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
7272 last_poll_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
7373 last_poll_error: Mapped[str | None] = mapped_column(Text, nullable=True)
74 recent_backfill_status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending")
75 recent_backfill_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
76 recent_backfill_completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
77 last_recent_backfill_error: Mapped[str | None] = mapped_column(Text, nullable=True)
7478 backfill_status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending")
7579 backfill_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
7680 backfill_completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
@@ -79,11 +83,12 @@ class TrackedActor(Base):
7983
8084class ServiceBackfillState(Base):
8185 __tablename__ = "service_backfill_states"
82 __table_args__ = (UniqueConstraint("actor", "service", name="uq_service_backfill_state_actor_service"),)
86 __table_args__ = (UniqueConstraint("actor", "service", "scope", name="uq_service_backfill_state_actor_service_scope"),)
8387
8488 id: Mapped[int] = mapped_column(Integer, primary_key=True)
8589 actor: Mapped[str] = mapped_column(String(255), nullable=False)
8690 service: Mapped[str] = mapped_column(String(32), nullable=False)
91 scope: Mapped[str] = mapped_column(String(16), nullable=False, default="full")
8792 cursor_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
8893 status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending")
8994 started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
src/srht_contrib/schemas.py +3
@@ -28,6 +28,9 @@ class ContributionIndexMetadata(BaseModel):
2828 is_indexed: bool
2929 last_polled_at: datetime | None = None
3030 indexing_state: Literal["pending", "indexed", "error"]
31 is_recent_window_backfilled: bool = False
32 recent_backfill_state: Literal["pending", "in_progress", "completed", "error"] = "pending"
33 recent_backfill_completed_at: datetime | None = None
3134 is_backfilled: bool = False
3235 backfill_state: Literal["pending", "in_progress", "completed", "error"] = "pending"
3336 backfill_completed_at: datetime | None = None
src/srht_contrib/services/aggregator.py +9
@@ -62,6 +62,12 @@ class ContributionAggregator:
6262 is_indexed=calendar.is_indexed,
6363 last_polled_at=calendar.last_polled_at,
6464 indexing_state=calendar.indexing_state,
65 is_recent_window_backfilled=calendar.is_recent_window_backfilled,
66 recent_backfill_state=calendar.recent_backfill_state,
67 recent_backfill_completed_at=calendar.recent_backfill_completed_at,
68 is_backfilled=calendar.is_backfilled,
69 backfill_state=calendar.backfill_state,
70 backfill_completed_at=calendar.backfill_completed_at,
6571 )
6672
6773 def _index_metadata(self, db: Session, actor: str) -> ContributionIndexMetadata:
@@ -81,6 +87,9 @@ class ContributionAggregator:
8187 is_indexed=is_indexed,
8288 last_polled_at=tracked_actor.last_polled_at if tracked_actor is not None else None,
8389 indexing_state=indexing_state,
90 is_recent_window_backfilled=(tracked_actor.recent_backfill_status == "completed") if tracked_actor is not None else False,
91 recent_backfill_state=(tracked_actor.recent_backfill_status if tracked_actor is not None else "pending"),
92 recent_backfill_completed_at=tracked_actor.recent_backfill_completed_at if tracked_actor is not None else None,
8493 is_backfilled=(tracked_actor.backfill_status == "completed") if tracked_actor is not None else False,
8594 backfill_state=(tracked_actor.backfill_status if tracked_actor is not None else "pending"),
8695 backfill_completed_at=tracked_actor.backfill_completed_at if tracked_actor is not None else None,
src/srht_contrib/services/git.py +31 −8
@@ -120,6 +120,24 @@ class GitIngestionService:
120120 return repositories
121121
122122 def fetch_backfill_batch(self, actor: str, cursor_state: dict | None = None) -> BackfillBatchResult:
123 return self._fetch_backfill_batch(actor, cursor_state, since=None)
124
125 def fetch_recent_backfill_batch(
126 self,
127 actor: str,
128 cursor_state: dict | None = None,
129 *,
130 since: datetime,
131 ) -> BackfillBatchResult:
132 return self._fetch_backfill_batch(actor, cursor_state, since=since)
133
134 def _fetch_backfill_batch(
135 self,
136 actor: str,
137 cursor_state: dict | None,
138 *,
139 since: datetime | None,
140 ) -> BackfillBatchResult:
123141 state = {
124142 "discovery_cursor": None,
125143 "discovery_complete": False,
@@ -186,13 +204,18 @@ class GitIngestionService:
186204 log_page = repository.get("log") or {}
187205 commits = log_page.get("results") or []
188206 next_cursor = log_page.get("cursor")
189 events = [
190 normalized
191 for commit in commits
192 if isinstance(commit, dict)
193 for normalized in [self._normalize_commit(actor=actor, repo_name=repo_name, commit=commit)]
194 if normalized is not None
195 ]
207 events: list[NormalizedEvent] = []
208 stop_repository = False
209 for commit in commits:
210 if not isinstance(commit, dict):
211 continue
212 commit_time = parse_datetime((commit.get("author") or {}).get("time"))
213 if since is not None and commit_time < since:
214 stop_repository = True
215 break
216 normalized = self._normalize_commit(actor=actor, repo_name=repo_name, commit=commit)
217 if normalized is not None:
218 events.append(normalized)
196219 logger.info(
197220 "git backfill actor=%s repository=%s commits=%s next_cursor=%s",
198221 actor,
@@ -200,7 +223,7 @@ class GitIngestionService:
200223 len(commits),
201224 bool(next_cursor),
202225 )
203 if next_cursor:
226 if next_cursor and not stop_repository:
204227 state["current_repository"]["cursor"] = next_cursor
205228 else:
206229 state["current_repository"] = None
src/srht_contrib/services/todo.py +37 −5
@@ -296,6 +296,24 @@ class TodoIngestionService:
296296 return TodoPollResult(events=tracker_events, cursor=cursor_time)
297297
298298 def fetch_backfill_batch(self, actor: str, cursor_state: dict | None = None) -> BackfillBatchResult:
299 return self._fetch_backfill_batch(actor, cursor_state, since=None)
300
301 def fetch_recent_backfill_batch(
302 self,
303 actor: str,
304 cursor_state: dict | None = None,
305 *,
306 since: datetime,
307 ) -> BackfillBatchResult:
308 return self._fetch_backfill_batch(actor, cursor_state, since=since)
309
310 def _fetch_backfill_batch(
311 self,
312 actor: str,
313 cursor_state: dict | None,
314 *,
315 since: datetime | None,
316 ) -> BackfillBatchResult:
299317 state = {
300318 "trackers_cursor": None,
301319 "tracker_queue": [],
@@ -349,10 +367,20 @@ class TodoIngestionService:
349367 tracker = data.get("tracker") or {}
350368 tickets_page = tracker.get("tickets") or {}
351369 tickets = tickets_page.get("results") or []
352 current_tracker["tickets_cursor"] = tickets_page.get("cursor")
353 current_tracker["pending_tickets"].extend(
354 [{"id": int(ticket["id"]), "ref": str(ticket.get("ref") or ticket["id"])} for ticket in tickets if isinstance(ticket, dict)]
355 )
370 next_tickets_cursor = tickets_page.get("cursor")
371 stop_tracker_paging = False
372 for ticket in tickets:
373 if not isinstance(ticket, dict):
374 continue
375 if since is not None:
376 updated = parse_datetime(ticket["updated"])
377 if updated < since:
378 stop_tracker_paging = True
379 continue
380 current_tracker["pending_tickets"].append(
381 {"id": int(ticket["id"]), "ref": str(ticket.get("ref") or ticket["id"])}
382 )
383 current_tracker["tickets_cursor"] = None if stop_tracker_paging else next_tickets_cursor
356384 logger.info(
357385 "todo backfill tracker=%s ticket page count=%s next_cursor=%s pending_tickets=%s",
358386 current_tracker.get("name") or current_tracker.get("id"),
@@ -391,6 +419,7 @@ class TodoIngestionService:
391419 )
392420
393421 events: list[NormalizedEvent] = []
422 stop_ticket_paging = False
394423 for event in page_events:
395424 if not isinstance(event, dict):
396425 continue
@@ -402,6 +431,9 @@ class TodoIngestionService:
402431 "tracker": {"name": current_tracker.get("name")},
403432 }
404433 occurred_at = parse_datetime(event["created"])
434 if since is not None and occurred_at < since:
435 stop_ticket_paging = True
436 continue
405437 for change in event.get("changes") or []:
406438 if not isinstance(change, dict):
407439 continue
@@ -415,7 +447,7 @@ class TodoIngestionService:
415447 if normalized is not None:
416448 events.append(normalized)
417449
418 if next_cursor:
450 if next_cursor and not stop_ticket_paging:
419451 state["current_ticket"]["cursor"] = next_cursor
420452 else:
421453 state["current_ticket"] = None
tests/test_contributions_api.py +7
@@ -48,6 +48,8 @@ def test_contributions_api_returns_zero_filled_range(client: TestClient, db_sess
4848 assert response.status_code == 200
4949 assert response.json()["is_indexed"] is True
5050 assert response.json()["indexing_state"] == "indexed"
51 assert response.json()["is_recent_window_backfilled"] is False
52 assert response.json()["recent_backfill_state"] == "pending"
5153 assert response.json()["days"] == [
5254 {"date": "2026-03-28", "count": 0, "score": 0.0},
5355 {"date": "2026-03-29", "count": 0, "score": 0.0},
@@ -63,6 +65,9 @@ def test_public_read_registers_actor_for_lazy_indexing(client: TestClient, db_se
6365 assert response.status_code == 200
6466 assert response.json()["is_indexed"] is False
6567 assert response.json()["indexing_state"] == "pending"
68 assert response.json()["is_recent_window_backfilled"] is False
69 assert response.json()["recent_backfill_state"] == "pending"
70 assert response.json()["recent_backfill_completed_at"] is None
6671 assert response.json()["is_backfilled"] is False
6772 assert response.json()["backfill_state"] == "pending"
6873 assert response.json()["backfill_completed_at"] is None
@@ -110,6 +115,8 @@ def test_contribution_stats_api(client: TestClient, db_session) -> None:
110115 assert response.json()["current_streak"] == 2
111116 assert response.json()["is_indexed"] is True
112117 assert response.json()["indexing_state"] == "indexed"
118 assert response.json()["is_recent_window_backfilled"] is False
119 assert response.json()["recent_backfill_state"] == "pending"
113120 assert response.json()["is_backfilled"] is False
114121
115122
tests/test_ingestion.py +57 −4
@@ -41,6 +41,15 @@ class RecordingTodoService:
4141 def fetch_backfill_batch(self, actor: str, cursor_state: dict | None = None) -> BackfillBatchResult:
4242 return BackfillBatchResult(events=[], cursor_state=None, complete=True)
4343
44 def fetch_recent_backfill_batch(
45 self,
46 actor: str,
47 cursor_state: dict | None = None,
48 *,
49 since: datetime,
50 ) -> BackfillBatchResult:
51 return BackfillBatchResult(events=[], cursor_state=None, complete=True)
52
4453
4554class EmptyGitService:
4655 service_name = "git"
@@ -61,6 +70,15 @@ class EmptyGitService:
6170 def fetch_backfill_batch(self, actor: str, cursor_state: dict | None = None) -> BackfillBatchResult:
6271 return BackfillBatchResult(events=[], cursor_state=None, complete=True)
6372
73 def fetch_recent_backfill_batch(
74 self,
75 actor: str,
76 cursor_state: dict | None = None,
77 *,
78 since: datetime,
79 ) -> BackfillBatchResult:
80 return BackfillBatchResult(events=[], cursor_state=None, complete=True)
81
6482
6583class BackfillingTodoService:
6684 service_name = "todo"
@@ -82,6 +100,15 @@ class BackfillingTodoService:
82100 )
83101 return BackfillBatchResult(events=[event], cursor_state=None, complete=True)
84102
103 def fetch_recent_backfill_batch(
104 self,
105 actor: str,
106 cursor_state: dict | None = None,
107 *,
108 since: datetime,
109 ) -> BackfillBatchResult:
110 return self.fetch_backfill_batch(actor, cursor_state)
111
85112
86113class QueueShrinkingTodoService:
87114 service_name = "todo"
@@ -100,6 +127,15 @@ class QueueShrinkingTodoService:
100127 state["tracker_queue"].pop(0)
101128 return BackfillBatchResult(events=[], cursor_state=state, complete=False)
102129
130 def fetch_recent_backfill_batch(
131 self,
132 actor: str,
133 cursor_state: dict | None = None,
134 *,
135 since: datetime,
136 ) -> BackfillBatchResult:
137 return self.fetch_backfill_batch(actor, cursor_state)
138
103139
104140class QueueShrinkingGitService:
105141 service_name = "git"
@@ -128,6 +164,15 @@ class QueueShrinkingGitService:
128164 state["repository_queue"].pop(0)
129165 return BackfillBatchResult(events=[], cursor_state=state, complete=False)
130166
167 def fetch_recent_backfill_batch(
168 self,
169 actor: str,
170 cursor_state: dict | None = None,
171 *,
172 since: datetime,
173 ) -> BackfillBatchResult:
174 return self.fetch_backfill_batch(actor, cursor_state)
175
131176
132177def make_settings(**overrides) -> Settings:
133178 values = {
@@ -507,14 +552,18 @@ def test_poll_marks_backfill_complete_and_persists_service_state(db_session) ->
507552
508553 tracked_actor = db_session.scalar(select(TrackedActor).where(TrackedActor.actor == "~ccleberg"))
509554 service_states = db_session.scalars(
510 select(ServiceBackfillState).where(ServiceBackfillState.actor == "~ccleberg").order_by(ServiceBackfillState.service)
555 select(ServiceBackfillState)
556 .where(ServiceBackfillState.actor == "~ccleberg")
557 .order_by(ServiceBackfillState.scope, ServiceBackfillState.service)
511558 ).all()
512559
513560 assert inserted == 1
514561 assert tracked_actor is not None
562 assert tracked_actor.recent_backfill_status == "completed"
563 assert tracked_actor.recent_backfill_completed_at is not None
515564 assert tracked_actor.backfill_status == "completed"
516565 assert tracked_actor.backfill_completed_at is not None
517 assert [state.service for state in service_states] == ["git", "todo"]
566 assert [f"{state.scope}:{state.service}" for state in service_states] == ["full:git", "full:todo", "recent:git", "recent:todo"]
518567 assert all(state.status == "completed" for state in service_states)
519568
520569
@@ -525,7 +574,9 @@ def test_backfill_cursor_state_shrinks_across_repeated_polls(db_session) -> None
525574 first_states = {
526575 state.service: state.cursor_json
527576 for state in db_session.scalars(
528 select(ServiceBackfillState).where(ServiceBackfillState.actor == "~ccleberg")
577 select(ServiceBackfillState)
578 .where(ServiceBackfillState.actor == "~ccleberg")
579 .where(ServiceBackfillState.scope == "full")
529580 ).all()
530581 }
531582
@@ -533,7 +584,9 @@ def test_backfill_cursor_state_shrinks_across_repeated_polls(db_session) -> None
533584 second_states = {
534585 state.service: state.cursor_json
535586 for state in db_session.scalars(
536 select(ServiceBackfillState).where(ServiceBackfillState.actor == "~ccleberg")
587 select(ServiceBackfillState)
588 .where(ServiceBackfillState.actor == "~ccleberg")
589 .where(ServiceBackfillState.scope == "full")
537590 ).all()
538591 }
539592