Skip to content

fix(0056): freshness live-only false-fire + ledger-processor alarms#97

Merged
karczuRF merged 4 commits into
developfrom
fix/0056-freshness-gate-and-processor-alarms
Jul 7, 2026
Merged

fix(0056): freshness live-only false-fire + ledger-processor alarms#97
karczuRF merged 4 commits into
developfrom
fix/0056-freshness-gate-and-processor-alarms

Conversation

@karczuRF

@karczuRF karczuRF commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Resolves the two post-deploy findings on task 0056 surfaced by the 0070 go-live + 0082 verification. Both were code gaps (not just fire-tests); the fire-tests + ops-topic subscribe remain operational.

Finding A — SDEX push-freshness false-fires under live-only

Root cause: seeded backfill_progress rows are status='running' with last_push_at=NULL, and the coalesce(last_push_at, started_at) fallback aged them from seed time — so with no backfill actually pushing, the alarm sat permanently in ALARM even though live SDEX ingestion was healthy.

Fix (packages/backfill-freshness-probe/src/lib.rs): AGE_QUERY gates on WHERE status = 'running' AND last_push_at IS NOT NULL, age = now() - last_push_at. Live-only seed rows and the paused/completed seams publish no metric → NOT_BREACHING → OK; a running backfill that has pushed then stalls still fires (AC #5). Supersedes review finding #4's started_at fallback, which couldn't tell "backfill overdue" from "no backfill at all" — the source of the false-page.

Finding B — the core ledger-processor was unmonitored

prices.ledger_processor.lag_seconds existed only as a comment; the processor emits no custom metric. Rather than add Rust emission + a processor redeploy, three alarms ride AWS-native metrics, built by deterministic physical name so ObservabilityStack needs no cross-stack ref (mirrors the opsAlarmsTopicName import pattern):

Alarm Metric Fires on
…-ledger-processor-lag AWS/SQS ApproximateAgeOfOldestMessage (ingest queue) > opsAlarms.ledgerProcessorLagSeconds (new config, default 60s), sustained 3×1m
…-ledger-processor-errors AWS/Lambda Errors ≥ 1 over 5m (handler crash)
…-ledger-processor-dlq ingest-DLQ ApproximateNumberOfMessagesVisible ≥ 1 (poison-pill gap the Errors alarm misses under reportBatchItemFailures)

All three → prices-{env}-ops-alarms, NOT_BREACHING.

Verification

  • Rust: cargo test 4/4, fmt --check, clippy, --features lambda build — clean.
  • Infra: nx build/eslint/prettier clean; cdk synth Prices-production-Observability shows all three alarms with correct namespaces.

Still operational (task 0056 stays active)

  1. Deploy Observability (make deploy-production-observability) + EventBridge (freshness probe) stacks.
  2. Subscribe + confirm an ops alias on prices-production-ops-alarms.
  3. Fire-test freshness + mTLS NotAfter alarms; record in notes/G-alarm-fire-test.md.

karczuRF added 4 commits July 8, 2026 00:03
Post-deploy findings A + B from the 0070 go-live / 0082 verification.

A: the SDEX push-freshness alarm sat permanently in ALARM under live-only
operation. The seeded backfill_progress rows are status='running' with a
NULL last_push_at, and the coalesce(last_push_at, started_at) fallback aged
them from seed time even with no backfill pushing. Gate AGE_QUERY on
`status='running' AND last_push_at IS NOT NULL` so seed rows and the
paused/completed seams publish no metric (NOT_BREACHING -> OK); a running
backfill that has pushed then stalls still fires (AC #5). Supersedes the
finding-#4 started_at fallback, which could not distinguish "backfill
overdue" from "no backfill at all".

B: the core ledger-processor Lambda shipped unmonitored (lag_seconds was
only a comment; it emits no custom metric). Add three alarms to
ObservabilityStack from AWS-native metrics, built by deterministic name so
no cross-stack ref / processor redeploy is needed: -lag (ingest-queue
ApproximateAgeOfOldestMessage > opsAlarms.ledgerProcessorLagSeconds,
default 60s, sustained 3x1m), -errors (AWS/Lambda Errors >= 1), -dlq
(ingest-DLQ depth >= 1, the poison-pill gap the Errors alarm misses under
reportBatchItemFailures). All wired to prices-{env}-ops-alarms.

Rust: 4 unit tests green, fmt/clippy/lambda-build clean.
Infra: tsc/eslint/prettier clean, cdk synth verifies all three alarms.
… prewhere

Self-review (code-review PR #97) caught a regression the unit tests missed:
dropping coalesce made `toInt64(... - toUnixTimestamp(last_push_at))` a
Nullable(Int64) column (the WHERE ... IS NOT NULL removes rows but not the
column's static type), which the clickhouse crate cannot deserialize into the
plain i64 `age_seconds` field. Every probe run would have errored -> the
-errors alarm false-pages AND no PushAgeSeconds is published, blinding the
freshness alarm.

- Wrap in assumeNotNull(last_push_at) (safe: WHERE guarantees non-NULL) so the
  column stays non-Nullable, matching the repo's toUnixTimestamp(Nullable) ->
  Option / strip convention (cf. sink.rs ExistingProgress).
- Pin SETTINGS optimize_move_to_prewhere_if_final = 0 so the non-sort-key
  status/last_push_at predicates cannot be moved to PREWHERE and surface a
  stale pre-FINAL row version.
- Fix the now-inaccurate SOROBAN_AMM_STREAM forensic-value doc comment.
- Assert both query invariants in the guard unit test.

The true deser path still needs the Docker-gated CH integration test to run;
the unit test only asserts the query shape.
Follow-up to the PR #97 self-review (findings 2/3/4):

- Halt blind spot (finding 2): the lag/errors/DLQ alarms all key on the
  PRESENCE of enqueued/failed messages, so a producer-side stop (S3->SNS->SQS
  delivery halts, subscription removed) drains the queue and invokes nothing,
  leaving all three OK while ingestion is dead. Add
  prices-{env}-ledger-processor-no-invocations: AWS/Lambda Invocations < 1 over
  15 min with treatMissingData BREACHING (missing = zero invocations = the
  halt). Pubnet closes a ledger every ~5-6s, so 15-min silence is a real
  outage. Soften the task-doc "covers the live-ingestion plane" claim.

- Altitude (finding 3): extract ledgerProcessorFunctionName / ingestQueueName /
  ingestDlqName helpers exported from ComputeStack (which now uses them for its
  own resources) and import them into ObservabilityStack, so the alarm metric
  dimensions share one source of truth by name (no cross-stack CFN ref). A
  rename can no longer silently orphan an alarm on a non-existent metric.

- Flapping (finding 4): raise default ledgerProcessorLagSeconds 60 -> 120 and
  the lag sustain 3 -> 5x1min so routine deploys / cold starts (oldest message
  ages a few min while briefly paused, then drains) don't false-page; a real
  stall keeps climbing well past it.

Synth-verified (4 ledger-processor alarms, helper-derived names on both
stacks); tsc/eslint/prettier clean.
Closes the test-coverage gap that let the PR #97 Nullable(Int64) regression
land: the unit tests only cover age_metrics() + the query string shape, never
the execute/deserialize path. freshness_it.rs runs the exact AGE_QUERY against
a local Docker ClickHouse and asserts:

- fetch_all::<StreamAge> executes + deserializes (the regression guard: fails
  on a Nullable(Int64) age_seconds column against the plain-i64 field),
- the status='running' AND last_push_at IS NOT NULL gate (seed / paused /
  completed rows all excluded — finding A, no live-only false-fire),
- FINAL returns the latest version, not a stale pre-merge 'running' row
  (a flipped running->completed stream is excluded),
- the live-only end state publishes zero rows (alarm stays OK).

Passes on ClickHouse 26.3.10.60 (prod-pinned). Docker-gated (#[ignore]).
@karczuRF karczuRF merged commit d541126 into develop Jul 7, 2026
3 checks passed
@karczuRF karczuRF deleted the fix/0056-freshness-gate-and-processor-alarms branch July 7, 2026 22:48
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