View Markdown

Journal epoch directories

On this page

The durable journal stores each request identity in one bounded epoch directory. Its records stay together so existing obligations can settle even when new admission is full. This page explains the storage protocol, configuration bounds and recovery behavior.

The Q=32 single-instance profile on e71e5da8 passed the documented release qualification. Qualification of later runtime changes is separate.

What changes#

Durable record protocol#

Every record follows the same order:

  1. Write the file.
  2. Fsync the file.
  3. Atomically rename it into place.
  4. Fsync the containing directory before acknowledging durability.

A step that replaces one record with another - the settled dispatch record replacing its intent, the request's row replacing its request intent - runs as one unit: the new record is written, fsynced and renamed, the directory is fsynced once, and the replaced record is unlinked after that fsync, so the replacement is durable before the old name goes without relying on the filesystem persisting directory operations in issue order. The request intent and the first dispatch intent are written in one unit the same way, immediately before the send. Each step therefore fsyncs its directory once (four times over a request's lifecycle, where the earlier protocol fsynced seven times). The unlink has no additional directory fsync: its persistence can wait because the replacement is already durable. A crash may leave the old intent beside the settled record; recovery keeps the settled record and delivers it once. Before the fused begin's directory fsync completes, a crash may leave either intent alone. No provider send has occurred at that point; surviving intents recover as UNKNOWN, including a dispatch intent without a request intent.

Replay serializes with the identity's writer. It skips a busy identity without blocking unrelated work. Snapshots carry directory-incarnation objects so reusing a slot name cannot validate an old snapshot.

Epoch lifecycle#

The ring uses s00 through the configured last hexadecimal slot.

Transition Required evidence Failure behavior
Prepare a directory Fsync the journal root before use A timed-out waiter does not cancel creation or add another worker
Assign identities Remain within the epoch's cumulative capacity Rotate only to a prepared durable directory
Retire a directory No mapped identities or in-flight pins; empty listing; successful removal and root fsync Retain the slot on failure
Ring exhausted No reusable slot Refuse new accounting identities; do not create directories without bound

Only one preparation and one creation operation may exist. Creating/removing states count toward the retained-epoch limit. Held records and failed cleanup can therefore eventually close new admission.

Record replacement failures#

Failure point Retained state
Before atomic rename Previous PRESENT record remains
Fused begin before its directory fsync Neither, either or both intents may survive; no provider send yet; surviving intents recover as UNKNOWN
Directory fsync after rename New record remains indexed for replay
After a record's directory fsync, before the unlink of the record it replaces (one unit) Both records remain and stay indexed; boot recovery keeps the settled record and discards its intent; replay delivers it once
Uncertain quarantine rename Retain the identity for restart/reconciliation; do not report delivery

The request-side index avoids speculative probes or unlinks for nonexistent intent files.

Configuration#

All settings below have the POINTSMAN_USAGE_JOURNAL_ environment prefix, are documented in docker/.env.example, and are forwarded by the standard Docker Compose service.

Suffix Default Meaning
EPOCH_IDENTITIES 1024 Maximum cumulative new identities assigned to one epoch
MAX_RETAINED_EPOCHS 32 Ring directory limit, 2–256; every lifecycle state counts
EPOCH_MAX_AGE_MS 60000 Soft age rotation when another durable directory is ready
EPOCH_CREATE_WAIT_MS 250 Admission waiter deadline; not a filesystem I/O cancellation guarantee
NAME_BUDGET_BYTES 268435456 Startup ceiling for the configured namespace-memory estimate

The startup estimate is E × R × (3 + 2D) × 320, with D equal to fallback attempts multiplied by the largest retry count in the initialized retry registry (including its default). At D=10 this is 241,172,480 bytes (230 MiB). The coefficient is a Linux allocation estimate, not a portable memory guarantee. Legacy files, quarantine, positive records, JVM, filesystem and other kernel memory still consume resources. Changing provider retry configurations dynamically would require revalidating this estimate; the release configuration uses the registry's startup instances. The configured limits are ignored when the journal is disabled.

The maintenance cadence is 200 ms; it affects cleanup latency, not the number of directories permitted. Shutdown waits up to 10 s for the worker. If filesystem work or record leases remain active, shutdown refuses to release ownership. The process must fully exit before a successor opens its volume.

Bounded journal I/O#

Every journal filesystem unit - a record's open, write, fsync, atomic rename and directory fsync; an unlink or rename with its directory fsync; a quarantine; a replay read - holds one of IO_CONCURRENCY permits (default 16) from its first filesystem call until the last one has returned or thrown. Nothing inside the unit changed: the protocol, its ordering and its acknowledgement boundaries are the ones above. Units beyond the bound park in arrival order (a fair queue, so replay is not starved) without holding a carrier thread or a descriptor. A wait is never timed out and is not ended by an interrupt; a permit is never released while its unit can still mutate the journal.

