Skip to content

Harden the PostgreSQL timeline backend for production - #1636

Merged
hisco merged 5 commits into
mainfrom
harden/postgres-timeline
Sep 4, 2026
Merged

hisco merged 5 commits into
mainfrom
harden/postgres-timeline

Conversation

@hisco

@hisco hisco commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Description

Production hardening for the PostgreSQL timeline backend merged in #1632. Three changes: the shared store contract now covers more than arrival order, the seen-resource write path no longer blocks the informer on database latency, and the operational failure modes are written down.

No behaviour change for the memory or SQLite backends.

Widen the shared store contract

The conformance suite exercised 5 of 12 EventStore methods and 3 of about 19 QueryOptions fields. Everything else (every filter, cluster scoping, owner lookup, payload encoding, most of Stats) was covered only by each backend's own tests. That is precisely how a backend shipped ignoring UntilSeq and SequenceOrder while its own suite stayed green.

Added properties: cluster-context scoping, the namespace/kind/name/source/event-type/exclude-deleted filters, the since-until window, GetChangesForOwner scoping, a full payload round-trip, the Stats counters and seq bounds, and the per-cluster seen set. All three stores pass.

One detail worth keeping: the payload property uses an explicit sub-microsecond timestamp instead of one derived from the clock. time.Now() is microsecond-resolution on darwin, so a clock-derived fixture cannot detect a backend that truncates to microseconds. That is why the truncation in #1314 failed on Linux CI and passed on the contributor's Mac. Verified by mutation: reintroducing the truncation now fails on macOS as well.

Batch the seen-resource writes

MarkResourceSeen ran one INSERT per resource, synchronously, from the informer add handler. During initial sync that is one round trip per resource in the cluster, serialized against database latency. Fine next to a sidecar; tens of seconds against a managed database in another zone.

Marks now update the in-memory set as before and queue the durable write for a background batcher. IsResourceSeen is unaffected. Measured over 5000 resources against a local database:

before after
informer handler blocked 1.15s 1.5ms
total wall clock 1.15s 42ms

Clears stay durable before returning. They are rare, and deferring them risks the opposite failure: a process that dies between the clear and the flush keeps a stale mark and never reports that resource as newly seen again. They share the queue with marks, so ordering holds and a queued mark cannot land after the clear that supersedes it. An earlier version of this change made clears fire-and-forget, and the existing persistence test caught it.

A full queue drops the durable write rather than stalling the informer. The in-memory set is already correct, so the cost is one re-report after the next restart. Drops are counted and reported per flush rather than per event.

Document the failure modes

Three things an operator needs before pointing Radar at a database, none of which were written down:

  • An unreachable database is fatal by design rather than a silent fall back to memory, so the database sits on Radar's startup path.
  • Recovery after an outage is not automatic. Only auth-shaped disconnects have a background retry, and a database failure classifies as network, so it needs the UI retry or a pod restart.
  • sslmode=require and verify-full both work; TLS needs no handling beyond the DSN.

How has this been tested?

  • Full store suite against real PostgreSQL 17 on macOS/arm64 and linux/amd64, over plaintext, sslmode=require, and sslmode=verify-full with a pinned CA.
  • TLS enforcement confirmed negatively: an untrusted certificate is rejected with an x509 error, and the DSN password stays redacted on that path.
  • go test -race clean on internal/timeline.
  • Mutation-tested both new guards. Dropping the cluster-context filter fails the scoping property; reintroducing microsecond truncation fails the payload property; making clears fire-and-forget fails the persistence test.
  • Whole repo green. TestCursorForceGrantEndToEnd in internal/ai flakes under full parallel load, but it does so on unmodified main too and passes in isolation; nothing here touches that package.

Known gaps, unchanged

Still open and worth tickets rather than scope creep here: roughly 550 lines duplicated between the PostgreSQL and SQLite stores, no background retry for network-classified disconnects, and no coverage for credential rotation, a slow database, migration against a partially-migrated database, or retention over a large backlog.

Type of change

  • Improvement to an existing feature (non-breaking)

Checklist

  • Code follows the project's coding standards
  • Self-review performed
  • Comments added where necessary
  • No new warnings
  • Dependent changes merged

