Skip to content

Commit e82a2b4

Browse files
committed
1 parent 664a05e commit e82a2b4

2 files changed

Lines changed: 78 additions & 18 deletions

File tree

services/appManagement/appsRuntimeState.js

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ const STABLE_RUN_MS = config.fluxapps.crashBackoffStableRunMs ?? 10 * 60 * 1000;
2020
// only the count (capped by the ladder) and the last timestamp are ever read,
2121
// so the persisted history never needs to grow beyond the ladder length
2222
const MAX_HISTORY = BACKOFF_DELAYS_MS.length;
23+
// The exit code cannot prove a fault: an image whose entrypoint is a wrapper
24+
// script ending in `exit 0` reports a clean stop for a segfault, and no init we
25+
// wrap around it can recover a status the image already discarded. So pacing on
26+
// the code alone would leave such a container restarting without limit. This is
27+
// the cause-blind backstop - this many automatic restarts inside the window is
28+
// itself evidence of a fault, whatever Docker reported, and it disposes into the
29+
// same ladder rather than into a state a human has to clear.
30+
const RESTART_BURST_COUNT = config.fluxapps.restartBurstCount ?? 5;
31+
const RESTART_BURST_WINDOW_MS = config.fluxapps.restartBurstWindowMs ?? 60 * 1000;
2332

2433
function collection() {
2534
const db = dbHelper.databaseConnection();
@@ -93,7 +102,10 @@ async function setOperatorStopped(identifier, stopped) {
93102
// app. Swallowing a write failure would let the API report success while the
94103
// lock never persisted - the caller must surface the failure instead.
95104
const fields = { operatorStopped: stopped };
96-
if (!stopped) fields.restartHistory = [];
105+
if (!stopped) {
106+
fields.restartHistory = [];
107+
fields.autoRestartWindow = [];
108+
}
97109
await setFields(identifier, fields);
98110
}
99111

@@ -110,20 +122,39 @@ async function isOperatorStopped(identifier) {
110122
}
111123

112124
/**
113-
* Appends a restart attempt (wall-clock) and trims the history to the ladder
114-
* length so a perpetually crashing container never grows the array unbounded.
125+
* Whether the automatic restarts already recorded fill the burst window. Read
126+
* BEFORE the current attempt is appended, so it answers "have there already
127+
* been enough" and the caller's own restart is the one over the line.
128+
*
129+
* @param {object|null} state
130+
* @returns {boolean}
131+
*/
132+
function burstExceeded(state) {
133+
const recent = (state && state.autoRestartWindow) || [];
134+
if (recent.length < RESTART_BURST_COUNT) return false;
135+
return Date.now() - recent[0] <= RESTART_BURST_WINDOW_MS;
136+
}
137+
138+
/**
139+
* Appends a restart attempt (wall-clock). Every automatic restart lands in the
140+
* burst window; only one with crash evidence - or one that fills the burst
141+
* window, which is the same conclusion reached without the exit code - also
142+
* walks the ladder. Both arrays are trimmed to their own bound so a
143+
* perpetually restarting container never grows the document unbounded.
115144
*
116145
* @param {string} identifier
146+
* @param {boolean} crashed - Docker reported a fault (non-zero exit or OOM kill)
117147
*/
118-
async function recordRestart(identifier) {
148+
async function recordRestart(identifier, crashed = true) {
119149
try {
120150
const state = await getState(identifier);
121-
const history = (state && state.restartHistory) || [];
122-
history.push(Date.now());
123-
if (history.length > MAX_HISTORY) {
124-
history.splice(0, history.length - MAX_HISTORY);
151+
const fields = {
152+
autoRestartWindow: [...((state && state.autoRestartWindow) || []), Date.now()].slice(-RESTART_BURST_COUNT),
153+
};
154+
if (crashed || burstExceeded(state)) {
155+
fields.restartHistory = [...((state && state.restartHistory) || []), Date.now()].slice(-MAX_HISTORY);
125156
}
126-
await setFields(identifier, { restartHistory: history });
157+
await setFields(identifier, fields);
127158
} catch (err) {
128159
log.error(`appsRuntimeState - failed to record restart for ${identifier}: ${err.message}`);
129160
}
@@ -150,10 +181,16 @@ async function recordRestart(identifier) {
150181
* @param {string} identifier
151182
* @param {number|null} lastFinishedAtMs - docker State.FinishedAt of the
152183
* stopped container (ms epoch), when the caller has inspect data
184+
* @param {boolean} crashed - Docker reported a fault (non-zero exit or OOM kill)
153185
* @returns {Promise<number>}
154186
*/
155-
async function restartWaitMs(identifier, lastFinishedAtMs = null) {
187+
async function restartWaitMs(identifier, lastFinishedAtMs = null, crashed = true) {
156188
const state = await getState(identifier);
189+
// A clean exit is the operator's own restart far more often than it is a
190+
// fault, and pacing it makes a deliberate restart look like an outage. It
191+
// goes back immediately - unless the restarts are arriving fast enough to
192+
// fill the burst window, which is a fault however the exit code reads.
193+
if (!crashed && !burstExceeded(state)) return 0;
157194
const history = (state && state.restartHistory) || [];
158195
if (history.length === 0) return 0;
159196

@@ -331,6 +368,7 @@ async function prepareCollection() {
331368
networkHealRemoval: twins.some((t) => t.networkHealRemoval === true),
332369
networkHealHistory: [...new Set(twins.flatMap((t) => t.networkHealHistory || []))].sort((a, b) => a - b).slice(-MAX_HISTORY),
333370
restartHistory: [...new Set(twins.flatMap((t) => t.restartHistory || []))].sort((a, b) => a - b).slice(-MAX_HISTORY),
371+
autoRestartWindow: [...new Set(twins.flatMap((t) => t.autoRestartWindow || []))].sort((a, b) => a - b).slice(-RESTART_BURST_COUNT),
334372
updatedAt: Math.max(...twins.map((t) => t.updatedAt || 0)),
335373
};
336374
const newestExit = twins.filter((t) => t.lastDiedAt !== undefined).sort((a, b) => b.lastDiedAt - a.lastDiedAt)[0];
@@ -368,4 +406,6 @@ module.exports = {
368406
BACKOFF_DELAYS_MS,
369407
STABLE_RUN_MS,
370408
MAX_HISTORY,
409+
RESTART_BURST_COUNT,
410+
RESTART_BURST_WINDOW_MS,
371411
};

services/appMonitoring/appReconciler.js

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,9 @@ async function dockerActual(identifier) {
305305
exists: true,
306306
running: !!(info.State && info.State.Running),
307307
exitCode: everRan ? (info.State.ExitCode ?? null) : null,
308+
// the kernel's verdict, not the entrypoint's: an image that swallows its
309+
// payload's status still cannot hide this one
310+
oomKilled: !!(info.State && info.State.OOMKilled),
308311
finishedAt,
309312
// classified from THIS inspect so the running-branch network check needs no
310313
// second docker call (and no TOCTOU between two inspects).
@@ -861,12 +864,25 @@ async function reconcile(rawIdentifier) {
861864
return;
862865
}
863866

864-
// exists but stopped, should run -> backoff-paced restart (no sleeping; the
865-
// worker re-enqueues when the backoff window elapses)
866-
const wait = await appsRuntimeState.restartWaitMs(identifier, actual.finishedAt);
867+
// exists but stopped, should run -> restart, paced by the ladder only when the
868+
// stop carries evidence of a fault (no sleeping; the worker re-enqueues when
869+
// the backoff window elapses). A clean exit goes back immediately: it is an
870+
// operator restarting their own app far more often than it is a crash, and
871+
// pacing that turns a deliberate restart into what looks like an outage.
872+
// exitCode null is a container that has never run - an initial start, not a death.
873+
const crashed = !!actual.oomKilled || (actual.exitCode !== null && actual.exitCode !== 0);
874+
const wait = await appsRuntimeState.restartWaitMs(identifier, actual.finishedAt, crashed);
867875
if (wait > 0) {
868-
log.warn(`appReconciler - ${identifier} stopped, backing off ${Math.round(wait / 1000)}s before restart`);
869-
fluxEventBus.publish('reconciler:actuated', { identifier, action: 'backoff', waitMs: wait });
876+
// name which of the two put it here: a reported fault, or restarts arriving
877+
// fast enough to be one whatever the exit code said. Support cannot tell
878+
// these apart from the outside, and the difference decides what they do next.
879+
const cause = crashed
880+
? `exit ${actual.exitCode}${actual.oomKilled ? ' (OOM-killed)' : ''}`
881+
: 'restarting too fast to be healthy';
882+
log.warn(`appReconciler - ${identifier} stopped, ${cause}; backing off ${Math.round(wait / 1000)}s before restart`);
883+
fluxEventBus.publish('reconciler:actuated', {
884+
identifier, action: 'backoff', waitMs: wait, crashed,
885+
});
870886
scheduleRetry(identifier, wait);
871887
return;
872888
}
@@ -887,14 +903,18 @@ async function reconcile(rawIdentifier) {
887903
return;
888904
}
889905

890-
await appsRuntimeState.recordRestart(identifier);
906+
await appsRuntimeState.recordRestart(identifier, crashed);
891907
try {
892908
await dockerService.appDockerStart(identifier);
893909
} catch (err) {
894910
// No die event fires for a failed start (the container never ran), so a
895911
// dropped throw here leaves the component down until the hourly sweep.
896-
// Schedule our own retry; pacing is free - the attempt was recorded above,
897-
// so a persistent failure walks the backoff ladder instead of hammering.
912+
// Schedule our own retry. A start that never ran carries no exit code, so it
913+
// is not a fault and does not walk the ladder directly - it reaches the
914+
// ladder by filling the burst window, which these retries do comfortably
915+
// (restartBurstCount x MANAGED_RETRY_MS against restartBurstWindowMs). That
916+
// relationship is what bounds a permanently failing start, and the config
917+
// comment on the window is where it is stated.
898918
log.error(`appReconciler - failed to start ${identifier}: ${err.message}; retrying`);
899919
fluxEventBus.publish('reconciler:actuated', { identifier, action: 'startFailed', reason: err.message });
900920
scheduleRetry(identifier, MANAGED_RETRY_MS);

0 commit comments

Comments
 (0)