Skip to content

Commit 8372b49

Browse files
daniel-lxsclaude
andauthored
[Improve] Harden the Discord gateway delivery and leadership paths (#410)
* Harden the Discord gateway delivery and leadership paths Follow-ups from the PR #268 reliability review: - surface the dead-letter stream: quarantine depth is tracked in gateway status and shown as a failing diagnostic row in Discord settings, so undeliverable events are visible instead of accumulating silently - bound the durable streams: the inbound stream and dead-letter stream get approximate MAXLEN caps (DISCORD_GATEWAY_INBOUND_MAX_ENTRIES, DISCORD_GATEWAY_DEAD_LETTER_MAX_ENTRIES), and capacity pressure is reported in status before shedding starts - tolerate one transient Redis error on lease renewal: the 30s lease leaves room for two 10s renew intervals, so only a definitive ownership loss or a second consecutive failure tears down the Gateway connection (renewDetailed distinguishes lost from error) - restart the delivery backoff after a long healthy run instead of ratcheting toward the lifetime maximum forever - report supervisor death: the embedded supervisor takes an onFatal hook, wired to Sentry in the BullMQ host, and stamps the shared status so a silently-stopped Discord ingestion pages and shows in diagnostics The lease-renewal tolerance is covered indirectly (renewDetailed contract + simple streak logic); there is no service-level harness to drive the renew timer directly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Address gateway review findings: attempts-hash leak and erasable pressure warning - pruneOrphanedAttempts deletes retry counters whose stream entries were shed by the MAXLEN cap (acknowledge/quarantine clean up their own); runs on the status refresh timer so the attempts hash stays bounded with the stream during a long outage - the capacity pressure warning moves from lastError — which every successful delivery clears — to a dedicated capacityWarning status field whose only writer is the refresh timer, and Discord settings shows it as an Event backlog diagnostic row Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Snapshot attempt counters before reading the oldest stream entry pruneOrphanedAttempts read the stream first: an event enqueued and attempted between XRANGE (empty) and HKEYS put a live counter in the deletion set, resetting its retry accounting past the configured maximum. Snapshotting the tracked ids first means a counter recorded after the snapshot can never be pruned; ordering is covered by tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c023805 commit 8372b49

15 files changed

Lines changed: 412 additions & 27 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@roomote/web': patch
3+
---
4+
5+
Harden the Discord gateway: quarantined (undeliverable) events now surface in
6+
the Discord settings diagnostics instead of accumulating invisibly, the
7+
durable inbound and dead-letter streams are bounded with capacity pressure
8+
reported before shedding, a single transient Redis blip no longer drops a
9+
healthy Gateway connection or ratchets delivery restarts to the maximum
10+
backoff forever, and a dead gateway supervisor reports to error tracking
11+
instead of only logging.

apps/bullmq/src/index.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
createAdminDashboardMiddleware,
3232
resolveAdminDashboardAuth,
3333
} from './admin-auth';
34-
import { initBullMqSentry } from './monitoring/sentry';
34+
import { captureBullMqMessage, initBullMqSentry } from './monitoring/sentry';
3535
import { getRedis, closeRedis } from './redis';
3636
import { startScheduler } from './scheduler';
3737
import { startSandboxOidcRefreshQueue } from './sandbox-oidc-refresh-queue';
@@ -69,12 +69,25 @@ const redis = getRedis();
6969

7070
initBullMqSentry();
7171

72-
const discordGatewaySupervisor = startDiscordGatewaySupervisor(redis, {
73-
...process.env,
74-
ENCRYPTION_KEY: Env.ENCRYPTION_KEY,
75-
R_DISCORD_GATEWAY_SECRET: Env.R_DISCORD_GATEWAY_SECRET,
76-
TRPC_URL: Env.TRPC_URL,
77-
});
72+
const discordGatewaySupervisor = startDiscordGatewaySupervisor(
73+
redis,
74+
{
75+
...process.env,
76+
ENCRYPTION_KEY: Env.ENCRYPTION_KEY,
77+
R_DISCORD_GATEWAY_SECRET: Env.R_DISCORD_GATEWAY_SECRET,
78+
TRPC_URL: Env.TRPC_URL,
79+
},
80+
{
81+
// A dead supervisor silently stops Discord ingestion while this process
82+
// stays green; make sure it pages instead of only logging.
83+
onFatal: (error) =>
84+
captureBullMqMessage(
85+
`Discord gateway supervisor stopped unexpectedly: ${error instanceof Error ? error.message : String(error)}`,
86+
undefined,
87+
{ component: 'discord-gateway', signal: 'discord-gateway-fatal' },
88+
),
89+
},
90+
);
7891