Related issues

Follow-up hardening for #1632. No issue is closed by this change.


Note

Medium Risk
Changes the hot-path seen-set durability semantics (marks may be dropped under load) and expands contract tests across all timeline stores; PostgreSQL informer behavior improves but restart could re-report “newly seen” resources if the queue overflowed.

Overview
Production hardening for the PostgreSQL timeline backend: seen-resource persistence no longer blocks informers on per-resource DB round trips, the shared EventStore conformance suite now exercises filters and scoping, and in-cluster docs spell out PostgreSQL failure modes.

Seen-resource writes move from synchronous INSERT/DELETE on every mark/clear to a background batcher (queued marks, ~1s / 500-key flushes). MarkResourceSeen still updates memory immediately and only queues durability; a full queue drops durable marks rather than stalling the informer (logged in aggregate). ClearResourceSeen stays blocking until the clear is flushed, with shared ordering so a mark cannot land after a clear for the same key. Shutdown drains the queue.

Conformance (pkg/timeline/storetest) adds properties for cluster-context scoping, query field filters, since/until, GetChangesForOwner, full event payload round-trip (including sub-microsecond timestamps), Stats, and per-cluster seen/clear behavior—so backends cannot pass while ignoring filters or encoding.

Docs clarify no SQLite→PostgreSQL migration, unreachable PostgreSQL is fatal (no silent in-memory fallback), recovery needs UI retry or pod restart after outage, and TLS via DSN (require / verify-full).

Tests: new PostgreSQL tests for async persistence and clear under queue saturation.

Reviewed by Cursor Bugbot for commit fb6fe2b. Bugbot is set up for automated code reviews on this repo. Configure here.

The suite exercised 5 of 12 EventStore methods and 3 of about 19 QueryOptions
fields. Everything else - every filter, cluster scoping, owner lookup, payload
encoding, and most of Stats - was covered only by each backend's own tests,
which is how a backend shipped that ignored UntilSeq and SequenceOrder while
its own suite passed.

Adds properties for cluster-context scoping, the namespace/kind/name/source/
event-type/exclude-deleted filters, the since-until window, GetChangesForOwner,
a full payload round-trip, the Stats counters and seq bounds, and the
per-cluster seen set. All three stores pass.

The payload property uses an explicit sub-microsecond timestamp rather than one
derived from the clock. time.Now() is microsecond-resolution on darwin, so a
fixture built from it cannot detect a backend that truncates to microseconds -
which is exactly why the PostgreSQL truncation showed up on Linux CI and not on
the contributor's machine. Verified by mutation: reintroducing the truncation
now fails on macOS too.
MarkResourceSeen ran one INSERT per resource, synchronously, from the informer
add handler. On initial sync that is one round trip per resource in the cluster,
serialized against the database's latency: tolerable next to a sidecar,
tens of seconds against a managed database in another zone.

Marks now update the in-memory set as before and queue the durable write for a
background batcher, so IsResourceSeen is unaffected and the handler no longer
waits on the network. Measured over 5000 resources against a local database:
handler time 1.15s -> 1.5ms, total wall clock 1.15s -> 42ms.

Clears stay durable before returning. They are rare, and deferring them risks
the opposite failure: a process that dies between the clear and the flush keeps
a stale mark and never reports that resource as newly seen again. They share
the queue with marks, so ordering holds and a queued mark can never land after
the clear that supersedes it - which the existing persistence test caught when
an earlier version of this change made clears fire-and-forget.

A full queue drops the durable write rather than stalling the informer; the
in-memory set is already correct and the cost is one re-report after the next
restart. Drops are counted and reported per flush, not per event.
Three things an operator has to know before pointing Radar at a database, none
of which were written down: an unreachable database is fatal by design rather
than a silent fall back to memory, so the database sits on the startup path;
recovery after an outage is not automatic, because only auth-shaped disconnects
have a background retry and a database failure classifies as network; and
sslmode=require and verify-full are both verified to work, so TLS needs no
special handling beyond the DSN.
@hisco
hisco requested a review from nadaverell as a code owner September 4, 2026 09:22
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Harden PostgreSQL timeline persistence for production

âœĻ Enhancement 🧊 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

