A single store-unreachable moment at startup ends collection for the life of the process, with no retry and no distinction from a genuinely broken rung
Surfaced by #2894's residual-2 work and deliberately kept out of it: this is a caller-side error-policy question, not a migration-applier one, so putting it in PgMigrations would have been the wrong place.
The shape
Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs:1011:
:1011 var applied = await PgMigrations.MigrateAsync(migrateConnection, _logger, stoppingToken);
:1012 _logger.LogInformation("Postgres store ready (schema v{Version}, {Applied} migration(s) applied)", …);
:1014 }
:1015 catch (Exception ex) when (ex is not OperationCanceledException)
:1016 {
:1017 _logger.LogCritical("Cannot reach or migrate the Postgres store: {Message}", ex.Message);
:1018 return;
:1019 }
One catch, one LogCritical, one return. Every failure mode is terminal and they are all treated identically:
- the store is momentarily unreachable — a restart, a failover, a network blip, the store still coming up alongside the service
- the advisory lock was held by a peer instance longer than the wait allows
- a rung genuinely cannot apply against this store
The first two are transient and would succeed on a retry seconds later. The third will never succeed and should stay terminal. Nothing distinguishes them, and the service does not try again — collection is over until somebody restarts the process.
Why this is a real gap rather than a defensible default
The very next block states the opposite policy explicitly, and says why. Role provisioning at :1021 carries:
a failure degrades (the Viewer cannot connect as admin/viewer until a later start succeeds) but never kills collection
So the codebase already reasons about degrade-vs-kill for store-adjacent startup work, and lands on degrade one block later. The migrate path has no such reasoning attached — the return reads as an unexamined default sitting next to a documented decision.
The blast radius is the whole point of the product. This is the monitoring tool's own collection loop. A transient store hiccup during service start is exactly the moment an operator is least likely to be watching, and the symptom — no data at all, one LogCritical line — looks like a much larger failure than a five-second outage.
What changed recently that makes this expressible
#2894's residual-2 change makes the advisory-lock waiter independent of an unbounded holder, so lock-wait expiry is now a distinguishable outcome rather than an indistinguishable timeout. That is what makes a retry policy writable at all: before, "couldn't get the lock" and "couldn't reach the store" arrived looking alike.
The design question
- Which failures are retryable? Connect/transport failures and lock-wait expiry are the obvious candidates. A rung that fails to apply is the obvious non-candidate. Where the boundary sits for anything ambiguous is the actual decision.
- Bounded retry with backoff, or retry for the life of the process? A monitoring service that retries forever and logs at a sane cadence is arguably right for the transient cases; an unbounded retry against a genuinely broken rung is a log flood.
- Does a retry re-enter the whole migrate, or resume?
MigrateAsync is idempotent rung-by-rung, so re-entering is probably safe — worth confirming rather than assuming, because the answer decides whether retry is a loop around the existing call or something more careful.
- What does the Viewer/Fleet Health show meanwhile? "Store unreachable, retrying" is a different operator story from silence.
Note there is a nearby precedent to check for consistency rather than invent against: a 6×2 s retry already wraps part of the startup path.
Not verified
No live reproduction — the terminal behaviour is read from source at the lines quoted, not observed by taking a store down mid-start. No measurement of how often this actually fires in practice; the case for fixing it rests on the asymmetry between failure modes and on the adjacent block's contrary policy, not on an incident.
A single store-unreachable moment at startup ends collection for the life of the process, with no retry and no distinction from a genuinely broken rung
Surfaced by #2894's residual-2 work and deliberately kept out of it: this is a caller-side error-policy question, not a migration-applier one, so putting it in
PgMigrationswould have been the wrong place.The shape
Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs:1011:One
catch, oneLogCritical, onereturn. Every failure mode is terminal and they are all treated identically:The first two are transient and would succeed on a retry seconds later. The third will never succeed and should stay terminal. Nothing distinguishes them, and the service does not try again — collection is over until somebody restarts the process.
Why this is a real gap rather than a defensible default
The very next block states the opposite policy explicitly, and says why. Role provisioning at
:1021carries:So the codebase already reasons about degrade-vs-kill for store-adjacent startup work, and lands on degrade one block later. The migrate path has no such reasoning attached — the
returnreads as an unexamined default sitting next to a documented decision.The blast radius is the whole point of the product. This is the monitoring tool's own collection loop. A transient store hiccup during service start is exactly the moment an operator is least likely to be watching, and the symptom — no data at all, one
LogCriticalline — looks like a much larger failure than a five-second outage.What changed recently that makes this expressible
#2894's residual-2 change makes the advisory-lock waiter independent of an unbounded holder, so lock-wait expiry is now a distinguishable outcome rather than an indistinguishable timeout. That is what makes a retry policy writable at all: before, "couldn't get the lock" and "couldn't reach the store" arrived looking alike.
The design question
MigrateAsyncis idempotent rung-by-rung, so re-entering is probably safe — worth confirming rather than assuming, because the answer decides whether retry is a loop around the existing call or something more careful.Note there is a nearby precedent to check for consistency rather than invent against: a 6×2 s retry already wraps part of the startup path.
Not verified
No live reproduction — the terminal behaviour is read from source at the lines quoted, not observed by taking a store down mid-start. No measurement of how often this actually fires in practice; the case for fixing it rests on the asymmetry between failure modes and on the adjacent block's contrary policy, not on an incident.