Skip to content

Darling service robustness: stuck-compression self-heal + single-instance / arg guards (#1581) - #1585

Merged
erikdarlingdata merged 3 commits into
devfrom
feature/1581-service-robustness
Jul 20, 2026
Merged

erikdarlingdata merged 3 commits into
devfrom
feature/1581-service-robustness

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Darling service robustness — the two durable #1581 product fixes

Closes the durable product fixes from the #1581 field incident (a prod box climbed toward OOM over ~30h with Windows error 487, "could not reserve shared memory region"). The confirmed root cause was three TimescaleDB compression-policy jobs that had silently died (next_start = -infinity, so the scheduler never re-fired them) on the largest hypertables — compression halted, uncompressed data grew without bound, autovacuum churned the swelling TOAST, the disk bled, and worker-spawns hit the 487 wall. A separate self-inflicted event during triage — a stray Service.exe --version that fell through into a real startup and spawned a second service instance fighting the first over the bundled PostgreSQL shared-memory segment — caused a ~7.5h outage. This PR makes both failure classes impossible to hit silently.

Fix A — stuck-compression-job self-heal

  • Hourly, TimescaleDB-only, fleet-level check in the worker sweep (reuses the disk-check cadence idiom; gated on _timescaleAvailable).
  • Reads every compression-policy job from timescaledb_information.job_stats JOIN jobs and flags one stuck when next_start = -infinity (compared in SQL, so it never depends on Npgsql's infinity conversion — the exact incident) OR it has been Running past max(2× schedule interval, 2h floor). Non-compression job types are never touched.
  • Re-arms a stuck job once via a parameterized alter_job($1, next_start => now()) and fires a CRITICAL alert through the same deliverer/history/cooldown the store-disk-pressure self-alert already uses (real email/webhook + audit row). If it re-hangs after the self-heal, or the re-arm itself fails (e.g. the store login doesn't own the job), the service escalates and stops re-arming (looping alter_job on a re-hanging job is a product-bug signal for a human), and writes a resolution row on recovery.
  • Failure-isolated at three layers (worker connection-open, evaluator wrapper, the read) so a store hiccup can never abort the collection sweep; a clean no-op on plain-PostgreSQL stores.

Fix B — single-instance guard + argument robustness

  • The exe classifies its first argument before building the host: --version/--help print and exit 0; any unknown option prints usage to stderr and exits non-zero without starting the host (the --version that triggered the outage now prints and exits). Recognized one-shot verbs and the no-arg SCM start are unchanged; the known-verb allowlist is the single source Program's dispatch mirrors, so the two can't drift.
  • A Global\PerformanceMonitorDarlingService named single-instance mutex is acquired between host build and run (Build doesn't touch Postgres; Run does): a second invocation that reaches startup logs a CRITICAL and exits without starting, so it can never fight the first over PostgreSQL or the MCP/web ports. An abandoned mutex (previous instance crashed) is acquired with a warning; the owner releases it on graceful shutdown so an SCM stop→start re-acquires cleanly. Windows-gated.

Darling service only. No store schema change.

Testing

Independently reviewed line-by-line and re-verified: Release build clean, full Darling.Tests green — 2723 passed / 0 failed / 142 gated-live skipped. Coverage: the pure stuck-detection predicate (all branches), the self-heal state machine (re-arm-once, escalate, re-hang, re-arm-failure, recovery+re-stuck, multi-job, both throwing seams isolated), the argument-classification decision table, and the mutex behaviors (fresh / held / abandoned / SCM restart), plus a gated-live test that forces next_start = -infinity on a real compression job and proves detection + re-arm heals it.

One review fix applied on top of the build (test-only): the abandoned-mutex test polled the production non-blocking WaitOne(0) immediately after Thread.Join, racing the kernel's post-join native-thread teardown that marks the mutex abandoned — a window that exists only for the in-process crash simulation, never in production. The retry now lives in the test (the guard must not retry — a false there means a live second instance holds the lock); confirmed 10/10 green in a determinism loop. Production guard code unchanged.

🤖 Generated with Claude Code

erikdarlingdata and others added 3 commits July 19, 2026 21:11
…instance guards (#1581)

A field box climbed toward OOM because three TimescaleDB compression policy
jobs silently died (next_start = -infinity, so the scheduler never re-fired
them), and separately a stray `Service.exe --version` fell into a real startup
path, spawned a second instance, and caused a ~7.5h Postgres outage.

Fix A - stuck-compression-job self-heal (the memory-climb root cause):
- New hourly, fleet-level, Timescale-only check in the worker sweep (reuses the
  _nextDiskCheckUtc interval-scheduling idiom), failure-isolated like purge/disk.
- TimescaleSupport reads compression-policy jobs (proc_name compression/
  columnstore) from job_stats JOIN jobs and flags STUCK ones: next_start =
  -infinity (dead scheduler, detected in SQL), or Running past max(2x
  schedule_interval, 2h). Pure IsCompressionJobStuck predicate + parameterized
  RearmJobSql (job_id bound: alter_job($1, next_start => now())).
- DarlingSelfAlertEvaluator runs the re-arm-once/escalate machine over the shared
  deliverer/history/mute: first detection re-arms once + fires CRITICAL; a
  re-hang escalates ("re-hung after self-heal") and STOPS re-arming; a failed
  re-arm escalates immediately; recovery writes one resolution row. Guards:
  extension/views absent -> skip; alter_job failure -> log once, no crash.

Fix B - arg robustness + single-instance guard (prevent the 2nd-instance outage):
- ClassifyStartupArgs: no-arg or a recognized verb reaches the host; --version/-v
  and --help/-h print and exit 0; ANY unknown option prints usage to stderr and
  exits non-zero WITHOUT starting the host (the --version fall-through bug).
- SingleInstanceGuard: a Global\ named mutex acquired before host.Run() (Windows-
  only, matching the codebase's Windows gating); a second instance logs + exits
  non-zero without starting. AbandonedMutexException (a prior crash) is acquired-
  with-a-warning; released on graceful shutdown so SCM stop->start re-acquires.

Tests: pure stuck-detection predicate + bound + parameterized SQL; store-
unavailable resilience; the escalate-on-re-hang state machine; the arg-parsing
decisions; and thread-based mutex held/abandoned/release cases. Full Darling.Tests
green (2723 passed, 142 gated-live self-skip, 0 failed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The abandoned-mutex single-instance test polled the production non-blocking
WaitOne(0) immediately after Thread.Join, racing the kernel's post-join native
teardown that marks the mutex abandoned; a poll in that window returns false
(mutex still owned by the dying thread). That window exists only for the
in-process crash SIMULATION, never in production (a crashed PROCESS is fully
torn down before the SCM starts the replacement), and the guard must NOT retry
there (a false means a live second instance holds the lock). So the retry lives
in the test: it re-polls until the abandonment is observable, then asserts the
contract (acquired WITH the abandoned warning). Production guard code unchanged.

Verified: full Darling.Tests 2723 passed / 0 failed / 142 gated-live skipped;
guard class 10/10 green in a determinism loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@erikdarlingdata
erikdarlingdata merged commit 34f962c into dev Jul 20, 2026
2 checks passed
@erikdarlingdata
erikdarlingdata deleted the feature/1581-service-robustness branch July 20, 2026 02:40
erikdarlingdata added a commit that referenced this pull request Jul 20, 2026
…ger-cast

Fix #1585 compression self-heal re-arm: alter_job job_id is integer, not bigint (#1581)
pull Bot pushed a commit to ehtick/PerformanceMonitor that referenced this pull request Jul 22, 2026
…id is integer (erikdarlingdata#1581)

The gated-live compression-self-heal test caught a real defect in the just-
merged erikdarlingdata#1585 self-heal: TimescaleDB's alter_job takes job_id INTEGER, but the
id is carried as a long and bound, so Npgsql sends bigint and Postgres cannot
resolve the function (42883: function alter_job(bigint, ...) does not exist).
The re-arm always threw -> TryRearmJobAsync caught it, returned false, and the
self-heal escalated with a misleading "auto-re-arm FAILED" message WITHOUT ever
re-arming. Detection + alerting worked; healing did not.

Fix: cast the bound id $1::integer at every alter_job call site (production
RearmJobSql + the test's kill/restore) and update the unit-test assertion. A
TimescaleDB job_id never exceeds int4, so the down-cast is always safe.

The unit tests missed this (they use a fake re-arm delegate); only the gated-
live test hits the real alter_job, and PR CI does not run the gated-live suite
- the dev nightly's real-TimescaleDB job surfaced it. Full Darling.Tests green
locally (2734/0/142); gated on the nightly's real-TimescaleDB job before merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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