feat: Phase 1 — api/sys persistence, /correlate, sentinel request_id, structured filters#63
Conversation
Persist the api and sys streams to a per-stream SQLite store so they gain queryable history beyond the bounded ring. Opt-in and default-off — without a store, both streams stay ring-only (backward compatible). - StreamStore (Python store/stream.py, Go stream_store.go): table per stream, full row as JSON payload + indexed columns (request_id, timestamp, level, service_id, event, method, path, status). Returns rows newest-first, byte-identical to what was pushed, with the same filter semantics as that language's ring so store and ring reads agree. - Write-through transport: the ring stays the hot write path; the store is a write-through sink. Reads for a persisted stream come from the store (durable depth); unpersisted streams read the ring. - Wiring reuses the completeness seam: when a stream store is configured it joins persisted_streams / persistedStreams, so the reader flag flips ring -> store with no handler change. Python resolves via FASTEN_API_DSN / FASTEN_SYSLOG_DSN or init(api_store=, syslog_store=); Go via Config.APIStore / Config.SyslogStore (caller owns the *sql.DB). - Go QueryAPI/QuerySyslog now return an error, surfaced as HTTP 500 like the audit reader; SQLite-only in v1 (Postgres stream store is a later phase). Tests: store roundtrip/filter, depth-beyond-ring, completeness flip to store, ring-only fallback, write-through sync — Python (executed, green) and Go (vet/build clean; executed in CI). Refs #58
Add a single reader endpoint that returns every stream for one request_id,
so a consumer holding an id gets the whole operation in one call instead of
stitching three. Fans out to the existing per-stream query paths (audit
store + sys/api rings-or-stores) and assembles them — no new query
semantics.
Response: { request_id, audit[], api[], sys[], counts{}, completeness{} }.
completeness reports each stream's durability class (store vs ring) so the
consumer knows whether a stream could be silently truncated; once api/sys
persist (FR1) the historical-investigation case recovers rows past the ring
window, which the tests pin.
Python: GET /correlate, request_id required (422 if missing). Go:
GET /correlate, request_id required (400 if missing), query errors surfaced
as 500 like the audit reader. Tests cover assembly, request_id isolation,
empty/unknown id, missing-param, and the persisted depth-beyond-ring case —
Python executed green; Go vet/build clean, executed in CI.
Refs #58
Guarantee that every sys/api stream row carries a non-empty request_id, so /logs/correlate is a contract rather than best-effort: today a context-less row (boot, init, background work, lost-context async) has no request_id and silently drops out of any correlated view. - Sentinel namespaces (boot/sched/bg/lib/orphan) with mint + classify helpers: Python context.mint_sentinel/request_id_kind/is_sentinel; Go MintSentinel/RequestIDKind/IsSentinel. The prefix tells an operator what kind of context produced the row instead of "anonymous noise". - Enforced at the transport push chokepoint so every path (shims, middleware, drainer) is covered: a missing request_id is filled with the shared per-process boot id during startup, then a unique orphan id once the first real request has been seen. Explicit ids are never touched. - boot id is minted once per process in Init and passed to the transport, so /correlate?request_id=boot-<svc>-<id> returns the whole startup window (boot-error diagnosis becomes a real workflow). Tests (Python executed green; Go vet/build clean, runs in CI): mint/classify, boot-then-orphan transition, explicit id untouched, boot rows correlatable, and a conformance check that no stream row across a mixed workload ever lacks a request_id. Refs #58
Expose the fields the StreamStore already indexes as first-class reader query params, so discovery by something an operator knows (an event name, a status code, a time window) is a fast indexed lookup instead of a scan or a free-text search: - /logs/sys gains ?event= and ?since=/?until= - /logs/api gains ?status= and ?since=/?until= Filters are honoured identically whether the stream is ring-only or persisted (store reads add WHERE clauses on the indexed columns; ring reads filter in memory with the same semantics), so behaviour doesn't change with persistence. Go: QuerySyslog/QueryAPI now take a StreamQuery filter struct (no more positional churn as filters grow); StreamStore.Query takes a since/until window. Tests assert each filter on the ring and prove store/ring agree — Python executed green; Go vet/build clean, runs in CI. Refs #58
Multi-agent review follow-ups: - Write-through resilience (parity + robustness): Python transport now wraps each stream-store insert and swallows failures to stderr, matching Go, so a persistence sink failure can never break the hot logging path (previously a raise could propagate through the HTTP shim's finally and corrupt the response). New test injects a failing store and asserts the ring copy survives and the push returns normally. - Null-timestamp window agreement: stores now COALESCE(timestamp,'') in the since/until predicates so a timestamp-less row (e.g. a sentinel boot/orphan row) is filtered identically to the ring, which treats a missing timestamp as "". New ring-vs-store agreement test. - Doc accuracy: drop the "byte-for-byte identical to what was pushed" claim from both StreamStore docs (JSON round-trip normalises numbers — int decodes to float64 in Go) and note the SQL LIKE wildcard caveat on the Python path filter. - Document why Python _boot_over needs no lock (GIL + monotonic flag) where Go uses bootMu. - Added coverage: combined filters compose (status + since), limit truncates to the newest N. Refs #58
…rity) Resolves the cross-language filter divergence from the Phase 1 review: the Python ring + store matched method case-insensitively and path by substring, while the Go SDK matched both exactly. /api?path=checkout returned a row in Python and nothing in Go for the same deployment. Python now matches method and path exactly in both the ring (store/ring.py) and the persisted store (store/stream.py), so the two SDKs are interchangeable on these filters. Tests updated to assert exact-match in both languages (case-folded / substring queries now correctly return nothing). Note: `level` is still lowercased on the Python filter side (tied to the lowercase-severity convention) — a smaller, separate divergence left as-is. Refs #58
① Historical correlation depth (FR1)One operation (
② The
|
GET /logs/correlate?request_id=… |
|
|---|---|
| BEFORE (Phase 0) | HTTP 404 — no endpoint; consumer stitches 3 calls |
| AFTER (Phase 1) | HTTP 200 — {audit, api, sys, counts, completeness} in one call |
③ Sentinel request_id invariant
A sys row written before any request (boot/init):
first context-less /sys row |
|
|---|---|
| BEFORE (Phase 0) | request_id: <<none>> — orphan, drops out of correlation |
| AFTER (Phase 1) | request_id: "boot-demo-svc-…" — correlatable, self-describing |
④ Structured filters (§3.4)
2501 api rows persisted; filter by status:
GET /logs/api?status=502 |
|
|---|---|
| BEFORE | status param didn't exist — ignored |
| AFTER (persisted) | ["/v1/checkout"] — exact row; [] if ring-only (502 row aged out) |
In one line: before, "find everything for this operation" only worked for the last few seconds of api/sys traffic and dropped context-less rows entirely; after, it works against durable history in one
call, and every row carries a request_id.
Two notes on the axes (since this tripped earlier):
- persistence on/off demonstrates rows ①+④ (FR1 depth) — same branch, toggle FASTEN_DEMO_PERSIST.
- Phase 0 vs Phase 1 branch demonstrates ②+③ (new code) — the sentinel isn't persistence-gated.
|
In docs, we should explain
|
PushAPI/PushSyslog (Go) and push_api/push_syslog (Python) now do a synchronous INSERT +
This is the phase0 naming gap compounded. When a store is attached, reads are served only
write_drainer_syslog pushes to the ring but does not call _persist (stdout.py:102) — it's
Each stream is capped at limit (default 100) independently, and counts reflects returned, Minor
|
Addresses the Phase 1 review feedback: - completeness no longer lies on a failing sink: a swallowed stream-persist failure is recorded on the StreamStore (NoteWriteFailure / note_write_failure at the transport swallow point) and the reader degrades that stream's flag to "store-degraded" — reads bypass the ring once a store is attached, so a lost row is silently missing and plain "store" would assert durability the data no longer has. Sticky by design: the hole in history doesn't heal. - /correlate reports per-stream "totals" (matching rows in the backing source) alongside "counts" (returned), so consumers can tell 100-of-100 from 100-of-5000: filtered COUNT on the stream stores and the audit store (CountFiltered on SQLite/Postgres, count(**filters) in Python), ring match-count otherwise. - Python drainer self-reports now persist like every other syslog push (write_drainer_syslog was the only path skipping the store, making drainer events vanish from the reader API when persistence is on — Go/Python parity; the store insert can't reintroduce the stdout deadlock). - stream stores set PRAGMA synchronous=NORMAL (per-connection in Python; in migrate + DSN guidance in Go) and document the per-row write-through cost. - level filters are exact-match in Python (like method/path, matching Go), and the Go slog handler writes lowercase levels — the canonical casing every other writer uses — so slog rows are findable cross-language. - doc notes: lexicographic time-window assumption (canonical UTC timestamps), sentinel prefix-classification caveat, only boot/orphan are auto-minted. - READMEs answer "is this the whole record or a recent window?" — the store / ring / store-degraded contract and counts-vs-totals truncation detection.
…reams default Conflict resolution keeps both sides' semantics: Init seeds persistedStreams from a per-engine copy of defaultPersistedStreams then extends it with api/sys per config; streamSource keeps the nil-map fallback to the shared default AND the store-degraded classification; the router _source docstring merges the durability-class framing with the degraded contract, now pointing at /correlate totals-vs-counts as the per-response truncation signal.
A local-build symlink slipped into the merge commit: the .gitignore pattern 'fasten-core/target/' only matches directories, so a symlink by the same name was not ignored. CI then failed with 'failed to create directory fasten-core/target: Not a directory'. Remove it and drop the trailing slash so the pattern covers both.
Phase 1 of #58 — the durable-history slice. Stacked on #59 (Phase 0); base is
feat/phase0-completeness-flags, so this diff shows only Phase 1. Merge #59 first.Turns correlation from a best-effort, last-few-seconds feature into a contract over durable history. Four features, Python + Go parity (FR5):
What
FR1 — opt-in durable persistence for
api/sys. A per-stream SQLiteStreamStore(one table per stream, full row as JSON payload + indexed columns). The in-memory ring stays the hot write path; the store is a write-through sink; reads come from the store when configured, else the ring. Default off → ring-only, fully backward compatible. Wiring reuses the Phase 0 completeness seam, so a persisted stream's flag flipsring → storewith no handler change. SQLite-only in v1 (Postgres stream store is a later phase). Config: Pythonapi_store=/syslog_store=orFASTEN_API_DSN/FASTEN_SYSLOG_DSN; GoConfig.APIStore/SyslogStore.FR2 —
GET /logs/correlate?request_id=X. One call returns every stream for an operation:{request_id, audit, api, sys, counts, completeness}. Pure fan-out + assembly over the existing per-stream paths — no new query semantics. The payoff: with FR1 on, it recovers rows long past the ring window.Sentinel
request_idinvariant. Every stream row now always carries a non-emptyrequest_id, stamped at the transport push chokepoint with namespaced sentinels (boot-/sched-/bg-/lib-/orphan-) when no real context is active. The per-processboot-id is shared (one startup window); orphans are per-row. This is what makes/correlatea contract instead of "fan out, hope the relevant sys lines weren't orphans."§3.4 — indexed structured filters.
/sysgainsevent+since/until;/apigainsstatus+since/until— the fields the store already indexes, so discovery by something an operator knows is a fast lookup. Honoured identically ring-vs-store.Demonstrated live (curl, before/after)
/correlate?request_id=<old op>buried under 2500 rowsapi: 0(ring) — churnedapi: 1(store) — recovered/api?status=502[](aged out of ring)["/v1/checkout"]Phase 0 → Phase 1 branch:
/correlate404 → 200; context-less/sysrowrequest_id<<none>> → boot-….Review
A multi-agent review ran on this branch; fixes are included in the last two commits: Python write-through made resilient (a sink failure can't break the hot logging path, matching Go); cross-language
method/pathfilters aligned to exact-match (FR5 parity); null-timestamp window agreement (COALESCE); doc-accuracy corrections; added coverage (write-through failure, combined filters, limit, null-timestamp).Deferred to a follow-up (non-blocking): Python
levelfilter still case-folds; GoStreamQuery.Status→*intand the storeeq-map's silent unknown-key drop; sentinel boot-window edge case (upstream id with a sentinel-like prefix); Go stderr rate-limiting under sustained store outage.Tests
New: Python
test_stream_persistence.py,test_correlate.py,test_sentinel_request_id.py,test_structured_filters.py,test_phase1_review.py; Gostream_store_test.go,correlate_test.go,sentinel_test.go,structured_filters_test.go.Local verification: Python suite 287 passed / 18 skipped. Go is vet/gofmt/build-clean but could not be executed in my environment (the cgo core lib can't be linked on this arch) — CI is the first run of the Go suite for this branch.
Refs #58