Skip to content

Commit 755d67f

Browse files
fix: address background sync edge cases
1 parent f5367b1 commit 755d67f

10 files changed

Lines changed: 108 additions & 15 deletions

File tree

docs/snippets/schemas/v3/index.schema.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838
"reindexRepoPollingIntervalMs": {
3939
"type": "number",
4040
"description": "The polling rate (in milliseconds) at which the db should be checked for repos that should be re-indexed. Defaults to 1 second.",
41-
"minimum": 1
41+
"minimum": 1,
42+
"deprecated": true
4243
},
4344
"maxConnectionSyncJobConcurrency": {
4445
"type": "number",
@@ -224,7 +225,8 @@
224225
"reindexRepoPollingIntervalMs": {
225226
"type": "number",
226227
"description": "The polling rate (in milliseconds) at which the db should be checked for repos that should be re-indexed. Defaults to 1 second.",
227-
"minimum": 1
228+
"minimum": 1,
229+
"deprecated": true
228230
},
229231
"maxConnectionSyncJobConcurrency": {
230232
"type": "number",

packages/backend/src/ee/repoPermissionSyncWorkload.test.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ const repo = {
7777
name: "github.com/sourcebot-dev/sourcebot",
7878
displayName: "sourcebot-dev/sourcebot",
7979
external_codeHostType: "github",
80+
external_codeHostUrl: "https://github.com",
8081
external_id: "123",
8182
metadata: {},
8283
connections: [],
@@ -239,7 +240,7 @@ describe("repoPermissionSyncWorkload", () => {
239240
expect(repoFindUniqueOrThrow).not.toHaveBeenCalled();
240241
});
241242

242-
test("replaces all permissions for a complete GitHub sync", async () => {
243+
test("uses the canonical repo issuer for a hostless GitHub Cloud sync", async () => {
243244
const githubRepo = {
244245
...repo,
245246
external_codeHostType: "github",
@@ -248,7 +249,6 @@ describe("repoPermissionSyncWorkload", () => {
248249
};
249250
repoFindUniqueOrThrow.mockResolvedValue(githubRepo);
250251
mocks.getAuthCredentialsForRepo.mockResolvedValue({
251-
hostUrl: "https://github.com",
252252
token: "token",
253253
});
254254
const octokit = {};
@@ -292,6 +292,7 @@ describe("repoPermissionSyncWorkload", () => {
292292
const bitbucketRepo = {
293293
...repo,
294294
external_codeHostType: "bitbucketCloud",
295+
external_codeHostUrl: "https://bitbucket.org",
295296
external_id: "repo-uuid",
296297
metadata: {
297298
codeHostMetadata: {
@@ -304,7 +305,7 @@ describe("repoPermissionSyncWorkload", () => {
304305
};
305306
repoFindUniqueOrThrow.mockResolvedValue(bitbucketRepo);
306307
mocks.getAuthCredentialsForRepo.mockResolvedValue({
307-
hostUrl: "https://bitbucket.org",
308+
hostUrl: "https://bitbucket.org/",
308309
token: "token",
309310
connectionConfig: {
310311
user: "service-account",
@@ -318,6 +319,15 @@ describe("repoPermissionSyncWorkload", () => {
318319

319320
await createWorkload().process(processContext);
320321

322+
expect(accountFindMany).toHaveBeenCalledWith({
323+
where: {
324+
providerType: "bitbucket-cloud",
325+
providerAccountId: {
326+
in: ["upstream-account"],
327+
},
328+
issuerUrl: "https://bitbucket.org",
329+
},
330+
});
321331
expect(repoUpdate).toHaveBeenCalledWith({
322332
where: { id: 42 },
323333
data: {

packages/backend/src/ee/repoPermissionSyncWorkload.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ const getGitHubPermissionSyncResult = async ({
278278
providerAccountId: {
279279
in: githubUserIds,
280280
},
281-
issuerUrl: credentials.hostUrl,
281+
issuerUrl: repo.external_codeHostUrl,
282282
},
283283
});
284284

@@ -311,7 +311,7 @@ const getGitLabPermissionSyncResult = async ({
311311
providerAccountId: {
312312
in: gitlabUserIds,
313313
},
314-
issuerUrl: credentials.hostUrl,
314+
issuerUrl: repo.external_codeHostUrl,
315315
},
316316
});
317317

@@ -370,7 +370,7 @@ const getBitbucketCloudPermissionSyncResult = async ({
370370
providerAccountId: {
371371
in: userAccountIds,
372372
},
373-
issuerUrl: credentials.hostUrl,
373+
issuerUrl: repo.external_codeHostUrl,
374374
},
375375
});
376376

@@ -429,7 +429,7 @@ const getBitbucketServerPermissionSyncResult = async ({
429429
where: {
430430
providerType: "bitbucket-server",
431431
providerAccountId: { in: userIds },
432-
issuerUrl: credentials.hostUrl,
432+
issuerUrl: repo.external_codeHostUrl,
433433
},
434434
});
435435

packages/backend/src/jobManager.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,4 +389,46 @@ describe("BullMQJobManager lifecycle", () => {
389389
expect.objectContaining({ attempt: 2 }),
390390
);
391391
});
392+
393+
test("treats BullMQ's stalled-limit failure as terminal", async () => {
394+
const onTerminalFailure = vi.fn(async () => undefined);
395+
const manager = new BullMQJobManager({} as Redis);
396+
manager.register(createWorkload({ onTerminalFailure }));
397+
await manager.start();
398+
399+
const stalledJob = { ...job, attemptsMade: 1 };
400+
const error = new Error("job stalled more than allowable limit");
401+
mocks.workers[0].handlers.get("failed")?.(stalledJob, error);
402+
403+
await vi.waitFor(() => {
404+
expect(onTerminalFailure).toHaveBeenCalledWith(
405+
expect.objectContaining({
406+
jobId: "job-1",
407+
attemptsMade: 1,
408+
maxAttempts: 2,
409+
}),
410+
error,
411+
);
412+
});
413+
});
414+
415+
test("keeps ordinary failures retryable before attempts are exhausted", async () => {
416+
const onTerminalFailure = vi.fn(async () => undefined);
417+
const manager = new BullMQJobManager({} as Redis);
418+
manager.register(createWorkload({ onTerminalFailure }));
419+
await manager.start();
420+
421+
const retryableJob = { ...job, attemptsMade: 1 };
422+
mocks.workers[0].handlers.get("failed")?.(
423+
retryableJob,
424+
new Error("temporary failure"),
425+
);
426+
427+
await vi.waitFor(() => {
428+
expect(mocks.logger.warn).toHaveBeenCalledWith(
429+
expect.stringContaining("will retry"),
430+
);
431+
});
432+
expect(onTerminalFailure).not.toHaveBeenCalled();
433+
});
392434
});

packages/backend/src/jobManager.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type { JobManager } from "./types.js";
2020
import { prisma } from "./prisma.js";
2121

2222
const LOG_TAG = "job-manager";
23+
const STALLED_JOB_TERMINAL_ERROR = "job stalled more than allowable limit";
2324
const logger = createLogger(LOG_TAG);
2425

2526
export class BullMQJobManager implements JobManager {
@@ -278,7 +279,9 @@ export class BullMQJobManager implements JobManager {
278279
return;
279280
}
280281
const maxAttempts = job.opts.attempts ?? 1;
281-
const isTerminal = job.attemptsMade >= maxAttempts;
282+
const isTerminal =
283+
job.attemptsMade >= maxAttempts ||
284+
error.message === STALLED_JOB_TERMINAL_ERROR;
282285
if (!isTerminal) {
283286
logger.warn(
284287
`Workload "${workload.queueSpec.name}" job ${job.id} failed attempt ${job.attemptsMade}/${maxAttempts}; will retry: ${error.message}`,

packages/schemas/src/v3/index.schema.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ const schema = {
3737
"reindexRepoPollingIntervalMs": {
3838
"type": "number",
3939
"description": "The polling rate (in milliseconds) at which the db should be checked for repos that should be re-indexed. Defaults to 1 second.",
40-
"minimum": 1
40+
"minimum": 1,
41+
"deprecated": true
4142
},
4243
"maxConnectionSyncJobConcurrency": {
4344
"type": "number",
@@ -223,7 +224,8 @@ const schema = {
223224
"reindexRepoPollingIntervalMs": {
224225
"type": "number",
225226
"description": "The polling rate (in milliseconds) at which the db should be checked for repos that should be re-indexed. Defaults to 1 second.",
226-
"minimum": 1
227+
"minimum": 1,
228+
"deprecated": true
227229
},
228230
"maxConnectionSyncJobConcurrency": {
229231
"type": "number",

packages/schemas/src/v3/index.type.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export interface Settings {
106106
*/
107107
resyncConnectionPollingIntervalMs?: number;
108108
/**
109+
* @deprecated
109110
* The polling rate (in milliseconds) at which the db should be checked for repos that should be re-indexed. Defaults to 1 second.
110111
*/
111112
reindexRepoPollingIntervalMs?: number;

packages/shared/src/jobLogger.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,30 @@ describe("createBullMQJobLogSink", () => {
102102

103103
expect(JSON.parse(log.mock.calls[0][0])).toMatchObject({ attempt: 2 });
104104
});
105+
106+
test("closes before draining and ignores later writes", async () => {
107+
let resolveWrite: (value: number) => void = () => undefined;
108+
const pendingWrite = new Promise<number>((resolve) => {
109+
resolveWrite = resolve;
110+
});
111+
const log = vi.fn().mockReturnValue(pendingWrite);
112+
const sink = createBullMQJobLogSink({
113+
id: "job-1",
114+
name: "connection",
115+
queueName: "connection",
116+
attemptsMade: 0,
117+
log,
118+
});
119+
120+
sink.info("Before flush");
121+
const flush = sink.flush();
122+
sink.info("During flush");
123+
resolveWrite(1);
124+
await flush;
125+
sink.info("After flush");
126+
127+
expect(log).toHaveBeenCalledOnce();
128+
});
105129
});
106130

107131
describe("readBullMQJobLogs", () => {

packages/shared/src/jobLogger.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,12 +177,17 @@ export const createBullMQJobLogSink = (
177177
options.label ?? `${job.queueName}:job:${job.id ?? "unknown"}`;
178178
const attempt = options.attempt ?? job.attemptsMade + 1;
179179
const pendingWrites = new Set<Promise<void>>();
180+
let closed = false;
180181

181182
const write = (
182183
level: JobLogLevel,
183184
message: string,
184185
rawFields?: unknown,
185186
): void => {
187+
if (closed) {
188+
return;
189+
}
190+
186191
const fields = sanitizeFields(rawFields);
187192

188193
const entry: JobLogEntry = {
@@ -221,7 +226,10 @@ export const createBullMQJobLogSink = (
221226
error: (message: string, fields?: unknown) =>
222227
write("error", message, fields),
223228
flush: async () => {
224-
await Promise.all([...pendingWrites]);
229+
closed = true;
230+
while (pendingWrites.size > 0) {
231+
await Promise.all([...pendingWrites]);
232+
}
225233
},
226234
} satisfies JobLogSink & { flush(): Promise<void> };
227235
};

schemas/v3/index.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@
3636
"reindexRepoPollingIntervalMs": {
3737
"type": "number",
3838
"description": "The polling rate (in milliseconds) at which the db should be checked for repos that should be re-indexed. Defaults to 1 second.",
39-
"minimum": 1
39+
"minimum": 1,
40+
"deprecated": true
4041
},
4142
"maxConnectionSyncJobConcurrency": {
4243
"type": "number",
@@ -176,4 +177,4 @@
176177
}
177178
},
178179
"additionalProperties": false
179-
}
180+
}

0 commit comments

Comments
 (0)