feat(exemplar): disk budget arithmetic inside 8 GiB - #211
Merged
Conversation
Implements the frozen #201 contract. Q1 - 8 GiB budget table documented in CLAUDE.md (4.5 GiB main relational tier, 1.5 GiB aggregate.db, 0.5 GiB DLQ, 0.5 GiB WAL/temp, 1 GiB mandatory headroom) with per-component measured high-water gauges. Q2 - EXEMPLAR_BYTES_GLOBAL_WINDOW default 8 MiB -> 3 MiB. New EXEMPLAR_RETENTION_DAYS=2 drives a separate transactional purge of exemplar traces, spans, logs, their FTS rows and expired weak references, running ahead of the HOT_RETENTION_DAYS purge on the same hourly tick. Aggregate retention stays 7 days. Q3 - synthesized logs are metered: every one reserves len(body)+len(attributesJSON)+logRowFixedBytes against the selected trace's per-trace budget and the shared per-service/global window budgets, under EXEMPLAR_SYNTH_LOGS_PER_SPAN=8 and EXEMPLAR_SYNTH_LOGS_PER_TRACE=64. Refusals drop the log, count synth_per_span|synth_per_trace|budget_bytes, and stamp the trace truncated. Q4 - reservation lifecycle replaces immediate charging: reserve before row construction, commit when the primary queue or DLQ accepts the batch, release only when the row never reached a destination. Reserved bytes bind the cap. Bytes accepted downstream are never refunded on selection eviction; count slots still are. Q5 - disk watchdog with staged shedding and hysteresis. statfs on DATA_DISK_PATH is the enforcement source, ceiling = min(DATA_DISK_BUDGET_MB, usable volume capacity). >=90% admits only error exemplars; >=95% disables all raw admission and the exemplar DLQ fallback, purges the expired exemplar tier, checkpoints the WAL and fails readiness. Recovery needs <90% and <85% respectively. Raw shedding never fails a successful Export; an authoritative aggregate commit hitting ENOSPC/SQLITE_FULL does, with RESOURCE_EXHAUSTED.
aksOps
marked this pull request as ready for review
August 22, 2026 13:58
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Implements the frozen contract from #201 (map #195, parent #194). Branched from
origin/mainat 8f0f524 (post-#210).Q1 — 8 GiB budget table
aggregate.dbDLQ_MAX_DISK_MBcap-wal/-shmsidecars of both DBs, SQLite temp, TLS, transient maintenanceDocumented in
CLAUDE.md(no separate budget doc exists). Every component gets a measured high-water gauge:otelcontext_disk_component_bytes{component}andotelcontext_disk_component_high_water_bytes{component}formain_db|aggregate_db|dlq|wal. Unused allocation in one tier never authorizes another to consume the final 1 GiB — the tiers are separate gauges, not a shared pool.Q2 — Exemplar retention + window budget
EXEMPLAR_BYTES_GLOBAL_WINDOWdefault 8 MiB → 3 MiB. 4 MiB remains configurable, is not the default. Per-service stays 512 KiB.EXEMPLAR_RETENTION_DAYS=2, validated1..HOT_RETENTION_DAYS. DrivesRepository.PurgeExemplarsBatched: exemplar traces + the spans that batch orphans inside one transaction per batch, then logs (FTS rows follow via the content-linkedAFTER DELETEtrigger — verified in test), then a sweep of expired weak references left by a cancelled pass. Runs on the existing purge tick ahead of the 7-day purge. Aggregate retention unchanged at 7 days.AGGREGATE_MODE=aggregate. Judgment call: in legacy/shadow the raw rows are the dataset, so a 2-day purge would be data loss rather than budget enforcement.Q3 — Synthesized-log metering
AllowSynthesizedLog(severity floor only) is replaced byReserveSynthesizedLog, which reserveslen(body)+len(attributesJSON)+logRowFixedBytesagainst the selected trace's per-trace budget AND the shared per-service/global window budgets, under newEXEMPLAR_SYNTH_LOGS_PER_SPAN=8/EXEMPLAR_SYNTH_LOGS_PER_TRACE=64. Refusals drop the log, incrementotelcontext_exemplar_dropped_total{signal="logs",reason}withsynth_per_span|synth_per_trace|budget_bytes, and stamp the trace truncated. They do not consume the ordinary log-exemplar quota. A cheapSynthesizedLogEligiblepre-gate keeps INFO span events from paying for a JSON marshal before refusal.Q4 — Reservation lifecycle
ChargeSpan/AdmitLog→ReserveSpan/ReserveLogtaking an*ExemplarReservation. Reserve before row construction → commit when the primary queue or DLQ accepts the batch → release only when the row never reached a destination (dropped pre-submission, or both destinations refused). Reserved bytes bind the cap exactly like committed ones. Once accepted the charge is monotonic for that window: eviction releases the count slot and never the bytes.Batch.Reservationcarries the charge to the submit boundary;submitExemplarssettles it.Q5 — Disk watchdog
internal/storage/disk_watchdog.go.statfsonDATA_DISK_PATHis the enforcement source (build-tagged shims for linux/darwin/other, stdlibsyscall, no new dependency); ceiling =min(DATA_DISK_BUDGET_MB, usable volume capacity). ≥90% →errors_only(error exemplars only; healthy/slow/WARN off). ≥95% →raw_off(all raw admission off, exemplar DLQ fallback closed, immediate expired-exemplar purge +wal_checkpoint(TRUNCATE),/ready→ 503). Hysteresis: leaveraw_offbelow 90%,errors_onlybelow 85%. A failedstatfsholds the current state rather than shedding on a syscall error.Raw shedding never converts a successful aggregate Export into a retryable failure. The exception:
aggregate.IsDiskFullclassifiesENOSPC/EDQUOT/SQLITE_FULLon the authoritative commit path and maps it toRESOURCE_EXHAUSTED(429 over HTTP OTLP), so the Export fails and the client retries. Shadow mode still swallows it — there the legacy raw path is the source of truth.Test results (real)
New tests: reservation commit/release/idempotence/merge, reserved bytes binding the cap, committed bytes surviving post-acceptance eviction with the count slot still released, span-slot vs synthesized-log release, Export-boundary commit and release; synthesized-log per-span/per-trace/byte caps with truncation stamping and quota isolation; shedding ladder + hysteresis transition table over a fake
statfs, ceiling clamping, stat-failure hold, high-water marks; exemplar purge (2-day gone incl. FTS, hot-tier intact, weak-reference sweep, transactional rollback, scheduler wiring, on-demand trigger);IsDiskFullclassification incl. adjacent non-disk-full errors;ENOSPC/SQLITE_FULL→ Export failure at bothapplyAggregateandTraceServer.Export; readiness disk probe; config validation for all four new knobs.Judgment calls
statfs, not summed component file sizes. Per the contract, file sizes are attribution gauges only.PurgeTracesBatchedorphan sweep). Implemented as that, bounded tostart_time < cutoffso clock-skewed spans under a live trace are never swept.ENOSPCmaps toRESOURCE_EXHAUSTED, notUNAVAILABLE, so the existing HTTP OTLP mapping produces a retryable 429.otelcontext_prefix, matching the OTLP metrics completeness scope for aggregate mode #199/Aggregate metadata lifecycle: dictionary GC and miner-state persistence #200 surface rather than the olderOtelContext_gauges.Unverified
The 4.5/1.5/0.5/0.5/1 GiB split is arithmetic and policy; only the seven-day gate (#202) can confirm the real amplification factor. The
2xDB/index/FTS amplification is the contract's provisional assumption, carried into the docs as provisional. Nothing here was run against a real 8 GiB volume under load — the watchdog is exercised through an injectedstatfs.