feat(usage): add chunks_stored retention meter - #1279
Conversation
The data plane could bill ingestion and OCR but not retention. The emitted metrics were tokens_embedded, pages_embedded, pages_ocr, bytes_ingested and bytes_stored -- no chunk counter, and bytes_stored is emitted per document per indexing pass, so it is an ingest FLOW. Summing it over a period counts a re-indexed document again on every pass: it measures churn, not what is retained. Record the retained chunk count once per UTC day, split by index mode, as a chunks_stored usage event. A month of readings sums to chunk-DAYS -- the integral of a changing stock -- so a corpus loaded or purged mid-month is charged pro rata, and the existing daily-sum rollup semantics are unchanged. The measurement already existed and was already mode-aware, so this is mostly wiring: count_indexed() and count_hybrid_chunks() are reused as-is, and keyword chunks are derived as total - hybrid so the parts always sum to the total. Event ids are derived from (metric, mode, UTC date). usage_events inserts are already ON CONFLICT (event_id) DO NOTHING, so a pod that restarts five times in a day still contributes exactly one reading per mode -- without this, restarts would silently multiply the billed chunk-days. Counts are exact rather than the gauges' exact=False shortcut: this runs once a day and the figure is billable. CROSS-REPO: chunks_stored is a new metric name, so per migration 007 the control-plane rollup ignores it until its catalog learns it. The mode split rides in the event metadata and the rollup groups by (day, metric) only, so both modes bill as one line until the CP also groups on index_mode. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @cbcoutinho's task in 3m 5s —— View job Review complete ✅
This is a clean, well-scoped PR — three prior review rounds already tightened the empty-corpus fast path, the Correctness
Known, disclosed tradeoff (not a new finding) — the Test coverage — thorough: both modes recorded + keyword derivation, exact (not approximate) counts, empty-corpus short-circuit (asserts Security / performance — no concerns. Counts are Process note: I wasn't able to execute No blocking issues found. Nice attention to the idempotency and race-window details in the write-up. |
Round-1 review nits. Fix a stale cross-reference: the guard being mirrored is record_indexing_usage (vector/processor.py), not _record_indexing_usage -- no leading underscore, and it is not module-private. Extract the next-reading delay from usage_stock_task into _next_reading_delay(). The boundary behaviour (align to UTC midnight, floor at 1s so the task cannot busy-loop exactly at midnight, cap at the configured interval) is now a pure function, so its tests assert the logic directly instead of monkeypatching anyio.move_on_after on the shared module object. Sweeps every minute of a day to prove no clock position yields a zero-length sleep. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Round-1 findings addressed in
You were right that the broad patch was only there because the logic was inline — extracting it removed the need entirely and tests the actual invariant better. 3386 unit tests pass; On the sandbox note: |
Round-2 review nits. Rename the discarded-tuple binding to total_chunks so the call site says what it kept without cross-referencing count_indexed's docstring. Document the usage_events metric catalog in docs/observability.md, which listed only Prometheus series. These are app-DB rows, not Prometheus metrics, so the section says so up front -- otherwise the natural reaction to "chunks_stored" is to go looking for it in Grafana. Splits the catalog into ingest FLOWS (emitted per document per pass, so a period sum measures churn) and the retention STOCK, and records that adding a metric here is a cross-repo change: the control-plane rollup silently ignores a metric its catalog does not know. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Round-2 findings addressed in
42 tests in the touched file pass (3386 unit total); |
Round-3 review nits. Move the total_chunks <= 0 guard above count_hybrid_chunks so a never-indexed tenant costs one Qdrant round-trip instead of three, and assert the saved call in the empty-corpus test. Apply the _next_reading_delay floor last, so max(1.0, min(interval, ...)) covers an operator setting USAGE_STOCK_SNAPSHOT_INTERVAL=0 as well as the midnight boundary. Previously min(interval, max(1.0, ...)) collapsed to 0 for a zero interval and would hot-loop the task against Qdrant -- idempotent, so harmless for billing, but needless load. Document the clamp's tradeoff: under a concurrent upsert between the two counts, max(0, total - hybrid) can also make hybrid + keyword exceed the total the first query observed, so a day's reading may overstate by the chunks written in that window. Bounded and re-read the next day; preferred over holding a consistent read across two counts on a continuously-written collection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Round-3 findings addressed in
3387 unit tests pass; Remaining known gap is the live-stack verification tracked on Deck #1001 — specifically that a re-index must not increase |
Round-4 review nits. A single-mode tenant was getting a row for the unused mode every day forever -- 100% hybrid meant a permanent daily `value=0, index_mode=keyword` row. Absence and an explicit zero are identical to a SUM, so the row was pure accumulation. Guard each mode on `count > 0`, matching the `if bytes_ingested > 0` guards in record_indexing_usage. Use NAMESPACE_DNS with a distinguishing prefix instead of a freshly-minted namespace constant, matching the other uuid5 id builders in this package (placeholder.py, dead_letter.py). Different id space (usage_events primary key vs Qdrant point ids), so the shared namespace costs nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Round-4 findings addressed in
3388 unit tests pass; ruff/format/ty green. |
|



Why
We are moving the price basis off source bytes (a 31x-spread unit across the fleet) onto chunks. Auditing the ingest path against that model found the data plane can bill ingestion and OCR, but cannot bill retention at all:
tokens_embedded,pages_embedded,pages_ocr,bytes_ingested,bytes_stored— there is no chunk counter on the billable path.bytes_storedis emitted per document per indexing pass, so it is an ingest flow. Summing it over a period counts a re-indexed document again on every pass — it measures churn, not what is retained.Retention is the term that compounds: it is the only cost that keeps accruing after the ingest work is done.
What
Records the retained chunk count once per UTC day, split by index mode, as a
chunks_storedusage event. A month of readings sums to chunk-days — the integral of a changing stock — so a corpus loaded or purged mid-month is charged pro rata, and the rollup's existing daily-sum semantics are unchanged.The measurement already existed and was already mode-aware, so this is mostly wiring:
count_indexed()andcount_hybrid_chunks()unchanged (the latter already filters on theindex_modepayload key).total - hybrid— one fewer Qdrant round-trip, and the parts always sum to the total.vector_density_snapshot_taskslow-cadence pattern; spawned at both consumer paths, since omitting the multi-user one would silently un-meter those tenants.Idempotency is the load-bearing detail
Event ids are derived from
(metric, mode, UTC date).usage_eventsinserts are alreadyON CONFLICT (event_id) DO NOTHING, so a pod that restarts five times in a day still contributes exactly one reading per mode. Without this, restarts would silently multiply the billed chunk-days. Verified against a real SQLiteusage_eventstable as well as in unit tests: three identical runs produce 2 rows totalling 1000 chunk-days, not 3000.Counts are exact rather than the gauges'
exact=Falseshortcut — this runs once a day and the figure is billable.Cross-repo sequencing (no merge-order requirement)
chunks_storedis a new metric name, so per migration 007 the control-plane rollup ignores it until the CP catalog and Stripe meter learn it. The mode split rides in the eventmetadata, and the rollup currently groups by(day, metric)only, so both modes bill as one line until the CP also groups onindex_mode.This lands correct and inert: it changes no existing metric, and is gated on
USAGE_METERING_ENABLED.metadatais alreadyJSONBon Postgres — explicitly "so the CP can slice on dimensions later" — so the CP side is a rollup query change, not a migration.Test coverage
15 new unit tests covering the billing properties: both modes recorded, keyword derived (and never negative if an upsert lands between the two counts), exact counts, empty corpus records nothing, metering-disabled no-ops, Qdrant failure does not raise, the row is stamped at UTC midnight of the day it describes, event ids stable across restarts but distinct across days, plus the task loop's immediate-first-reading and its non-zero sleep floor at the day boundary.
Deviation from plan, flagged deliberately: the plan called for a Pact contract test. On inspection that is the wrong tool here — every existing test in
tests/contract/is an HTTP boundary, but the control plane consumesusage_eventsby reading the app DB directly. This is a schema contract, not a Pact-able HTTP one. The metric name and metadata shape are asserted in unit tests instead, and the CP-catalog sync requirement is documented at the metric definition. Happy to add a Pact test if you disagree.Integration verification against a running stack (mixed-mode corpus, then confirming a re-index does not inflate the stock) is still outstanding.
Deck: board 8 #1001. Depends on #1000 for mode-differentiated billing.
This PR was generated with the help of AI, and reviewed by a Human