Skip to content

Commit d5d5246

Browse files
fix(sync,web): surface exhausted jobs and skip draft compass hop (#2522)
* fix(sync,web): surface exhausted jobs and skip draft compass hop Expose exhausted failed Sync jobs in diagnostics, sweep logs, and a manage-failed-jobs CLI so operators can clear or requeue wedged work. Project Week grid drafts straight to GridEvent without a CompassEvent bridge. Co-authored-by: Tyler Dane <tyler-dane@users.noreply.github.com> * refactor(sync,scripts): harden exhausted-job cli and share filter Use a true exhausted count with truncation signal on list, reject clear on non-failed jobs, and check remove success. Share the exhausted-job Mongo filter between count/list helpers. Co-authored-by: Tyler Dane <tyler-dane@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Tyler Dane <tyler-dane@users.noreply.github.com>
1 parent 7da433a commit d5d5246

14 files changed

Lines changed: 445 additions & 67 deletions

File tree

packages/core/src/types/sync/diagnostic.contracts.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ const sample = (): DiagnosticConnectionResponse => ({
1818
disconnectedAt: null,
1919
calendarCount: 2,
2020
pendingJobCount: 1,
21+
failedJobCount: 0,
22+
exhaustedJobCount: 0,
2123
pendingCommandCount: 0,
2224
});
2325

packages/core/src/types/sync/diagnostic.contracts.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,13 @@ export const DiagnosticConnectionResponseSchema = z.strictObject({
3131
lastHealthyAt: DateTimeSchema.nullable(),
3232
disconnectedAt: DateTimeSchema.nullable(),
3333
calendarCount: z.number().int().nonnegative(),
34+
// pending + claimed only — active work still eligible to run.
3435
pendingJobCount: z.number().int().nonnegative(),
36+
// All failed jobs for the connection (including permanent and exhausted).
37+
failedJobCount: z.number().int().nonnegative(),
38+
// Failed jobs that exhausted the self-heal requeue budget and need an
39+
// operator (`bun run cli manage-failed-jobs …`).
40+
exhaustedJobCount: z.number().int().nonnegative(),
3541
pendingCommandCount: z.number().int().nonnegative(),
3642
});
3743
export type DiagnosticConnectionResponse = z.infer<

packages/scripts/src/cli.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { CliValidator } from "@scripts/cli.validator";
2+
import { runManageFailedJobs } from "@scripts/commands/manage-failed-jobs";
23
import { runPurgeCorruptSyncEvents } from "@scripts/commands/purge-corrupt-sync-events";
34
import { runPurgeUser } from "@scripts/commands/purge-user";
45
import { runRefreshConnectionStates } from "@scripts/commands/refresh-connection-states";
@@ -25,6 +26,9 @@ export default class CompassCLI {
2526
case cmd === "refresh-connection-states":
2627
await runRefreshConnectionStates();
2728
break;
29+
case cmd === "manage-failed-jobs":
30+
await runManageFailedJobs();
31+
break;
2832
case cmd === "purge-user":
2933
await runPurgeUser();
3034
break;
@@ -73,6 +77,14 @@ export default class CompassCLI {
7377
"Re-derive every provider connection's stored state from live evidence (--apply to write)",
7478
);
7579

80+
program
81+
.command("manage-failed-jobs")
82+
.helpOption(false)
83+
.allowUnknownOption(true)
84+
.description(
85+
"List/clear/requeue Sync jobs that exhausted the self-heal budget (list | clear | requeue)",
86+
);
87+
7688
return program;
7789
}
7890
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { loadCompassConfig } from "@core/config/compass.config";
2+
import { Logger } from "@core/logger/winston.logger";
3+
import { SyncJobIdSchema } from "@core/types/sync/identity.contracts";
4+
import { FAILED_JOB_MAX_REQUEUES } from "@sync/domain/failed-job-requeue.service";
5+
import { JobRepository } from "@sync/storage/repositories/job.repository";
6+
import { SyncMongoService } from "@sync/storage/sync-mongo.service";
7+
8+
const logger = Logger("scripts.commands.manage-failed-jobs");
9+
10+
function syncMongoUri(): string {
11+
const fromEnv = process.env["SYNC_MONGO_URI"]?.trim();
12+
if (fromEnv) return fromEnv;
13+
const uri = loadCompassConfig().sync?.mongoUri?.trim();
14+
if (!uri) {
15+
throw new Error(
16+
"Set SYNC_MONGO_URI or add sync.mongoUri to compass.yaml before manage-failed-jobs",
17+
);
18+
}
19+
return uri;
20+
}
21+
22+
function flagValue(args: string[], name: string): string | undefined {
23+
const index = args.indexOf(name);
24+
if (index < 0) return undefined;
25+
return args[index + 1];
26+
}
27+
28+
/**
29+
* Operator tooling for Sync jobs that exhausted the self-heal requeue budget
30+
* and still occupy a coalescing key. Default actions are dry-run; `--apply`
31+
* persists.
32+
*
33+
* bun run cli manage-failed-jobs list
34+
* bun run cli manage-failed-jobs clear --id <id> --coalescing-key <key> [--apply]
35+
* bun run cli manage-failed-jobs requeue --id <id> [--apply]
36+
*/
37+
export async function runManageFailedJobs(): Promise<void> {
38+
const args = process.argv.slice(3);
39+
const action = args[0];
40+
if (action !== "list" && action !== "clear" && action !== "requeue") {
41+
throw new Error(
42+
"Usage: manage-failed-jobs <list|clear|requeue> [--id …] [--coalescing-key …] [--apply]",
43+
);
44+
}
45+
46+
const apply = args.includes("--apply");
47+
const syncMongo = new SyncMongoService();
48+
try {
49+
await syncMongo.connect({
50+
uri: syncMongoUri(),
51+
enforceLeastPrivilege: false,
52+
forbiddenDatabaseName: "prod_calendar",
53+
});
54+
const jobs = new JobRepository(syncMongo.db);
55+
56+
if (action === "list") {
57+
const [count, sample] = await Promise.all([
58+
jobs.countExhaustedFailed(FAILED_JOB_MAX_REQUEUES),
59+
jobs.listExhaustedFailed(FAILED_JOB_MAX_REQUEUES),
60+
]);
61+
const report = {
62+
maxRequeues: FAILED_JOB_MAX_REQUEUES,
63+
count,
64+
sampleSize: sample.length,
65+
truncated: count > sample.length,
66+
jobs: sample.map((job) => ({
67+
id: job.id,
68+
coalescingKey: job.coalescingKey,
69+
connectionId: job.connectionId,
70+
failureClass: job.failureClass,
71+
requeuedCount: job.requeuedCount,
72+
updatedAt: job.updatedAt.toISOString(),
73+
})),
74+
};
75+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
76+
logger.info(
77+
`manage-failed-jobs list count=${report.count} sample=${report.sampleSize} truncated=${report.truncated}`,
78+
);
79+
await syncMongo.disconnect();
80+
process.exit(0);
81+
}
82+
83+
const idRaw = flagValue(args, "--id");
84+
if (!idRaw) {
85+
throw new Error(`manage-failed-jobs ${action} requires --id <SyncJobId>`);
86+
}
87+
const id = SyncJobIdSchema.parse(idRaw);
88+
const existing = await jobs.findByIdUnscoped(id);
89+
if (!existing) {
90+
throw new Error(`No job found for id=${id}`);
91+
}
92+
if (existing.state !== "failed") {
93+
throw new Error(
94+
`Job id=${id} is state=${existing.state}; ${action} only accepts failed jobs`,
95+
);
96+
}
97+
98+
if (action === "clear") {
99+
const coalescingKey =
100+
flagValue(args, "--coalescing-key") ?? existing.coalescingKey;
101+
if (coalescingKey !== existing.coalescingKey) {
102+
throw new Error(
103+
`coalescing-key mismatch for id=${id}: expected ${existing.coalescingKey}`,
104+
);
105+
}
106+
const report = {
107+
dryRun: !apply,
108+
action: "clear" as const,
109+
id,
110+
coalescingKey,
111+
state: existing.state,
112+
};
113+
if (apply) {
114+
const ok = await jobs.remove(id, coalescingKey);
115+
if (!ok) {
116+
throw new Error(`Failed to clear id=${id} key=${coalescingKey}`);
117+
}
118+
}
119+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
120+
logger.info(
121+
`manage-failed-jobs clear dryRun=${report.dryRun} id=${id} key=${coalescingKey}`,
122+
);
123+
await syncMongo.disconnect();
124+
process.exit(0);
125+
}
126+
127+
const report = {
128+
dryRun: !apply,
129+
action: "requeue" as const,
130+
id,
131+
coalescingKey: existing.coalescingKey,
132+
requeuedCount: existing.requeuedCount,
133+
};
134+
if (apply) {
135+
const ok = await jobs.requeue(id, new Date());
136+
if (!ok) {
137+
throw new Error(`Failed to requeue id=${id}`);
138+
}
139+
}
140+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
141+
logger.info(`manage-failed-jobs requeue dryRun=${report.dryRun} id=${id}`);
142+
await syncMongo.disconnect();
143+
process.exit(0);
144+
} catch (error) {
145+
logger.error(error);
146+
try {
147+
await syncMongo.disconnect();
148+
} catch {
149+
// ignore
150+
}
151+
process.exit(1);
152+
}
153+
}

packages/sync/src/app.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import {
99
CONNECTION_CACHE_RETENTION_MS,
1010
purgeExpiredDisconnectedConnections,
1111
} from "@sync/domain/connection-retention.service";
12-
import { requeueFailedJobs } from "@sync/domain/failed-job-requeue.service";
12+
import {
13+
FAILED_JOB_MAX_REQUEUES,
14+
requeueFailedJobs,
15+
} from "@sync/domain/failed-job-requeue.service";
1316
import { reconcileStaleCalendars } from "@sync/domain/reconcile.service";
1417
import { retryStaleCommands } from "@sync/domain/stale-command-retry.service";
1518
import { maintainExpiringSubscriptions } from "@sync/domain/subscription-sweep.service";
@@ -262,9 +265,6 @@ async function start(): Promise<void> {
262265
// at least this long — long enough that a real provider outage has had a
263266
// chance to clear before we burn another retry ladder on it.
264267
const FAILED_JOB_REQUEUE_COOLDOWN_MS = 30 * 60_000;
265-
// How many times the self-heal sweep will requeue the same job before
266-
// leaving it failed for an operator instead.
267-
const FAILED_JOB_MAX_REQUEUES = 3;
268268
// A resource not synced within this window is swept for a reconcile pull.
269269
const RECONCILE_STALE_AFTER_MS = 15 * 60_000;
270270
// A cloud command left nonterminal past this long since its last update is
@@ -467,6 +467,16 @@ function buildSchedulers(
467467
if (result.exhausted > 0) {
468468
logger.error(
469469
`Sync self-heal sweep: ${result.exhausted} failed job(s) exhausted their requeue budget and need operator attention`,
470+
{
471+
exhaustedJobs: result.exhaustedJobs.map((job) => ({
472+
id: job.id,
473+
coalescingKey: job.coalescingKey,
474+
connectionId: job.connectionId,
475+
failureClass: job.failureClass,
476+
requeuedCount: job.requeuedCount,
477+
updatedAt: job.updatedAt.toISOString(),
478+
})),
479+
},
470480
);
471481
}
472482
return result.requeued;

packages/sync/src/domain/connection-diagnostic.service.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
type DiagnosticConnectionResponse,
33
DiagnosticConnectionResponseSchema,
44
} from "@core/types/sync/diagnostic.contracts";
5+
import { FAILED_JOB_MAX_REQUEUES } from "@sync/domain/failed-job-requeue.service";
56
import { type CommandRepository } from "@sync/storage/repositories/command.repository";
67
import { type JobRepository } from "@sync/storage/repositories/job.repository";
78
import { type ProviderCalendarRepository } from "@sync/storage/repositories/provider-calendar.repository";
@@ -19,6 +20,7 @@ export interface ConnectionDiagnosticDeps {
1920
export async function resolveDiagnosticConnection(
2021
deps: ConnectionDiagnosticDeps,
2122
diagnosticKey: string,
23+
maxRequeues: number = FAILED_JOB_MAX_REQUEUES,
2224
): Promise<DiagnosticConnectionResponse | null> {
2325
const connection = await deps.connections.findByDiagnosticKey(diagnosticKey);
2426
if (!connection) return null;
@@ -28,12 +30,28 @@ export async function resolveDiagnosticConnection(
2830
connection.principalId,
2931
connection._id,
3032
);
31-
const [pendingJobCount, pendingCommandCount] = await Promise.all([
33+
const [
34+
pendingJobCount,
35+
failedJobCount,
36+
exhaustedJobCount,
37+
pendingCommandCount,
38+
] = await Promise.all([
3239
deps.jobs.countOutstandingByConnection(
3340
connection.tenantId,
3441
connection.principalId,
3542
connection._id,
3643
),
44+
deps.jobs.countFailedByConnection(
45+
connection.tenantId,
46+
connection.principalId,
47+
connection._id,
48+
),
49+
deps.jobs.countExhaustedFailedByConnection(
50+
connection.tenantId,
51+
connection.principalId,
52+
connection._id,
53+
maxRequeues,
54+
),
3755
deps.commands.countNonterminalByConnection(
3856
connection.tenantId,
3957
connection.principalId,
@@ -56,6 +74,8 @@ export async function resolveDiagnosticConnection(
5674
disconnectedAt: connection.disconnectedAt?.toISOString() ?? null,
5775
calendarCount: calendars.length,
5876
pendingJobCount,
77+
failedJobCount,
78+
exhaustedJobCount,
5979
pendingCommandCount,
6080
});
6181
}

packages/sync/src/domain/failed-job-requeue.service.db.test.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ describe("requeueFailedJobs", () => {
4949

5050
const result = await requeueFailedJobs(deps(), cooldownBefore, now, 3);
5151

52-
expect(result).toEqual({ requeued: 1, exhausted: 0 });
52+
expect(result).toEqual({ requeued: 1, exhausted: 0, exhaustedJobs: [] });
5353
const raw = await storage
5454
.db()
5555
.collection("jobs")
@@ -64,7 +64,7 @@ describe("requeueFailedJobs", () => {
6464

6565
const result = await requeueFailedJobs(deps(), cooldownBefore, now, 3);
6666

67-
expect(result).toEqual({ requeued: 0, exhausted: 0 });
67+
expect(result).toEqual({ requeued: 0, exhausted: 0, exhaustedJobs: [] });
6868
});
6969

7070
it("stops requeuing once a job hits the cap and reports it as exhausted", async () => {
@@ -84,13 +84,22 @@ describe("requeueFailedJobs", () => {
8484

8585
const result = await requeueFailedJobs(deps(), cooldownBefore, now, 2);
8686

87-
expect(result).toEqual({ requeued: 0, exhausted: 1 });
87+
expect(result.requeued).toBe(0);
88+
expect(result.exhausted).toBe(1);
89+
expect(result.exhaustedJobs).toEqual([
90+
expect.objectContaining({
91+
id,
92+
failureClass: "retryableTransient",
93+
requeuedCount: 2,
94+
}),
95+
]);
8896
});
8997

9098
it("does nothing when there are no failed jobs", async () => {
9199
expect(await requeueFailedJobs(deps(), cooldownBefore, now, 3)).toEqual({
92100
requeued: 0,
93101
exhausted: 0,
102+
exhaustedJobs: [],
94103
});
95104
});
96105
});

packages/sync/src/domain/failed-job-requeue.service.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { type JobRepository } from "@sync/storage/repositories/job.repository";
1+
import {
2+
type ExhaustedFailedJob,
3+
type JobRepository,
4+
} from "@sync/storage/repositories/job.repository";
5+
6+
// How many times the self-heal sweep will requeue the same job before leaving
7+
// it failed for an operator instead. Shared with diagnostics so exhausted
8+
// counts use the same budget the sweep enforces.
9+
export const FAILED_JOB_MAX_REQUEUES = 3;
210

311
export interface FailedJobRequeueDeps {
412
jobs: JobRepository;
@@ -10,6 +18,8 @@ export interface FailedJobRequeueResult {
1018
// How many failed jobs have hit the requeue cap and re-failed anyway — the
1119
// sweep will not touch them again; they need an operator.
1220
exhausted: number;
21+
// Bounded sample of exhausted rows for operator-facing logs / CLI.
22+
exhaustedJobs: ExhaustedFailedJob[];
1323
}
1424

1525
// The self-heal sweep for jobs terminalized as state:"failed". A worker marks
@@ -34,7 +44,7 @@ export async function requeueFailedJobs(
3444
deps: FailedJobRequeueDeps,
3545
before: Date,
3646
now: () => Date,
37-
maxRequeues: number,
47+
maxRequeues: number = FAILED_JOB_MAX_REQUEUES,
3848
limit = 100,
3949
): Promise<FailedJobRequeueResult> {
4050
const candidates = await deps.jobs.listFailedForRequeue(
@@ -45,6 +55,13 @@ export async function requeueFailedJobs(
4555
for (const job of candidates) {
4656
await deps.jobs.requeue(job._id, now());
4757
}
48-
const exhausted = await deps.jobs.countExhaustedFailed(maxRequeues);
49-
return { requeued: candidates.length, exhausted };
58+
const [exhausted, exhaustedJobs] = await Promise.all([
59+
deps.jobs.countExhaustedFailed(maxRequeues),
60+
deps.jobs.listExhaustedFailed(maxRequeues),
61+
]);
62+
return {
63+
requeued: candidates.length,
64+
exhausted,
65+
exhaustedJobs,
66+
};
5067
}

0 commit comments

Comments
 (0)