A NEW request identity - a request's first record, a cache hit's row, a pre-dispatch failure row - is refused while IO_ADMISSION_WAITING units (default 64) are already waiting: 503 accounting_unavailable, counted as admission_refused{reason="io_saturated"}, decided under the index lock before any file, index entry or epoch admission exists, and before any provider send. This is the same boundary journal-max-entries refuses at. Settlements, dispatch records, deletions and replay are never refused; they wait. A request that settles without a provider send (a cache hit, a coalesced delivery) first secures its journal identity with a request intent, before the hit is announced and before anything settles: a refusal there is a clean 503 whose reservation is released, never a settled admission whose record could not land. This costs a cache hit one more record and one more unlink.

Clean refusals and existing obligations#

A clean journal-admission refusal has no optional usage-ledger failure row. It has incurred no provider obligation and must not wait behind the same stalled storage it just refused.

Request state Treatment
Direct request refused at first record Return the clean refusal; release reservation and concurrency
Cache hit or coalesced delivery refused before settlement Do not announce the hit; release its reservation
Stream refused before provider stream starts No provider dispatch; clean refusal
Coalesced follower already settling an output-policy block Preserve the 400 outcome
Partially written record, provider bill or already-owed settlement Keep existing failure-row and recovery handling

X-Pointsman-Request-ID joins the RELEASED reservation rows and refusal metrics; a clean refusal has no dispatch or charge. An absent ledger row does not make the refusal unobservable.

What each bound covers#

The I/O settings bound filesystem units, not complete requests.

Bound Covers Does not cover
IO_CONCURRENCY Journal units currently holding a filesystem permit Other blocking work or the complete JDK carrier pool
IO_ADMISSION_WAITING Queue pressure at which new identities refuse A hard ceiling on all queued units; existing obligations still join
EPOCH_CREATE_WAIT_MS Time a new identity waits for directory creation before I/O admission Number of those waiters
journal-max-entries Admitted journal identities An HTTP request concurrency limit
pointsman.intake.max-in-flight Concurrent /v1 HTTP request lifecycles (default 256) Settlement work continuing after timeout or disconnect

The intake gate returns 503 overloaded. Journal admission returns 503 accounting_unavailable, including io_saturated or epoch_create_timeout reasons as applicable.

Suffix Default Meaning
IO_CONCURRENCY 16 Journal filesystem units running at once, 1-256; about two descriptors per unit. It limits how many journal units block in the kernel at once; it does not bound the JDK carrier pool, which other blocking work also grows and which keeps compensating carriers after a burst
IO_ADMISSION_WAITING 64 Waiting units at which new identities are refused, 1-100000

Qualified profile and storage-pressure observation#

The v1 overlay sets IO_ADMISSION_WAITING=32; the gateway default remains 64 and IO_CONCURRENCY remains 16. A smaller admission depth refuses new work sooner during a storage stall.

One diagnostic per depth added 50 ms write latency for 60 seconds at 50 RPS (30 September 2026):

Observation Depth 64 Depth 32
Descriptor rise +71 +39
Journal queue plateau 64 32
Refused requests 2,685 2,706
Successful requests during stall 313 291
Successful-request p99 during stall 18.2 s 11.5 s

All refusals were clean: no ledger row or provider send, reservation released. These single observations do not establish healthy-burst tolerance or a general performance guarantee. Slow storage still degrades service.

The Q=32 profile later passed the frozen 12-hour and 24-hour qualification on e71e5da8. See release status for scope; this does not change the library default or qualify later runtime changes.

Work outside the permit gate#

Epoch maintenance, replay directory listing/per-record stat, and the quarantine gauge each run on one thread outside the I/O permit gate. Epoch creation stays outside the saturated queue so admissions can obtain a directory without waiting behind that queue. Group commit is not implemented.

Upgrade, corruption and downgrade#

Use one durable volume per replica. Stop the old owner before starting this candidate. Root-level legacy records remain readable and replayable; new identities use epochs. Existing legacy identities settle in place. No bulk flat-layout conversion occurs. Boot plans recovery before deleting any competing record. A valid settled record supersedes its intent only after durability validation. Identical settlements are deduplicated. Conflicting bytes, malformed competing settlements or an identity split between surviving directories refuse startup with the originals unchanged. Conflicting accounting evidence must be reconciled before startup.

Malformed standalone replay records move intact to q/ with collision-resistant names. Quarantine is never automatically erased. Keep the volume and reconcile it against the ledger and reservation manifest. A zero pending gauge only means no replayable records; it does not resolve quarantine. Unknown files and unreadable inventories must not be interpreted as an empty journal.

Held settlement records (EVIDENCE_HELD: a restored database lost their rows) stay in their epoch, retried every minute, counted by gateway_usage_journal_held and alerted by BudgetEvidenceHeld, until an attestation includes them and an operator archives them (POST /admin/budget/held/{event_id}/archive; docs/operating-notes.md). An archived record moves intact to a/ as <event>.<sha256>.held.json: never replayed, never deleted by the gateway, and its epoch slot is released. The gateway and journal-records.py (selector archived) accept a/; a drain refuses it like quarantine. The archive is still read (read-only) as recovery evidence after a restore to before the attestation that included it (docs/operating-notes.md), so keep it on the volume while any restorable backup predates that attestation; move it to the incident record before a downgrade only after that, since an older reader refuses the unknown directory.

