feat(mongodb): move CI telemetry to a ciControl satellite and reclaim things index storage - #583
Conversation
… things index storage
Production audit (2026-09-02): things_v2 held 1,824,527 docs of which 99.75%
were ci-* webhook telemetry written since August, with 64 indexes totalling
3.15 GB for 1.18 GB of logical data (322 MB on disk). Five indexes served
fields no code has ever written, the five v1-era kind_* indexes held only
null entries, and every CI row paid an entry in every index plus the wildcard
text index. Ingest was ~270k rows/day and accelerating.
- Register the `ciControl` collection (ciControl_v1): every ci-* Thing now
lives there via getCiControlCollection(), on a six-index plan (unique
shareId, dashboard sort, status counts, external-id lookups, per-parent
history, TTL on root expiresAt). No text index.
- Retention policy (ciControl/retentionCore.ts): events 14d, job rows 30d,
runs/deployments/previews 90d, entities never; env overrides
THINGTIME_CI_{EVENT,JOB,ACTIVITY}_RETENTION_DAYS (0 = forever).
- Ingest de-noising (ciControl/ingestPolicyCore.ts): the repository row is
upserted by every delivery but records a ci-event only on insert or a
status transition (was 49% of all events as active→active no-ops).
- things index plan: retire the seven dead/moved indexes by name at boot,
swap the five kind_* indexes and the sandbox TTL for partial replacements
(create-then-drop), fall back to drop-then-create only at the 64 cap, and
prune leftover `__rebuild` twins so the migration runner can always start.
- Admin migrations: relocate-ci-control-telemetry (time-budgeted,
insert-if-absent by shareId, expired rows deleted without copying) and
rebuild-things-indexes (one index at a time, unique constraints held by a
same-key partial twin throughout, foreign indexes left alone).
- Migration status carries a storage census per physical collection
(dataBytes/storageBytes/indexBytes/indexes; api.admin-migrations 1.1.0) and
the panel renders it with a bloat badge; the query workbench allowlists
ciControl (api.mongodb-raw-results 1.1.0).
- Docs: audit report (docs/architecture/mongodb-index-storage-audit.md),
FUNDAMENTALS §3 row, README env + storage runbook, TESTING checklist,
DECISIONS entries, schema descriptions.
Verified: unit suites green (collections, ci-control, migrations, schemas,
capabilities), typecheck ratchet at baseline, and a live local run through
the real API: signed webhook deliveries land only on ciControl_v1 with the
expected stamps, the relocation and rebuild migrations converge, and a
concurrent duplicate-shareId probe saw 20,423 E11000 rejections and 0
accepted inserts during the rebuild.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ex storage reclaim Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
🧹 Develop S3 preview removedThe PR-specific alias and every workflow-created develop deployment were removed when this PR closed. The ordinary generated Vercel Preview remains available on the shared development runtime. |
…trol satellite change AST-only update through scripts/graphify; the wrapper activated one portable snapshot and pruned the superseded ones. Markdown changes in this branch were not semantically re-indexed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ist bumps through contractVersion featureVersion is not read by createApiCapabilitiesManifest; contractVersion is. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
🤖 Branch status still computing. The base branch moved, and GitHub had not finished recomputing whether this PR conflicts or is behind after the detector waited 500s — so no branch update was started this round. The next push or the twice-hourly scheduled sweep (minutes :02/:32) re-checks automatically. Posted by the conflict detector at 05:35 UTC, 2026-09-02; this notice is edited in place on re-checks. |
The branch tip (graphify snapshot + capability-version fix) was three commits past the PR head GitHub recorded, with no synchronize event for over 30 minutes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
scripts/graphify update pruned develop's 170 tracked snapshot files (1.4 GB, 37M lines); GitHub then refused to compute the pull request diff and stopped synchronizing its head. This PR carries no graphify-out changes; the graph refresh belongs to the merge-time hooks. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…shot finding Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
🌸 Lopu — reviewed in full. No changes needed; one deploy-ordering note worth writing down.Every check is green, The partial
|
| suite | result |
|---|---|
ingestPolicyCore + retentionCore + ciControlRelocationCore + migrationUiCore |
23/23 pass |
mongodb/indexBudget.test.ts |
8/8 pass |
ciControl/*.test.ts |
42/43 — the one failure is Cannot find module '@vercel/sandbox' |
migrations/*.test.ts |
36/37 — Cannot find module 'tldts' |
components/Schemas/*.test.ts |
5/5 pass |
One thing I want to be explicit about so it isn't misread as a regression: mongodb/queryRunner.test.ts reports 4 failures ("One or more typed BSON values are invalid"). I checked out develop 814ebfdc and got the identical 4 failures, so it tracks the BSON version in my ad-hoc test install, not this PR.
No changes made. Nothing here justified an edit.
Lopu · automated repository review · 0 open CodeQL alerts on 208f4875
Lopu repository reviewLopu reviewed this PR against develop as Thingtime's principal PR and repository manager. Using Claude Opus 5. Lopu made justified improvements and pushed c3166e8 to claude/thingtime-mongodb-index-storage-dffe19. Lopu review — PR #583
Check and alert stateEvery check on this head is green — What I comparedFull head against Reviewed in full: Finding and changeThe index-budget guard measured the plan at rest, not at its peak
This PR converts five v1-era Measured by replaying
This is not a defect: 64 is inclusive, the swap succeeds, and if it ever did not Change (test only, No production code was changed. Nothing else in this PR justified a change. Verified, nothing to change
Observations (deliberately not changed)
ValidationRun in the PR worktree at
DispositionSound, and the best-evidenced change in this repository. Ready to merge. The |
…satellite and reclaim things index storage
|
Lopu review — PR #583 ( All checks are green and there is no CodeQL alert on this head, so this is a Independently confirmed on 8.0.29
Two defects found, both reproduced and fixed in this branch
Both are covered by new unit tests ( Notes, no change made — your call:
Rollout order in §5 looks right to me, and step 2's "re-run until pending is 0" — Lopu |
|
🤖 Lopu review — no defects found. One operational note. The measurement in the code is what makes this reviewable, and I want to Architecture fit. This is consistent with Migration properties, all the ones I'd want on a 1.8M-doc relocation:
💭 Operational note, not a code finding: the code is right; the risk here lives All checks green (API suite, typecheck ratchet, both CodeQL analyses); CodeQL |
…satellite and reclaim things index storage
|
🌸 Lopu review — PR #583 ( All checks green, The migration promised something it did not do
It wasn't. That ordering carries more weight than it looks like it does. Every other plan index is degrade-only while it's missing: the query falls back to a scan of a now-small collection, exactly as the description says. The text index is the one exception — and Fixed in I also renamed two adjacent columns in the What I re-verified live, and what it says about this PRI want to be specific about this, because the result is genuinely unusual:
That The plan-name derivation is the right design, too — the plan is only ever expressed as Things I checked and deliberately did not change
One thing for you, not for me to decide
I left it alone on purpose: the honest fix might be a sweep that Rollout order in the PR body is right and worth following as written: deploy (boot alone prunes the dead indexes), drain No blocking concerns. |
…satellite and reclaim things index storage
|
🌸 Lopu review — PR #583 ( All checks green, Four earlier passes covered the partial- 1. The twin prune could silently unprotect the key it promises to hold
Round 2 found that That window isn't exotic: Replayed the exact sequence on a MongoDB 8.0 replica set — twin created → original dropped → prune runs → 20 duplicate-
Twenty duplicates on a key the migration was reporting as twinned. Fix: the prune now decides per twin. Original present → pure redundancy, dropped exactly as before — and that's the shape that actually parked the collection at the cap in round 2 (an aborted run that created all its twins up front), so it keeps doing the job it was added for. Orphan → left to the rebuild's own 2.
|
…satellite and reclaim things index storage
|
🌸 Lopu review — PR #583 ( All checks green, Six earlier passes have worked over the load-bearing seams — the partial- 1. The advisory storage census could 500 the endpoint that runs this PR's own rollout
That endpoint backs /migrations, which is the only in-app way to run What makes this clearly a defect rather than a judgement call: the code was already written to survive an absent census. Fix: the census is now genuinely advisory — any 2. An unavailable census serialized as
|
…satellite and reclaim things index storage
…ite--to-main [Promote] feat(mongodb): CI telemetry satellite + things index storage reclaim (#583)
|
🌸 Lopu review — PR #583 ( All checks green, Seven earlier passes have worked the data path and, last round, the operator-facing surface. I didn't re-litigate any of it. I went at the one thing none of them had put a number on: what this boot actually costs in index slots on the collection that is already at the cap. The headroom test measures the plan at rest. The boot is not at rest.
The five I replayed
The entire 4-slot headroom the test promises is spent, with nothing left over. This is not a defect. 64 is inclusive, the swap succeeds, and Fix (test only): a companion guard that models occupancy over time and asserts the peak fits, that each pending swap costs exactly one transient slot, and that every swapped original is dropped once its replacement exists. It is a real guard, not a tautology — adding one index to the plan and re-running gives: The steady-state test passing while the peak test fails is precisely the gap. Reverted, back to 37/0. Worth knowing for the rollout: step 1 spends the whole headroom. An ad-hoc operator index or an orphan Verified, nothing to change
Validation
Ready to merge. Rollout order in the README is right: deploy → drain — Lopu, Thingtime's principal developer and repository steward. Using Claude Opus 5. |
Why
The Atlas dashboard showed ~3 GB of index storage against ~300 MB of documents on
things. A read-only audit of the production cluster (2026-09-02) found:things_v2= 1,824,527 docs, 1,177 MB logical (322 MB on disk), 64 indexes = 3,147 MB — at MongoDB's hard cap.ci-event1.37 M,ci-workflow-run434 k), all written since August, ~270 k rows/day and accelerating. Real user content ≈ 600 docs.skipped.kind_1_typeId_*×4,kind_1_deletedAt_*) served fields on zero documents (≈ 685 MB); the five v1-erakind_*indexes held only nulls (≈ 650 MB); the wildcard text index tokenised every CI payload (582 MB); every other general index paid a 51-byteshareIdsuffix per CI row.Full report with measurements and the rollout runbook:
docs/architecture/mongodb-index-storage-audit.md.What
ciControlsatellite collection (ciControl_v1,getCiControlCollection()): everyci-*Thing now lives there — same envelope, same deterministic ids, six purpose-sized indexes, no text index. Nothing outsideapi/utils/ciControl/ever read those rows.ciControl/retentionCore.ts): rootexpiresAt+ TTL — events 14 d,job:rows 30 d, runs/deployments/previews 90 d, entities never. Env:THINGTIME_CI_{EVENT,JOB,ACTIVITY}_RETENTION_DAYS(0= forever).ciControl/ingestPolicyCore.ts): the repository row records an event only on insert or a status transition.thingsindex plan: seven dead/moved indexes retired by name at boot; the fivekind_*indexes and the sandbox TTL become partial (create-then-drop swaps, with a drop-then-create fallback only at the 64 cap); leftover__rebuildtwins are pruned at boot.relocate-ci-control-telemetry(time-budgeted, idempotent, insert-if-absent byshareId, expired rows deleted without copying) andrebuild-things-indexes(one index at a time; unique constraints held by a same-key partial twin throughout; foreign indexes left alone).GET /api/v1/admin/migrationsand the/migrationspanel (document / on-disk / index bytes per physical collection with a bloat badge) —api.admin-migrations1.1.0;ciControlallowlisted in the query workbench —api.mongodb-raw-results1.1.0.Verification
things_v2gained 0 CI rows;ciControl_v1gained 21 events (repository: 1 — the insert), 6 run rows, entities without expiry; stamps 14.0 / 30.0 / 90.0 days.shareIdprobe saw 20,423 E11000 rejections, 0 accepted./migrationspanel checked in the browser at desktop and mobile widths (tables scroll inside their own container, no page overflow).Production rollout (owner)
ciControl_v1./migrations→relocate-ci-control-telemetry(dry run, then confirm; repeat until drained)./migrations→rebuild-things-indexes(dry run, then confirm). Expected:things_v2≈ 4.5 k docs, tens of MB of index, 57 indexes.drop-stale-collection-generationsfor the empty legacy collections; optionally an Atlas compaction to reclaim the collection file's freed pages.Note on graphify
This PR carries no
graphify-outchanges. Runningscripts/graphify update .on the branch pruned the 170 snapshot filesdeveloptracks (1.4 GB, 37 M lines); GitHub then refused to compute the diff ("taking too long to generate") and stopped synchronizing the PR head for 40 minutes. Restoring develop's snapshot set fixed it in seconds. The graph refresh belongs to the merge-time hooks; the report's follow-ups flag the snapshot-in-git problem.🤖 Generated with Claude Code