The short version: An invoice line is a promise. Once you have shown a customer their April total, that number cannot quietly change because a late event trickled in. usageDb encodes that promise in the period lifecycle. A billing period is Open or Closed. Closing it captures a frozen snapshot of the totals, stored on the manifest. After close, new Usage events in the period are rejected at ingest, but Correction and Retraction events are accepted and surfaced as named, summed adjustments. The period endpoint reports the frozen total plus a list of pending adjustments plus a net_total, so the bill that changes always changes for a reason you can point to.
This is Part 9 of the usageDb internals series. By now the database can ingest durably, deduplicate, store events in columnar segments, roll them up by the hour, and serve queries. All of that produces a number: the sum of metered quantity for an account in a month. This article is about the moment that number becomes a commitment, and how usageDb keeps it stable afterward.
(Previous: Part 8, compaction. Next: Part 10, property tests and simulation testing.)
Why a usage database needs a "close" verb
A general-purpose database has no opinion about whether you should keep writing to last month. A billing-aware one must. The day you generate April invoices, April's totals are no longer a live aggregate that drifts as data lands. They are a figure a customer has seen, possibly paid, possibly disputed. If a delayed collector then pushes a stray April event into the store, a naive "sum everything in April" query silently produces a different number than the one on the invoice. Now your reported total and your billed total disagree, and nobody can say why they diverged.
The fix is to make closing a period an explicit, recorded act. A period in usageDb is a (year, month) tuple (UTC, per the spec simplification) and its lifecycle is intentionally minimal: Open by default, or Closed. The helpers live in src/period.rs: period_for_ts maps an event timestamp to its period, parse_period reads the YYYY-MM URL form, and is_period_closed / find_closed look the period up in the manifest's closed_periods list.
Closing a period captures a frozen snapshot
You close a period with POST /v1/accounts/{id}/periods/{YYYY-MM}/close. The handler does not just flip a flag. Before it writes the closed marker, it runs the period total as a rollup query and stores the result on the manifest entry. The snapshot is three fields, and they are deliberately Option so that an older build's entry (written before snapshot support existed) still deserializes:
pub struct ClosedPeriod {
pub account_id: String,
pub year: u16,
pub month: u8,
pub closed_at_ms: i64,
#[serde(default)] pub frozen_quantity: Option<i128>,
#[serde(default)] pub frozen_event_count: Option<u64>,
#[serde(default)] pub watermark_at_close_ms: Option<i64>,
}
The order matters. The snapshot is taken before the closed marker is added, because the marker only blocks new events at ingest; it does not touch what is already in segments and rollups. So the snapshot reflects the state exactly as it stood at close time. The watermark_at_close_ms records the rollup watermark (from Part 6) so an operator can later reconcile the frozen figure against what the rollup system had sealed.
Closing is idempotent. If the period is already closed, the handler returns the existing entry and explicitly does not refresh the snapshot, so re-running a billing job never silently recaptures a different total. The write goes through a commit_manifest_if helper that re-checks the closed list inside the write lock, so two concurrent close calls cannot both push an entry. The struct and close handler are in src/storage/manifest.rs and src/api/http_server.rs.
After close: reject usage, accept adjustments
Once the marker is on the manifest, the ingest path treats the period differently. During validation and classification, every incoming Usage event has its timestamp mapped to a period; if that period is closed for the account, the event goes into the rejected bucket, the same counter introduced back in Part 3 alongside duplicates and conflicts:
if matches!(event.kind, EventKind::Usage) {
if let Some((year, month)) = period_for_ts(event.timestamp_ms) {
if closed_snapshot.iter().any(|p| p.account_id == event.account_id.0
&& p.year == year && p.month == month)
{
rejected += 1; // visible in the batch response
continue; // never enters the WAL or memtable
}
}
}
This is the load-bearing line. A late Usage event for a closed period does not get to silently move the total; it is refused, counted, and logged. The collector sees a non-zero rejected count in the batch response and knows something arrived too late.
But "refuse everything" is too blunt for billing. Real corrections happen after close: a metering bug overcounted, a customer was credited, a charge was reversed. Those are not stray usage; they are deliberate accounting adjustments, and must be allowed in. So Correction and Retraction events are intentionally not subject to the closed-period check. They pass through ingest and land in the store, where the period endpoint can pick them out.
The Correction and Retraction event kinds
An adjustment is not a free-form edit. The event model in src/model/event.rs defines EventKind as Usage, Correction, or Retraction, and a Correction or Retraction must carry a correction_ref: the original_event_id it adjusts plus a free-text reason. Validation enforces this; an adjustment with no reference is rejected. That requirement is what turns a correction from "the number changed" into "this credit, citing this original event, for this reason."
The arithmetic is handled at rollup time, and it is the same mechanism corrections use everywhere else in the database. The rollup builder simply folds each event's quantity into the running sum with a saturating add:
entry.quantity_sum = entry.quantity_sum.saturating_add(event.quantity);
entry.event_count += 1;
Because quantity is a signed i128, a correction carrying a negative quantity nets out the original, so the aggregate reads SUM = original + correction with no special case (this is the same correction behavior covered in Part 6). And because every event keeps its kind, queries can filter or group by it to isolate adjustments for forensics, which is exactly what the period endpoint does next.
What GET /periods/{YYYY-MM} returns
For an open period, GET /v1/accounts/{id}/periods/{YYYY-MM} returns a live total: the current sum over the period, served from rollups with a raw fallback for the open tail above the watermark. That is what you want while the month is still accumulating.
For a closed period it returns the promise plus the audit trail:
- the frozen snapshot (
frozen.quantity,frozen.event_count) captured at close time; - pending_adjustments, a raw scan of the
Correction/Retractionevents that landed in the period, returned as full rows for inspection; - adjustments_quantity, those adjustments summed;
- net_total = frozen.quantity + adjustments_quantity, the value an invoice would show today.
A worked example: a closed April, then a late credit
Suppose April closes with a frozen total of 100 over its events. The snapshot is on the manifest; April is sealed. A week later you discover an overcount on one original event and post a single Correction with quantity -40, citing that event's id and a reason. It is accepted at ingest (corrections are never period-rejected). Now GET /periods/2026-04 returns:
| Field | Value | Meaning |
|---|---|---|
frozen.quantity | "100" | unchanged; the figure on the invoice |
pending_adjustments | [ { event_id: "corr", quantity: -40, ... } ] | one named, reasoned adjustment |
adjustments_quantity | "-40" | sum of adjustments |
net_total | "60" | what the bill shows today |
The crucial property: the frozen 100 did not move. The total fell to 60, but it fell because of one adjustment you can read, attribute, and explain. This is the difference between "the bill changed and nobody knows why" and "here is the frozen total plus one named adjustment of minus forty." The behavior is pinned by a test in tests/period_lifecycle.rs (a post-close correction must surface as an adjustment, never alter the frozen snapshot), with the broader invariant covered in tests/billing_safety.rs.
Reopening, and an honest back-compat note
Sometimes a period was closed in error, or a correction is large enough that you would rather restate than adjust. POST /v1/accounts/{id}/periods/{YYYY-MM}/reopen removes the closed marker. The frozen snapshot is discarded, the period returns to a live total, and new Usage events for it are accepted again. Re-closing later takes a fresh snapshot of whatever the totals are then. Reopen is the deliberate escape hatch: restating a closed period is allowed, but it requires an explicit action, not a silent recompute.
One honest detail about backward compatibility. The frozen fields are Option because a ClosedPeriod written by a build that predates snapshot support has none. When the endpoint encounters such a legacy entry, it cannot report a true frozen figure, so it falls back to a live total and attaches a warning field plus a live_total_fallback value, rather than pretending it has a snapshot it does not. That is the right tradeoff for a young system: never fabricate a frozen number, and tell the caller plainly when one is missing.
Why this is the billing-safety story
Everything earlier in the series protects the integrity of an individual event: it is durable, deduplicated, immutable, correctly aggregated. The period lifecycle protects the integrity of a statement made to a customer. Closing freezes the figure you reported. Rejecting late usage stops it drifting behind your back. Accepting corrections, while requiring a reference and surfacing them separately, means every change to a bill is visible and attributable. The frozen snapshot is never overwritten; net_total recomputes from it plus a list you can audit line by line. Once you have shown someone their April total, it stays put, and anything that moves the bill afterward shows up as a named, reasoned, summable adjustment.
usageDb internals: the full series
- Why a purpose-built usage database
- The ingest path and durability contract
- Idempotency and deduplication
- The columnar segment format
- The manifest and crash recovery
- Hourly rollups and the watermark
- The query engine
- Compaction
- Period lifecycle and frozen snapshots
- Property tests and simulation testing
The code is open source at github.com/pbudzik/usagedb. usageDb is the storage engine behind UsageBox.
Part 9 of the usageDb internals series. usageDb is an open-source, append-only usage database written in Rust for AI billing workloads, where idempotency and an immutable audit trail matter more than general-purpose query power. Next up: Part 10, how property tests and deterministic simulation testing keep these invariants honest.