Downgrading to a root-only reader is safe only after stopping the gateway and verifying a drained volume:

bash
bash load-test/journal-drain-check.sh /absolute/path/to/stopped-journal

This uses a nonblocking POSIX record lock compatible with Java FileChannel (not flock). It refuses a live owner, missing lock, symlink, unknown entry, temporary record, carried reservation, quarantine or archived held evidence. Keep the gateway stopped between this check and the downgrade. Empty known epoch directories can remain. If any obligation remains, use the newer reader to reconcile/drain it; do not flatten or delete the volume.

Observe and investigate#

  • gateway_usage_journal_epochs{state} and gateway_usage_journal_retained_limit: capacity and lifecycle.
  • gateway_usage_journal_admission_refused_total{reason}: capacity, exhausted ring, creation failure/timeout.
  • gateway_usage_journal_epoch_removals_total / epoch_removal_failures_total{reason}: reclamation progress.
  • gateway_usage_journal_index_repairs_total{kind}: adopted or vanished externally observed records.
  • gateway_usage_journal_quarantine_records: unresolved evidence; negative means inventory unavailable.
  • gateway_usage_journal_io_active, _io_queued, _io_permits: permits in use, units waiting, the bound.
  • gateway_usage_journal_admission_duration_seconds{outcome,reason}: how long a NEW identity's admission decision took (the index lock and any wait for epoch creation), admitted or refused. Diagnostic, fixed buckets.
  • gateway_journal_refusal_stage_seconds{stage,path,outcome}: where a request refused at journal admission spent its time - to_refusal (receipt to the refusal surfacing), abandon (releasing its admission), failure_row (its failure row; outcome is skipped for a clean journal refusal - the row is intentionally not attempted - and written/refused/failed where one was attempted) and total. Refusals slower than 1 s are also sampled to the log at most once a second, with the X-Pointsman-Request-ID value on the line, never as a label.
  • gateway_usage_journal_io_wait_seconds and gateway_usage_journal_io_duration_seconds{op} (record, delete, move, quarantine, replay_read): time waiting for a permit and time holding one.
  • gateway_usage_journal_identities: identities holding records or leases.
  • gateway_requests_accepted_outstanding and gateway_stream_settlements_in_flight: accepted requests not yet settled, and settlements running. Both are observation gauges, not admission caps: nothing refuses a request because of them.
  • Existing pending, delivery, replay failure and held-settlement metrics remain relevant.

Prometheus rules warn when half the ring stays retained for ten minutes, warn on io_saturated refusals (JournalIoSaturated: journal storage slower than the arrival rate), and alert on other non-capacity admission refusals or quarantine. Inspect held reservations, delivery failures, the volume's permissions and free space. Do not relieve pressure by deleting accounting evidence. A failed tmp write may retain a nonempty epoch until boot recovery removes its incomplete temporary file; that consumes a bounded slot and must be investigated.

load-test/journal-records.py supports strict root/epoch inventory, selectors, checksums and the drain check. Live counts are observations, not atomic accounting snapshots. Freeze or stop writers for evidence capture. The reconciler reads both layouts and refuses unknown directories or hidden quarantine obligations.

Quarantine and journal storage: monitoring and the operator procedure#

Quarantine is never erased automatically. That is deliberate: a record the gateway cannot read is evidence, and deleting it silently would turn an accounting question into an accounting loss. Its storage is therefore indefinite until an operator acts, not bounded. Two signals cover it:

  • gateway_usage_journal_quarantine_records and the critical alert JournalQuarantineEvidence (!= 0). This fires on any quarantined record, and also on -1, which means the inventory is unreadable.
  • gateway_usage_journal_volume_usable_bytes and the critical alert JournalVolumeLowSpace. This fires below 1 GiB usable for 5 minutes, or on -1, which means the file store is unreadable. A full volume refuses new request identities with 503 accounting_unavailable; this alert says so first.

To resolve quarantine, with the gateway stopped (or the volume mounted read-only elsewhere):

  1. Inventory: python3 load-test/journal-records.py <journal-root> paths --selector quarantine.
  2. Manifest: python3 load-test/journal-records.py <journal-root> manifest | grep ' q/' > quarantine.sha256. The manifest mode hashes every record with paths relative to the root, and quarantine lives under q/.
  3. Copy off the volume and verify the copy: copy q/ with its relative layout, then run sha256sum -c quarantine.sha256 from the copy's root.
  4. Reconcile each record against the usage ledger and the reservation manifest. A record that still describes unsettled usage is an accounting question, not a storage one.
  5. Remove the verified, reconciled files by hand. Record what was removed, and where the verified copy lives.

drain refuses while quarantine evidence exists, so a non-zero quarantine can only reach zero through this procedure. Never relieve journal-volume pressure by deleting accounting evidence: grow the volume instead.

Pointsman v1 candidate documentation Source: 0e09f5e7

Search guides, reference pages and architecture.

Documentation

Open full-size image