7992
const { schedulerQueue, schedulerWorker, schedulerQueueEvents } =
8093
startScheduler();

apps/discord-gateway/src/config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ const DEFAULT_DELIVERY_MAX_ATTEMPTS = 8;
44
const DEFAULT_DELIVERY_MAX_BACKOFF_MS = 30_000;
55
const DEFAULT_LOGIN_RETRY_BASE_MS = 15_000;
66
const DEFAULT_LOGIN_RETRY_MAX_MS = 5 * 60_000;
7+
const DEFAULT_INBOUND_MAX_ENTRIES = 50_000;
8+
const DEFAULT_DEAD_LETTER_MAX_ENTRIES = 1_000;
79

810
function positiveInteger(value: string | undefined, fallback: number): number {
911
const parsed = Number(value);
@@ -33,6 +35,8 @@ export type DiscordGatewayConfig = {
3335
deliveryPollMs: number;
3436
deliveryMaxAttempts: number;
3537
deliveryMaxBackoffMs: number;
38+
inboundMaxEntries: number;
39+
deadLetterMaxEntries: number;
3640
loginRetryBaseMs: number;
3741
loginRetryMaxMs: number;
3842
};
@@ -65,6 +69,14 @@ export function resolveDiscordGatewayConfig(
6569
env.DISCORD_GATEWAY_DELIVERY_MAX_BACKOFF_MS,
6670
DEFAULT_DELIVERY_MAX_BACKOFF_MS,
6771
),
72+
inboundMaxEntries: positiveInteger(
73+
env.DISCORD_GATEWAY_INBOUND_MAX_ENTRIES,
74+
DEFAULT_INBOUND_MAX_ENTRIES,
75+
),
76+
deadLetterMaxEntries: positiveInteger(
77+
env.DISCORD_GATEWAY_DEAD_LETTER_MAX_ENTRIES,
78+
DEFAULT_DEAD_LETTER_MAX_ENTRIES,
79+
),
6880
loginRetryBaseMs: positiveInteger(
6981
env.DISCORD_GATEWAY_LOGIN_RETRY_BASE_MS,
7082
DEFAULT_LOGIN_RETRY_BASE_MS,

apps/discord-gateway/src/delivery-loop.test.ts

Lines changed: 56 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/discord-gateway/src/delivery-loop.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ export async function runSupervisedDeliveryLoop(
155155
let restartDelayMs = params.pollMs;
156156

157157
while (!params.signal.aborted) {
158+
const startedAt = Date.now();
158159
try {
159160
// This flag represents a running delivery worker, not simply the
160161
// presence of an API secret. Any unexpected exit clears it first.
@@ -164,6 +165,12 @@ export async function runSupervisedDeliveryLoop(
164165
throw new Error('Discord delivery loop exited unexpectedly');
165166
} catch (error) {
166167
if (params.signal.aborted) break;
168+
// A long healthy run means this failure is a fresh incident, not part
169+
// of the previous one — start the backoff over instead of ratcheting
170+
// toward the lifetime maximum.
171+
if (Date.now() - startedAt > restartMaxBackoffMs * 2) {
172+
restartDelayMs = params.pollMs;
173+
}
167174
await params.status
168175
.update({
169176
forwardingReady: false,

apps/discord-gateway/src/embedded.test.ts

Lines changed: 16 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/discord-gateway/src/embedded.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@ export type DiscordGatewaySupervisor = {
1515
export function startDiscordGatewaySupervisor(
1616
redis: Redis,
1717
env: NodeJS.ProcessEnv = process.env,
18+
options: {
19+
/**
20+
* Invoked when the supervisor itself dies. Without it the only signal is
21+
* a console line while the host process stays green and Discord
22+
* ingestion is silently stopped — hosts should report this to their
23+
* error tracker.
24+
*/
25+
onFatal?: (error: unknown) => void;
26+
} = {},
1827
): DiscordGatewaySupervisor {
1928
const service = new DiscordGatewayService(
2029
redis,
@@ -24,6 +33,15 @@ export function startDiscordGatewaySupervisor(
2433
console.error(
2534
`[discord-gateway] supervisor stopped unexpectedly: ${error instanceof Error ? error.message : String(error)}`,
2635
);
36+
void service.status
37+
.update({
38+
phase: 'error',
39+
ready: false,
40+
connected: false,
41+
lastError: `Discord gateway supervisor stopped: ${error instanceof Error ? error.message : String(error)}`,
42+
})
43+
.catch(() => undefined);
44+
options.onFatal?.(error);
2745
});
2846

2947
return {

apps/discord-gateway/src/inbound-queue.test.ts

Lines changed: 94 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)