â€Ē Batches PostgreSQL seen-resource writes without blocking informer synchronization.
â€Ē Expands shared EventStore conformance across filters, scoping, payloads, statistics, and seen
 state.
â€Ē Documents PostgreSQL outage, recovery, TLS, and migration constraints.
Diagram

graph TD
  I["Informer Add"] --> M["Memory Seen Set"]
  I --> Q["Write Queue"] --> B["Batch Writer"] --> P[("PostgreSQL")]
  C["Clear Request"] --> M
  C --> Q
  B --> A["Clear Ack"] --> C
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Persistent write spool
  • ➕ Avoids losing durable marks when the in-memory queue is full
  • ➕ Can survive process termination before PostgreSQL recovers
  • ➖ Introduces another persistence layer and recovery protocol
  • ➖ Adds operational complexity for a low-impact re-reporting failure mode
2. Keyed mutation coalescing
  • ➕ Reduces duplicate writes during high informer churn
  • ➕ Uses queue capacity more efficiently
  • ➖ Complicates mark-clear ordering and clear acknowledgments
  • ➖ Requires synchronization around replacement and in-flight batches

Recommendation: Keep the bounded ordered batch queue. It removes database latency from the informer hot path while retaining immediate in-memory correctness, durable clears, ordered mutations, and clean-shutdown draining. A persistent spool is only justified if one-time re-reporting after overflow becomes operationally unacceptable.

Files changed (4) +469 / -16

Enhancement (1) +153 / -15
postgres_store.goBatch PostgreSQL seen-resource persistence asynchronously +153/-15

Batch PostgreSQL seen-resource persistence asynchronously

â€Ē Replaces synchronous per-resource inserts with a bounded ordered queue and background batch writer. Marks remain immediately visible in memory, clears wait for processing, queue overflow is aggregated in logs, and shutdown drains pending mutations.

internal/timeline/postgres_store.go

Tests (2) +313 / -0
postgres_store_test.goVerify asynchronous seen-state durability and ordering +48/-0

Verify asynchronous seen-state durability and ordering

â€Ē Adds a multi-batch persistence test that closes and reopens the store. It verifies marks survive restart, clears cannot be overtaken by earlier marks, and seen state remains cluster-scoped.

internal/timeline/postgres_store_test.go

conformance.goExpand the shared EventStore conformance contract +265/-0

Expand the shared EventStore conformance contract

â€Ē Adds backend-independent coverage for cluster scoping, field and time filters, owner lookup, complete payload round-trips, statistics, and per-cluster seen state. Explicit nanosecond fixtures detect timestamp precision loss consistently across operating systems.

pkg/timeline/storetest/conformance.go

Documentation (1) +3 / -1
in-cluster.mdDocument PostgreSQL production failure and recovery behavior +3/-1

Document PostgreSQL production failure and recovery behavior

â€Ē Clarifies that SQLite history cannot be imported and PostgreSQL failures do not trigger an in-memory fallback. Documents manual recovery requirements, startup availability implications, and verified TLS modes.

docs/in-cluster.md

@qodo-code-review

qodo-code-review Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Full queue drops clears ✓ Resolved 🐞 Bug ≡ Correctness
Description
When seenWrites is full, enqueueSeenWrite drops a clear and closes its completion channel,
causing ClearResourceSeen to return while the stale database row remains. A subsequent restart
hydrates that row and incorrectly treats the deleted resource as already seen.
Code

internal/timeline/postgres_store.go[R550-553]

+	default:
+		s.seenDropped.Add(1)
+		// Nobody will flush this one, so release any waiter immediately rather
+		// than letting it sit until its timeout.
Relevance

●●● Strong

Clear durability is explicit contract; dropped clears leave stale rows that restart hydration
restores.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The clear first removes only the in-memory value and considers closure of done completion. The
full-queue branch skips the SQL writer but closes that channel, while startup hydration later
restores every still-persisted key from PostgreSQL.

internal/timeline/postgres_store.go[527-536]
internal/timeline/postgres_store.go[547-557]
internal/timeline/postgres_store.go[629-645]
internal/timeline/postgres_store.go[1033-1050]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`enqueueSeenWrite` drops every mutation when the queue is full, including durable clear operations. It then releases the clear's waiter, allowing `ClearResourceSeen` to return even though PostgreSQL still contains the stale mark.

