Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat(anomaly): data-loss guard for empty-primary full resync (valkey#579)#302

Merged
KIvanow merged 10 commits into
masterfrom
feat/data-loss-guard
Jul 8, 2026
Merged

feat(anomaly): data-loss guard for empty-primary full resync (valkey#579)#302
KIvanow merged 10 commits into
masterfrom
feat/data-loss-guard

Conversation

@KIvanow

@KIvanow KIvanow commented Jul 6, 2026

Copy link
Copy Markdown
Member

Summary

Detects the silent data-loss scenario from valkey/valkey#579: a primary without persistence restarts empty, replicas see a new master_replid, full-resync, and wipe their own copies. The monitor now alerts the moment the primary comes back empty (Rule A), confirms when a replica has been wiped (Rule B), dispatches a new Pro-tier data.loss.detected webhook, and shows a critical banner with a remediation runbook on the Anomaly dashboard.

Changes

  • proprietary/anomaly-detection: new MetricType.DATASET_KEYS + per-connection replication snapshot (replid, offset, uptime, keyspace key count) in AnomalyService.pollConnection(); Rule A (primary restarted empty with restart evidence: replid change / uptime reset / offset regression) and Rule B (replica dataset collapsed ≥90% after replid change) emit CRITICAL anomalies. Same-replid FLUSHALL is deliberately not flagged.
  • packages/shared + proprietary/webhook-pro: new data.loss.detected webhook event (Pro tier) with dispatchDataLossDetected() payload (kind, previous/current keys, replids, role, instance).
  • apps/web: new DataLossAlertBanner (critical alert + REPLICAOF NO ONE runbook, resolve action) on the Anomaly dashboard; dataset_keys metric label; webhook event label; resolveAnomalyEvent API helper.
  • Event ids use randomUUID() so they persist on the Postgres storage adapter (uuid column).

Checklist

  • Unit / integration tests added — 8 new jest cases (rules fire, restart-with-data / failover-with-data / FLUSHALL don't; 42/42 in suite) + 6 vitest cases for the banner (209/209 web suite). Live e2e repro of the issue #579 docker scenario verified Rule A within one poll, Rule B after resync, webhook delivery, and banner data.
  • Docs added / updated
  • Roborev review passed — run roborev review --branch or /roborev-review-branch in Claude Code (internal)
  • Competitive analysis done / discussed (internal)
  • Blog post about it discussed (internal)

Note

High Risk
Changes anomaly detection and resolution paths for replication/data-loss incidents; incorrect rules or resolution semantics could miss real data loss or let critical banners dismiss without durable state.

Overview
Adds critical data-loss detection for the empty-primary / full-resync wipe scenario (valkey#579): AnomalyService tracks replication snapshots and emits dataset_keys CRITICAL anomalies (primary restarted empty vs replica wiped), with guards for loading/FLUSHALL and a new Pro data.loss.detected webhook.

The Anomaly dashboard gets a DataLossAlertBanner fed by activeOnly anomaly queries (no 24h window) plus resolveAnomalyEvent; dismiss only succeeds after the server persists resolution.

Resolution behavior is reworked: resolveAnomaly / resolveGroup are async and storage-first for durable events; memory-only anomalies use a persisted flag; storage resolveAnomaly is idempotent (true if already resolved) across memory/SQLite/Postgres.

API/client tweaks: activeOnly on anomaly events, async resolve endpoints, and webhook form label for the new event type.

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

…y#579)

When a primary without persistence restarts empty, replicas full-resync
and silently wipe their own data. Add two state-transition detectors to
the anomaly poller: Rule A fires when a primary comes back empty with
restart evidence (replid change / uptime reset / offset regression), and
Rule B fires when a replica's dataset collapses after a replid change.
Both emit a CRITICAL dataset_keys anomaly, dispatch a new Pro-tier
data.loss.detected webhook, and surface a remediation-runbook banner on
the anomaly dashboard.
Comment thread apps/web/src/components/anomalies/DataLossAlertBanner.tsx
Comment thread apps/web/src/components/anomalies/DataLossAlertBanner.tsx
Resolving an anomaly only flipped the in-memory cache entry, so data-loss
dismissals were lost on restart or storage-backed queries and the critical
banner reappeared. Now resolveAnomaly/resolveGroup also persist via
storage.resolveAnomaly. The banner additionally keeps showing unless the
API confirms { success: true }, instead of hiding on failure.
Comment thread proprietary/anomaly-detection/anomaly.service.ts
Comment thread apps/web/src/pages/AnomalyDashboard.tsx Outdated
…r visible across date filter

Rule A treated a restarted primary with zero keys as an empty-primary
wipe even while INFO reported loading in progress, so a normal RDB/AOF
restart could emit a critical data-loss alert before the snapshot loads.
Gate detection on !isLoading (loading / async_loading) and skip recording
the transient empty snapshot as baseline.

The DataLossAlertBanner shared the date-filtered events list, so changing
the date range could hide an active unresolved dataset_keys incident. Feed
it a dedicated unfiltered dataset_keys poll instead.

Adds regression tests for the loading and async_loading cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_013ntFKmeHP3LBzsTMxswrxH
Comment thread proprietary/anomaly-detection/anomaly.service.ts Outdated
resolveAnomaly flipped the in-memory resolved flag and returned success
even when storage persistence threw, so the data-loss banner would add the
id to its dismissed set and stay hidden while storage-backed polls still
returned the incident as unresolved. Persist first, return false on failure
or when storage did not resolve the event, and only sync the cached copy on
a durable resolution.
@KIvanow KIvanow requested a review from jamby77 July 7, 2026 07:41
let total = 0;
for (const [key, value] of Object.entries(info)) {
if (!/^db\d+$/.test(key)) continue;
const match = /keys=(\d+)/.exec(value);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

sumKeyspaceKeys never returns nonzero in production, so Rule A/B can't fire.

It regexes keys=(\d+) out of each db<N> value expecting a raw string, but the real INFO parser stores each db as an object ({keys, expires, avg_ttl} — see info.parser.ts parseKvLine and the consumer at metrics.service.ts:199-201). convertInfoToRecord then stringifies it to "[object Object]", so the regex misses → total stays 0 every poll → prev.totalKeys > 0 is never true.

Tests pass only because the mock injects db0: 'keys=150,...' as a string, a shape the parser never emits.

Fix: read .keys off the typed keyspace object (mirror metrics.service.ts) and switch the test mock to the real object shape.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Dug into this and the premise is actually inverted — sumKeyspaceKeys does fire in production. The parser does not store keyspace dbs as objects: InfoParser.parse (info.parser.ts) writes each section value as the raw substring after the colon, and parseKvLine is never applied to the keyspace section. So info.keyspace.db0 is the string "keys=150,expires=5,avg_ttl=0", and the regex matches. Verified against the real parser:

InfoParser.parse("# Keyspace\r\ndb0:keys=150,expires=5,avg_ttl=0\r\n").keyspace.db0
// => "keys=150,expires=5,avg_ttl=0"  (typeof "string")

migration.service.ts:113 relies on this same string shape (val.match(/keys=(\d+)/)) and would throw at runtime if val were an object — so it is definitively a string in prod. And the existing test mock (db0: 'keys=150,...') is faithful, not fictional.

The metrics.service.ts:199 reference is actually the mis-shaped one: its typeof val === 'object' && 'keys' in val check is false against the real string, so its keyspaceSize is silently always 0. The root cause is a type-vs-runtime mismatch: the KeyspaceInfo type declares an object but the parser emits strings. (Worth a separate fix for metrics.service — flagging it.)

Addressed the underlying fragility in 182d7b4: sumKeyspaceKeys now reads the typed keyspace section off the INFO response (before the lossy String() flatten that would produce "[object Object]") and handles both the string and object shapes, so it is correct today and cannot silently break if the parser is later aligned with the type. Added tests for both shapes.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

You're right and I was wrong here — apologies. The anomaly service uses getInfo (→ InfoParser.parse, which keeps each section value as a raw string), not the typed getInfoParsed, so keyspace.db0 is "keys=150,..." and the regex matches; sumKeyspaceKeys was firing correctly in prod. My proof cited metrics.service.ts:199, which is actually the mis-shaped consumer (silently 0 against the real string) — I inverted it. Ironically this contradicts what I'd verified on #304 (that InfoParser.parse keeps strings) and I failed to carry it back here.

182d7b4e is a good hardening regardless — reading the typed keyspace before the lossy flatten and handling both shapes future-proofs it, and the separate metrics.service keyspaceSize === 0 bug is a real catch. Withdrawing this as a blocker.

…h shapes

Review hardening for sumKeyspaceKeys. The count is now read from the typed
`keyspace` section of the parsed INFO response rather than the flattened,
stringified record — the flatten runs String() on each value, which would
collapse an object-shaped db to "[object Object]" and silently zero the total.

InfoParser currently emits each db as a raw string ("keys=123,expires=5,
avg_ttl=0"), which is what anomaly.service (and migration.service) already
regex against and what the Rule A/B tests exercise, so detection works today.
But the KeyspaceInfo type declares an object ({ keys, expires, avg_ttl }), so
the function now accepts both shapes and can't silently break if the parser is
ever aligned with the type. Adds focused tests for both shapes plus the
empty/missing/non-db cases.
Comment thread apps/web/src/pages/AnomalyDashboard.tsx
The banner poll passed only metricType and relied on the events endpoint, but
GET /anomaly/events applies a default 24h startTime when none is given. An
unresolved data-loss incident older than 24h was filtered out, so the banner
could go silent while storage still held an open incident.

Add an explicit `activeOnly` feed end to end:
- getRecentAnomalies(activeOnly) queries durable storage with resolved:false and
  no startTime floor (the in-memory cache is capped and lost on restart, so an
  old open incident must come from storage). AnomalyQueryOptions.resolved is
  already supported by every adapter (postgres has a partial unresolved index).
- Controller exposes ?activeOnly=true, skips the 24h default for it, and switches
  the default from `||` to `??` so an explicit startTime=0 is honored.
- API client forwards activeOnly; the data-loss banner poll sets it.

Adds service tests: activeOnly reads storage with resolved:false and no
startTime, and bypasses the in-memory cache branch.
Comment thread proprietary/anomaly-detection/anomaly.service.ts
Comment thread proprietary/anomaly-detection/anomaly.service.ts Outdated
Comment thread proprietary/anomaly-detection/anomaly.service.ts
}

resolveGroup(correlationId: string): boolean {
async resolveGroup(correlationId: string): Promise<boolean> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The storage-first fix landed on resolveAnomaly but not here — resolveGroup is now inconsistent with its sibling. It sets anomaly.resolved = true before the storage call, swallows every per-item error in the try/catch, and unconditionally return true.

So if storage is briefly down, POST /anomaly/groups/:id/resolve returns {success:true}, the UI hides the group, but the next storage-backed poll returns them still unresolved (banner reappears) and the in-memory copies are wrongly marked resolved — the exact durability gap resolveAnomaly was just fixed to avoid.

Mirror the resolveAnomaly shape: persist first, only flip the cached copy on success, and return false if any member fails to persist (or aggregate the results).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Should be addressed now

…unpersisted incidents

Addresses two bugbot findings on #302.

- resolveGroup marked members resolved in memory and returned true even when
  storage.resolveAnomaly threw/failed, so a client could dismiss a group while
  storage-backed polls still returned members unresolved. Now mirror
  resolveAnomaly: only flip the cached copy for durably-resolved members and
  return true only when every member persisted.

- The activeOnly (data-loss banner) feed read durable storage only, so an
  incident whose saveAnomalyEvent failed — while the Pro webhook still fired —
  had no matching banner until a later poll persisted it. The feed now unions
  storage with in-memory unresolved events (dedup by id, storage wins; same
  filters), so a dispatched incident is always visible.

Tests: resolveGroup success/partial-failure/no-row-updated; activeOnly union,
dedupe, and resolved-exclusion.
Comment thread proprietary/anomaly-detection/anomaly.service.ts Outdated
KIvanow added 2 commits July 8, 2026 11:04
# Conflicts:
#	proprietary/anomaly-detection/__tests__/anomaly.service.spec.ts
#	proprietary/anomaly-detection/anomaly.service.ts
#	proprietary/anomaly-detection/types.ts
…es closed members

Bugbot follow-up on #302. resolveAnomaly returned false for an already-resolved
row (WHERE resolved = false matched nothing), indistinguishable from a missing
row. resolveGroup then reported { success: false } when any member was already
resolved — via a retry, a partial resolve, or resolving one anomaly through the
data-loss banner before the group — even though every event was closed in storage.

resolveAnomaly is now idempotent across all three adapters: it returns true when
the anomaly is resolved (newly or already), and false only when no anomaly with
that id exists. resolveGroup's all-persisted check is now correct as-is.

Adds a describe.each (memory + sqlite) test: first resolve and re-resolve both
return true; an unknown id returns false.

@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 12bbbfe. Configure here.

Comment thread proprietary/anomaly-detection/anomaly.service.ts
@KIvanow KIvanow requested a review from jamby77 July 8, 2026 08:14
@jamby77

jamby77 commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Resolve regression on Postgres: anomaly_events.id is UUID PRIMARY KEY, but failover/promotion/cluster/persistence/dup-primary events use string ids (${connectionId}-failover-${ts} etc.). Those inserts throw and are swallowed in addAnomaly, so they never persist. The new persist-first resolveAnomaly/resolveGroup then returns false for them → those anomaly types become undismissable in the UI on Postgres (prod).

Fix: fall back to the in-memory flip when storage reports no row (safe — a never-stored event can't be resurfaced by a storage-backed poll). Prefer this over giving everything randomUUID(), since the deterministic ids drive the idempotent ON CONFLICT upsert on sqlite/memory.

Nit: new sumKeyspaceKeys(infoResponse: any) violates the no-any rule.

On Postgres, anomaly_events.id is a UUID PK, but failover/promotion/
cluster/persistence/dup-primary events use deterministic string ids
(${connectionId}-failover-${ts} etc). Their inserts throw and are
swallowed in addAnomaly, so they never persist; the persist-first
resolveAnomaly/resolveGroup then fail for them and they surface from the
in-memory cache as undismissable banners in prod.

Track durability per cached event (persisted flag, set when
saveAnomalyEvent succeeds) and fall back to an in-memory flip when the
event was never stored — safe, since a storage-backed poll can't
resurface a row that was never written. Durable events keep the
storage-first path so a transient resolve failure still can't dismiss a
banner a later poll re-surfaces. Deterministic ids are preserved (they
drive the idempotent ON CONFLICT upsert on sqlite/memory).

Also type sumKeyspaceKeys off a structural INFO shape instead of any.
@KIvanow

KIvanow commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

Resolve regression on Postgres: anomaly_events.id is UUID PRIMARY KEY, but failover/promotion/cluster/persistence/dup-primary events use string ids (${connectionId}-failover-${ts} etc.). Those inserts throw and are swallowed in addAnomaly, so they never persist. The new persist-first resolveAnomaly/resolveGroup then returns false for them → those anomaly types become undismissable in the UI on Postgres (prod).

Fix: fall back to the in-memory flip when storage reports no row (safe — a never-stored event can't be resurfaced by a storage-backed poll). Prefer this over giving everything randomUUID(), since the deterministic ids drive the idempotent ON CONFLICT upsert on sqlite/memory.

Nit: new sumKeyspaceKeys(infoResponse: any) violates the no-any rule.

@jamby77 this should be fixed now. When requesting changes, please use the request changes review status, so it is easier to find among the GH spam

@jamby77 jamby77 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved. Solid data-loss guard — persisted-flag durability model is sound, loading guard and FLUSHALL discrimination are correct, strong test coverage. Non-blocking: a few one-line ifs (sumKeyspaceKeys + the 3 adapters + resolveGroup guard) should be braced per CLAUDE.md; Postgres resolveAnomaly idempotency path is untested (describe.each covers Memory+Sqlite only). CI red is the unrelated pre-existing cache-proposals-sqlite failure.

@KIvanow KIvanow merged commit 917f98a into master Jul 8, 2026
2 of 4 checks passed
@KIvanow KIvanow deleted the feat/data-loss-guard branch July 8, 2026 15:47
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 8, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants