Repository navigation
Darling service robustness: stuck-compression self-heal + single-instance / arg guards (#1581) - #1585
Merged
Merged
Conversation
…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>
…obustness # Conflicts: # CHANGELOG.md
This was referenced Jul 20, 2026
Merged
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>
This was referenced Jul 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 strayService.exe --versionthat 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
_timescaleAvailable).timescaledb_information.job_statsJOINjobsand flags one stuck whennext_start = -infinity(compared in SQL, so it never depends on Npgsql's infinity conversion — the exact incident) OR it has beenRunningpastmax(2× schedule interval, 2h floor). Non-compression job types are never touched.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 (loopingalter_jobon a re-hanging job is a product-bug signal for a human), and writes a resolution row on recovery.Fix B — single-instance guard + argument robustness
--version/--helpprint and exit 0; any unknown option prints usage to stderr and exits non-zero without starting the host (the--versionthat 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.Global\PerformanceMonitorDarlingServicenamed 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.Testsgreen — 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 forcesnext_start = -infinityon 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 afterThread.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