## Issue Context
Marks may be dropped because their in-memory state is authoritative until restart, but clears must remain ordered behind preceding marks and be persisted before returning. Ensure queue saturation cannot discard a clear or signal its completion prematurely, and add a saturation test that verifies the cleared row does not reappear after reopening the store.

## Fix Focus Areas
- internal/timeline/postgres_store.go[531-557]
- internal/timeline/postgres_store_test.go[1147-1193]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Conformance comment recounts defect ✓ Resolved 📘 Rule violation ⚙ Maintainability
Description
The added conformance and test comments embed implementation history by recounting that a PostgreSQL
backend previously shipped while ignoring options and that MarkResourceSeen “no longer” writes
inline, rather than describing only the current behavioral contracts.
Code

pkg/timeline/storetest/conformance.go[R408-409]

+	// backend could ignore one entirely and still pass. That is exactly how a
+	// PostgreSQL backend shipped ignoring UntilSeq and SequenceOrder.
Relevance

●●● Strong

Comments explicitly recount prior backend defects and implementation changes, matching the active
history-comment rule.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 3036538 prohibits code comments containing explicit change, defect, or diff history. The
statements that a PostgreSQL backend “shipped ignoring UntilSeq and SequenceOrder” and that
`MarkResourceSeen “no longer writes inline” explicitly record a past defect and contrast current
behavior with the previous implementation.

Rule 3036538: Disallow references to tickets or PR history in code comments
pkg/timeline/storetest/conformance.go[403-409]
internal/timeline/postgres_store_test.go[1147-1151]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The comments record historical implementation behavior: a previously shipped PostgreSQL defect and the prior inline behavior of `MarkResourceSeen`. Rewrite them to state only the current test rationale and behavioral contract.

## Issue Context
Explain that untested filter fields could be ignored without referring to historical implementation behavior, and retain the useful `MarkResourceSeen` test invariant by describing only the current asynchronous persistence behavior.

## Fix Focus Areas
- pkg/timeline/storetest/conformance.go[403-409]
- internal/timeline/postgres_store_test.go[1147-1151]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 41 rules
Review mode: ⚖ïļ Balanced: Downgraded extended -> standard: change is below the extended eligibility bar (hunks 11/18, lines 485/200; both must reach the floor). Router rationale: This production-hardening PR changes PostgreSQL persistence semantics, asynchronous batching and shutdown ordering, plus broad cross-backend conformance behavior across many independent logic paths, making multiple subtle defects plausible.

Grey Divider

Tip of the day
ðŸ’Ą Did you know, you can route each action level your way: inline, summary, both, or drop

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread pkg/timeline/storetest/conformance.go Outdated
Comment thread internal/timeline/postgres_store.go Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit fec9ac4. Configure here.

Comment thread internal/timeline/postgres_store.go
A saturated write queue dropped the clear and closed its completion channel, so
ClearResourceSeen returned as though the delete had landed while the row was
still present. A mark already queued for the same key then flushed on top of
it, leaving the resource marked as seen for good: after the next restart a
recreated resource hydrates as already-seen and its first appearance is
suppressed, silently and permanently.

Marks are still dropped under pressure, which is sound - the in-memory set is
already correct and the cost is one re-report after a restart - but a clear is
not equivalent, so it now waits for room. That cannot deadlock: the writer
drains on a ticker regardless of database health, so a full queue is transient
unless the store is closing, which the wait also selects on.

Found by two independent review bots on the pull request. The new test fails
against the previous behaviour.

Also rewrites three comments that recounted how the code got here rather than
what it does now.
ClearResourceSeen waited twice: once for room in the write queue, then again
for the flush, each up to postgresOperationTimeout. It runs on the informer's
delete handler, so a database slow enough to saturate the queue could hold that
handler for twice as long as the single round trip this path replaced.

Both waits now share one deadline, restoring the previous bound. If the clear
cannot be queued within it the call returns instead of waiting again, and says
so: the in-memory set is already correct, and the durable row is left for the
next delete of the same resource, which is the same outcome the earlier
synchronous DELETE had when it timed out.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant