Skip to content

Commit 94cb791

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
fix(replication,webapp): recover leadership after a failed leader-lock extend
Run replication now recovers on its own after a Redis restart or outage. The leader could previously lose its lock without noticing and log "Cannot extend an already-expired lock" every few seconds while holding the replication slot open, until the server was restarted. It now re-acquires the lock, or steps down once and re-elects. Deployments running under a process supervisor can set `RUN_REPLICATION_MAX_RESUBSCRIBE_ATTEMPTS` (or the session equivalent) to exit and be restarted when a replication stream cannot recover. It defaults to 0, which keeps retrying and preserves existing behavior. Mono-RevId: 465d1f9bd0bb720320c8dd294dde3c3a9b88f1d9
1 parent 66ff818 commit 94cb791

10 files changed

Lines changed: 950 additions & 16 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Run replication now recovers on its own after a Redis restart or outage, in place of logging "Cannot extend an already-expired lock" and holding the replication slot open until the server is restarted. Deployments running under a process supervisor can set `RUN_REPLICATION_MAX_RESUBSCRIBE_ATTEMPTS` to exit and be restarted when a stream cannot recover.

apps/webapp/app/env.server.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2015,6 +2015,18 @@ const EnvironmentSchema = z
20152015
RUN_REPLICATION_FLUSH_BATCH_SIZE: z.coerce.number().int().default(100),
20162016
RUN_REPLICATION_MAX_POISON_STRIPS_PER_BATCH: z.coerce.number().int().default(1),
20172017
RUN_REPLICATION_LEADER_LOCK_TIMEOUT_MS: z.coerce.number().int().default(30_000),
2018+
2019+
// Replication self-heals in-process: a lost stream or leader lock is retried
2020+
// with backoff. These bound that. 0 (the default) retries forever, which is
2021+
// right without a supervisor; set a budget where something can restart the
2022+
// process (k8s, ECS, systemd) so an unrecoverable stream gets a clean slate
2023+
// instead of retrying against a slot that is never coming back.
2024+
RUN_REPLICATION_MAX_RESUBSCRIBE_ATTEMPTS: z.coerce.number().int().min(0).default(0),
2025+
// Grace period before exiting, so the final logs can flush.
2026+
RUN_REPLICATION_EXIT_DELAY_MS: z.coerce.number().int().min(0).default(5_000),
2027+
// Capped at 255: POSIX masks the code to `code & 0xff`, so anything larger
2028+
// could silently become 0 and read as a clean exit to a supervisor.
2029+
RUN_REPLICATION_EXIT_CODE: z.coerce.number().int().min(0).max(255).default(1),
20182030
RUN_REPLICATION_LEADER_LOCK_EXTEND_INTERVAL_MS: z.coerce.number().int().default(10_000),
20192031
RUN_REPLICATION_ACK_INTERVAL_SECONDS: z.coerce.number().int().default(10),
20202032
RUN_REPLICATION_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"),
@@ -2093,6 +2105,18 @@ const EnvironmentSchema = z
20932105
SESSION_REPLICATION_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
20942106
SESSION_REPLICATION_FLUSH_BATCH_SIZE: z.coerce.number().int().default(100),
20952107
SESSION_REPLICATION_LEADER_LOCK_TIMEOUT_MS: z.coerce.number().int().default(30_000),
2108+
2109+
// Replication self-heals in-process: a lost stream or leader lock is retried
2110+
// with backoff. These bound that. 0 (the default) retries forever, which is
2111+
// right without a supervisor; set a budget where something can restart the
2112+
// process (k8s, ECS, systemd) so an unrecoverable stream gets a clean slate
2113+
// instead of retrying against a slot that is never coming back.
2114+
SESSION_REPLICATION_MAX_RESUBSCRIBE_ATTEMPTS: z.coerce.number().int().min(0).default(0),
2115+
// Grace period before exiting, so the final logs can flush.
2116+
SESSION_REPLICATION_EXIT_DELAY_MS: z.coerce.number().int().min(0).default(5_000),
2117+
// Capped at 255: POSIX masks the code to `code & 0xff`, so anything larger
2118+
// could silently become 0 and read as a clean exit to a supervisor.
2119+
SESSION_REPLICATION_EXIT_CODE: z.coerce.number().int().min(0).max(255).default(1),
20962120
SESSION_REPLICATION_LEADER_LOCK_EXTEND_INTERVAL_MS: z.coerce.number().int().default(10_000),
20972121
SESSION_REPLICATION_LEADER_LOCK_ADDITIONAL_TIME_MS: z.coerce.number().int().default(10_000),
20982122
SESSION_REPLICATION_LEADER_LOCK_RETRY_INTERVAL_MS: z.coerce.number().int().default(500),
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// What to do when a replication client has exhausted its in-process
2+
// self-healing. The client retries a lost stream or leader lock on its own with
3+
// backoff; once it gives up, the stream is not coming back without help (a
4+
// dropped slot, a publication that no longer exists, credentials that no longer
5+
// work). Retrying past that point hides a dead replica behind a healthy-looking
6+
// process.
7+
//
8+
// Exiting hands the problem to whatever supervises us (Kubernetes, ECS,
9+
// systemd), which can give the process a clean slate. Deployments without a
10+
// supervisor leave *_REPLICATION_MAX_RESUBSCRIBE_ATTEMPTS at 0, so the client
11+
// never gives up and this never runs.
12+
//
13+
// The process-control decision lives here, at the composition root, rather than
14+
// inside the replication services: they take an `onUnrecoverable` callback so
15+
// they stay testable without touching globals.
16+
17+
// One exit, however many sources give up. The first failure is the useful one;
18+
// the rest would only race the same timer.
19+
let exitScheduled = false;
20+
21+
export function scheduleReplicationExit(options: {
22+
label: string;
23+
delayMs: number;
24+
exitCode: number;
25+
details: Record<string, unknown>;
26+
}): void {
27+
const { label, delayMs, exitCode, details } = options;
28+
29+
if (exitScheduled) return;
30+
exitScheduled = true;
31+
32+
console.error(
33+
`🗃️ ${label}: replication is unrecoverable, exiting in ${delayMs}ms so the supervisor can restart this process`,
34+
{ ...details, exitCode }
35+
);
36+
37+
// Delayed so the logs above can flush; unref'd so this timer alone never
38+
// keeps an otherwise-finished process alive.
39+
setTimeout(() => process.exit(exitCode), delayMs).unref();
40+
}

apps/webapp/app/services/runsReplicationInstance.server.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
setRunsReplicationGlobal,
1111
} from "./runsReplicationGlobal.server";
1212
import { runsReplicationSourceMetrics } from "./runsReplicationMetrics.server";
13+
import { scheduleReplicationExit } from "./replicationUnrecoverableExit.server";
1314
import {
1415
RunsReplicationService,
1516
type RunsReplicationSource,
@@ -253,6 +254,22 @@ function initializeRunsReplicationInstance() {
253254
// A source whose publication carries no usable table logs every 30s and replicates nothing.
254255
// Boot cannot see it (the source IS configured), so the counter is the alarmable signal.
255256
onSourceError: runsReplicationSourceMetrics.recordSourceError,
257+
maxResubscribeAttempts: env.RUN_REPLICATION_MAX_RESUBSCRIBE_ATTEMPTS,
258+
onUnrecoverable: ({
259+
sourceId,
260+
reason,
261+
attempts,
262+
}: {
263+
sourceId: string;
264+
reason: string;
265+
attempts: number;
266+
}) =>
267+
scheduleReplicationExit({
268+
label: "Runs replication",
269+
delayMs: env.RUN_REPLICATION_EXIT_DELAY_MS,
270+
exitCode: env.RUN_REPLICATION_EXIT_CODE,
271+
details: { sourceId, reason, attempts },
272+
}),
256273
};
257274

258275
// Construct the SINGLE legacy source synchronously (the split gate has not resolved

apps/webapp/app/services/runsReplicationService.server.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,13 @@ export type RunsReplicationServiceOptions = {
122122
* count it on.
123123
*/
124124
onSourceError?: (info: { sourceId: string; error: unknown }) => void;
125+
/** 0 (default) retries a broken stream forever; above that the client gives up. */
126+
maxResubscribeAttempts?: number;
127+
/**
128+
* Self-healing for a source has been exhausted. Injected rather than exiting
129+
* here, so the service stays free of process control and testable.
130+
*/
131+
onUnrecoverable?: (info: { sourceId: string; reason: string; attempts: number }) => void;
125132
};
126133

127134
type PostgresTaskRun = TaskRun & { masterQueue: string };
@@ -367,6 +374,7 @@ export class RunsReplicationService {
367374
redisOptions: options.redisOptions,
368375
autoAcknowledge: false,
369376
resubscribeOnFailure: true,
377+
maxResubscribeAttempts: options.maxResubscribeAttempts,
370378
publicationActions: ["insert", "update", "delete"],
371379
logger:
372380
options.logger ?? new Logger("LogicalReplicationClient", options.logLevel ?? "info"),
@@ -490,6 +498,15 @@ export class RunsReplicationService {
490498
client.events.on("leaderElection", (isLeader) => {
491499
this.logger.info("Leader election", { sourceId: source.id, isLeader });
492500
});
501+
502+
client.events.on("unrecoverable", ({ reason, attempts }) => {
503+
this.logger.error("Replication client gave up; source is down", {
504+
sourceId: source.id,
505+
reason,
506+
attempts,
507+
});
508+
this.options.onUnrecoverable?.({ sourceId: source.id, reason, attempts });
509+
});
493510
}
494511

495512
/** Exposed for tests and metrics — batches where nothing landed even after stripping JSON. */

apps/webapp/app/services/sessionsReplicationInstance.server.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { env } from "~/env.server";
33
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
44
import { singleton } from "~/utils/singleton";
55
import { meter, provider } from "~/v3/tracer.server";
6+
import { scheduleReplicationExit } from "./replicationUnrecoverableExit.server";
67
import { SessionsReplicationService } from "./sessionsReplicationService.server";
78
import { signalsEmitter } from "./signals.server";
89

@@ -45,6 +46,14 @@ function initializeSessionsReplicationInstance() {
4546
leaderLockExtendIntervalMs: env.SESSION_REPLICATION_LEADER_LOCK_EXTEND_INTERVAL_MS,
4647
leaderLockAcquireAdditionalTimeMs: env.SESSION_REPLICATION_LEADER_LOCK_ADDITIONAL_TIME_MS,
4748
leaderLockRetryIntervalMs: env.SESSION_REPLICATION_LEADER_LOCK_RETRY_INTERVAL_MS,
49+
maxResubscribeAttempts: env.SESSION_REPLICATION_MAX_RESUBSCRIBE_ATTEMPTS,
50+
onUnrecoverable: ({ reason, attempts }) =>
51+
scheduleReplicationExit({
52+
label: "Sessions replication",
53+
delayMs: env.SESSION_REPLICATION_EXIT_DELAY_MS,
54+
exitCode: env.SESSION_REPLICATION_EXIT_CODE,
55+
details: { reason, attempts },
56+
}),
4857
ackIntervalSeconds: env.SESSION_REPLICATION_ACK_INTERVAL_SECONDS,
4958
logLevel: env.SESSION_REPLICATION_LOG_LEVEL,
5059
waitForAsyncInsert: env.SESSION_REPLICATION_WAIT_FOR_ASYNC_INSERT === "1",

apps/webapp/app/services/sessionsReplicationService.server.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ export type SessionsReplicationServiceOptions = {
5454
leaderLockExtendIntervalMs?: number;
5555
leaderLockAcquireAdditionalTimeMs?: number;
5656
leaderLockRetryIntervalMs?: number;
57+
/** 0 (default) retries a broken stream forever; above that the client gives up. */
58+
maxResubscribeAttempts?: number;
59+
/**
60+
* Self-healing has been exhausted. Injected rather than exiting here, so the
61+
* service stays free of process control and testable.
62+
*/
63+
onUnrecoverable?: (info: { reason: string; attempts: number }) => void;
5764
ackIntervalSeconds?: number;
5865
acknowledgeTimeoutMs?: number;
5966
logger?: Logger;
@@ -188,6 +195,7 @@ export class SessionsReplicationService {
188195
redisOptions: options.redisOptions,
189196
autoAcknowledge: false,
190197
resubscribeOnFailure: true,
198+
maxResubscribeAttempts: options.maxResubscribeAttempts,
191199
publicationActions: ["insert", "update", "delete"],
192200
logger: options.logger ?? new Logger("LogicalReplicationClient", options.logLevel ?? "info"),
193201
leaderLockTimeoutMs: options.leaderLockTimeoutMs ?? 30_000,
@@ -251,6 +259,14 @@ export class SessionsReplicationService {
251259
this.logger.info("Leader election", { isLeader });
252260
});
253261

262+
this._replicationClient.events.on("unrecoverable", ({ reason, attempts }) => {
263+
this.logger.error("Replication client gave up; sessions replication is down", {
264+
reason,
265+
attempts,
266+
});
267+
options.onUnrecoverable?.({ reason, attempts });
268+
});
269+
254270
// Initialize retry configuration
255271
this._insertMaxRetries = options.insertMaxRetries ?? 3;
256272
this._insertBaseDelayMs = options.insertBaseDelayMs ?? 100;

apps/webapp/app/services/webhookDeliveriesReplicationService.server.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,13 @@ export class WebhookDeliveriesReplicationService {
198198
publishViaPartitionRoot: options.publishViaPartitionRoot,
199199
redisOptions: options.redisOptions,
200200
autoAcknowledge: false,
201+
// Without this, losing the leader lock stops this client for good: it
202+
// tears the stream down (a non-leader must not hold the slot open) and
203+
// has nothing to re-contend with. The runs and sessions services already
204+
// enable it. Note that every teardown path below must then call the
205+
// client's shutdown(), not stop(): only shutdown() latches the intentional
206+
// stop that keeps a pending resubscribe from reviving the stream.
207+
resubscribeOnFailure: true,
201208
publicationActions: ["insert", "update", "delete"],
202209
logger: options.logger ?? new Logger("LogicalReplicationClient", options.logLevel ?? "info"),
203210
leaderLockTimeoutMs: options.leaderLockTimeoutMs ?? 30_000,
@@ -276,7 +283,7 @@ export class WebhookDeliveriesReplicationService {
276283

277284
if (!this._currentTransaction) {
278285
this.logger.info("No transaction to commit, shutting down immediately");
279-
await this._replicationClient.stop();
286+
await this._replicationClient.shutdown();
280287
this._isSubscribed = false;
281288
this._isShutDownComplete = true;
282289
return;
@@ -305,7 +312,7 @@ export class WebhookDeliveriesReplicationService {
305312
async stop() {
306313
this.logger.info("Stopping replication client");
307314

308-
await this._replicationClient.stop();
315+
await this._replicationClient.shutdown();
309316

310317
if (this._acknowledgeInterval) {
311318
clearInterval(this._acknowledgeInterval);
@@ -441,7 +448,7 @@ export class WebhookDeliveriesReplicationService {
441448
if (this._isShutDownComplete) return;
442449

443450
if (this._isShuttingDown) {
444-
this._replicationClient.stop().finally(() => {
451+
this._replicationClient.shutdown().finally(() => {
445452
this._isSubscribed = false;
446453
this._isShutDownComplete = true;
447454
});

0 commit comments

Comments
 (0)