Skip to content

Commit 39b5953

Browse files
feat(backend): add BullMQ JobManager framework (#1427)
* feat(backend): add BullMQ JobManager framework Introduces a JobManager over BullMQ: work-queue Workloads with a ProcessContext, CronWorkloads multiplexed onto a shared cron worker with a reconcile() sweep helper, a JobProducer that owns the queues and the deduplicated enqueue path, and a Redis-backed read model (status/jobDetail). Wired into the backend entrypoint with a demo workload. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * wip * further wip * further wip * migrated repo indexing to workload * further wip * simplicity: remove much of the web changes * wip * add necessary lifecyle hooks * further wip * further wip * migrate attachment & audit log pruning to workload system * prioritize initial repository indexing * update retry behaviour * add locking * improve logging s.t., we use a log context * fix: make job scheduler upserts idempotent * fix: clean up repos from deleted connections * refactor: rename job scheduler reconciliation * fix: tolerate missing account permission sync jobs * fix: address background sync edge cases * docs: add job manager changelog entry --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4d21e8c commit 39b5953

94 files changed

Lines changed: 10106 additions & 4252 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
- Migrated connection syncing, repository indexing, permission syncing, and background pruning from in-process managers and pollers to BullMQ workloads with retries and per-resource execution locking. [#1427](https://github.com/sourcebot-dev/sourcebot/pull/1427)
12+
1013
### Fixed
1114
- Upgraded Next.js to 16.3.1 to bound memory retained by high-cardinality dynamic route cache entries. [#1594](https://github.com/sourcebot-dev/sourcebot/pull/1594)
1215
- Fixed memory leak attributed to CodeMirror allocating objects on heap that were never freed. [#1580](https://github.com/sourcebot-dev/sourcebot/pull/1580)

CLAUDE.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,26 @@ To build a specific package:
1818
yarn workspace @sourcebot/<package-name> build
1919
```
2020

21+
## Backend Workloads
22+
23+
Use the workload system in `packages/backend` for background work. Define the queue payload and default job behavior in the shared queue registry, implement a `Workload`, and register it with the `JobManager`.
24+
25+
### Execution locks
26+
27+
- Key an execution lock by the logical resource being mutated, not by the job ID. Workloads that mutate the same resource must use the exact same lock key. For example, repo indexing and repo cleanup share the per-repo filesystem and search-index lock, while repo permission syncing uses a separate per-repo permission lock.
28+
- An execution lock serializes work but does not deduplicate it. Multiple jobs for one resource may still be queued and will execute one at a time.
29+
- The lock lease is extended automatically while work is running. The workload's `AbortSignal` is aborted if extension fails or the worker shuts down.
30+
- Abortion is cooperative. Call `signal.throwIfAborted()` before side effects and after long-running or external operations so work stops promptly after losing the lock. The signal cannot cancel an operation that has already been submitted.
31+
- `onStarted` runs after the execution lock is acquired and immediately before `process`. `onCompleted` and `onTerminalFailure` are BullMQ event hooks and run after the processor has returned and released the lock.
32+
33+
### Lifecycle state
34+
35+
- In `onStarted`, upsert the workload-specific job row as `IN_PROGRESS`. If the parent resource tracks a `latest...JobId`, update that pointer in the same database transaction.
36+
- Completion and terminal-failure hooks must always update their own historical job row by job ID. Do not condition that update on the job still being latest. Every job row should record its actual outcome.
37+
- `onTerminalFailure` only runs after the job exhausts all retry attempts. Intermediate failures are retried without marking the lifecycle row as terminally failed.
38+
- If a completion or failure hook publishes state onto the parent resource, use a conditional `updateMany` keyed by both the resource ID and its `latest...JobId`. This prevents an older hook from overwriting state belonging to a newer job after the execution lock has been released.
39+
- Parent-resource state written inside `process` is already serialized by the execution lock. It does not need a latest-job conditional merely because the resource tracks the latest job ID.
40+
2141
## File Naming
2242

2343
Files should use camelCase starting with a lowercase letter:

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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,14 @@
3232
"resyncConnectionPollingIntervalMs": {
3333
"type": "number",
3434
"description": "The polling rate (in milliseconds) at which the db should be checked for connections that need to be re-synced. Defaults to 1 second.",
35-
"minimum": 1
35+
"minimum": 1,
36+
"deprecated": true
3637
},
3738
"reindexRepoPollingIntervalMs": {
3839
"type": "number",
3940
"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
41+
"minimum": 1,
42+
"deprecated": true
4143
},
4244
"maxConnectionSyncJobConcurrency": {
4345
"type": "number",
@@ -52,7 +54,8 @@
5254
"maxRepoGarbageCollectionJobConcurrency": {
5355
"type": "number",
5456
"description": "The number of repo GC jobs to run concurrently. Defaults to 8.",
55-
"minimum": 1
57+
"minimum": 1,
58+
"deprecated": true
5659
},
5760
"repoGarbageCollectionGracePeriodMs": {
5861
"type": "number",
@@ -216,12 +219,14 @@
216219
"resyncConnectionPollingIntervalMs": {
217220
"type": "number",
218221
"description": "The polling rate (in milliseconds) at which the db should be checked for connections that need to be re-synced. Defaults to 1 second.",
219-
"minimum": 1
222+
"minimum": 1,
223+
"deprecated": true
220224
},
221225
"reindexRepoPollingIntervalMs": {
222226
"type": "number",
223227
"description": "The polling rate (in milliseconds) at which the db should be checked for repos that should be re-indexed. Defaults to 1 second.",
224-
"minimum": 1
228+
"minimum": 1,
229+
"deprecated": true
225230
},
226231
"maxConnectionSyncJobConcurrency": {
227232
"type": "number",
@@ -236,7 +241,8 @@
236241
"maxRepoGarbageCollectionJobConcurrency": {
237242
"type": "number",
238243
"description": "The number of repo GC jobs to run concurrently. Defaults to 8.",
239-
"minimum": 1
244+
"minimum": 1,
245+
"deprecated": true
240246
},
241247
"repoGarbageCollectionGracePeriodMs": {
242248
"type": "number",

packages/backend/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
"vitest": "^4.1.4"
2323
},
2424
"dependencies": {
25+
"@bull-board/api": "6.11.2",
26+
"@bull-board/express": "6.11.2",
27+
"@bull-board/ui": "6.11.2",
2528
"@coderabbitai/bitbucket": "^1.1.3",
2629
"@gitbeaker/rest": "^40.5.1",
2730
"@octokit/app": "^16.1.1",
@@ -35,7 +38,7 @@
3538
"@types/express": "^5.0.0",
3639
"argparse": "^2.0.1",
3740
"azure-devops-node-api": "^15.1.1",
38-
"bullmq": "^5.34.10",
41+
"bullmq": "^5.81.3",
3942
"chokidar": "^4.0.3",
4043
"cross-fetch": "^4.0.0",
4144
"dotenv": "^16.4.5",
@@ -46,7 +49,7 @@
4649
"gitea-js": "^1.22.0",
4750
"glob": "^11.1.0",
4851
"http-status-codes": "^2.3.0",
49-
"ioredis": "^5.4.2",
52+
"ioredis": "^5.11.1",
5053
"lowdb": "^7.0.1",
5154
"micromatch": "^4.0.8",
5255
"p-limit": "^7.2.0",

packages/backend/src/api.ts

Lines changed: 28 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
1+
import { createBullBoard } from '@bull-board/api';
2+
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter.js';
3+
import { ExpressAdapter } from '@bull-board/express';
4+
import { Octokit } from '@octokit/rest';
5+
import * as Sentry from "@sentry/node";
16
import { PrismaClient, RepoIndexingJobType } from '@sourcebot/db';
2-
import * as Sentry from '@sentry/node';
3-
import { hasEntitlement } from './entitlements.js';
4-
import { createLogger, doesIdpSupportPermissionSyncing, env } from '@sourcebot/shared';
7+
import { createLogger, env, JOB_PRIORITIES } from '@sourcebot/shared';
58
import express, { NextFunction, Request, Response } from 'express';
69
import 'express-async-errors';
710
import * as http from "http";
8-
import { ConnectionManager } from './connectionManager.js';
9-
import { AccountPermissionSyncer } from './ee/accountPermissionSyncer.js';
11+
import z from 'zod';
12+
import { SINGLE_TENANT_ORG_ID } from './constants.js';
13+
import { isGitHubRateLimitError, isNotFound } from './errors.js';
1014
import { PromClient } from './promClient.js';
11-
import { RepoIndexManager } from './repoIndexManager.js';
1215
import { createGitHubRepoRecord } from './repoCompileUtils.js';
13-
import { isGitHubRateLimitError, isNotFound } from './errors.js';
14-
import { Octokit } from '@octokit/rest';
15-
import { SINGLE_TENANT_ORG_ID } from './constants.js';
16-
import z from 'zod';
16+
import type { JobManager } from './types.js';
1717

1818
const logger = createLogger('api');
1919

@@ -26,24 +26,27 @@ export class Api {
2626
constructor(
2727
promClient: PromClient,
2828
private prisma: PrismaClient,
29-
private connectionManager: ConnectionManager,
30-
private repoIndexManager: RepoIndexManager,
31-
private accountPermissionSyncer: AccountPermissionSyncer,
29+
private jobManager: JobManager,
3230
) {
3331
const app = express();
3432
app.use(express.json());
3533
app.use(express.urlencoded({ extended: true }));
3634

35+
const bullBoardAdapter = new ExpressAdapter();
36+
bullBoardAdapter.setBasePath('/admin/queues');
37+
createBullBoard({
38+
queues: jobManager.getQueues().map(queue => new BullMQAdapter(queue, { readOnlyMode: true })),
39+
serverAdapter: bullBoardAdapter,
40+
});
41+
app.use('/admin/queues', bullBoardAdapter.getRouter());
42+
3743
// Prometheus metrics endpoint
3844
app.use('/metrics', async (_req: Request, res: Response) => {
3945
res.set('Content-Type', promClient.registry.contentType);
4046
const metrics = await promClient.registry.metrics();
4147
res.end(metrics);
4248
});
4349

44-
app.post('/api/sync-connection', this.syncConnection.bind(this));
45-
app.post('/api/index-repo', this.indexRepo.bind(this));
46-
app.post('/api/trigger-account-permission-sync', this.triggerAccountPermissionSync.bind(this));
4750
app.post(`/api/experimental/add-github-repo`, this.experimental_addGithubRepo.bind(this));
4851

4952
app.use((error: unknown, _req: Request, _res: Response, next: NextFunction) => {
@@ -53,97 +56,10 @@ export class Api {
5356

5457
this.server = app.listen(PORT, () => {
5558
logger.debug(`API server is running on port ${PORT}`);
59+
logger.debug(`Bull Board is available at ${workerApiUrl.origin}/admin/queues`);
5660
});
5761
}
5862

59-
private async syncConnection(req: Request, res: Response) {
60-
const schema = z.object({
61-
connectionId: z.number(),
62-
}).strict();
63-
64-
const parsed = schema.safeParse(req.body);
65-
if (!parsed.success) {
66-
res.status(400).json({ error: parsed.error.message });
67-
return;
68-
}
69-
70-
const { connectionId } = parsed.data;
71-
const connection = await this.prisma.connection.findUnique({
72-
where: {
73-
id: connectionId,
74-
}
75-
});
76-
77-
if (!connection) {
78-
res.status(404).json({ error: 'Connection not found' });
79-
return;
80-
}
81-
82-
const [jobId] = await this.connectionManager.createJobs([connection]);
83-
84-
res.status(200).json({ jobId });
85-
}
86-
87-
private async indexRepo(req: Request, res: Response) {
88-
const schema = z.object({
89-
repoId: z.number(),
90-
}).strict();
91-
92-
const parsed = schema.safeParse(req.body);
93-
if (!parsed.success) {
94-
res.status(400).json({ error: parsed.error.message });
95-
return;
96-
}
97-
98-
const { repoId } = parsed.data;
99-
const repo = await this.prisma.repo.findUnique({
100-
where: { id: repoId },
101-
});
102-
103-
if (!repo) {
104-
res.status(404).json({ error: 'Repo not found' });
105-
return;
106-
}
107-
108-
const [jobId] = await this.repoIndexManager.createJobs([repo], RepoIndexingJobType.INDEX);
109-
res.status(200).json({ jobId });
110-
}
111-
112-
private async triggerAccountPermissionSync(req: Request, res: Response) {
113-
if (env.PERMISSION_SYNC_ENABLED !== 'true' || !await hasEntitlement('permission-syncing')) {
114-
res.status(403).json({ error: 'Permission syncing is not enabled.' });
115-
return;
116-
}
117-
118-
const schema = z.object({
119-
accountId: z.string(),
120-
}).strict();
121-
122-
const parsed = schema.safeParse(req.body);
123-
if (!parsed.success) {
124-
res.status(400).json({ error: parsed.error.message });
125-
return;
126-
}
127-
128-
const { accountId } = parsed.data;
129-
const account = await this.prisma.account.findUnique({
130-
where: { id: accountId },
131-
});
132-
133-
if (!account) {
134-
res.status(404).json({ error: 'Account not found' });
135-
return;
136-
}
137-
138-
if (!doesIdpSupportPermissionSyncing(account.providerType)) {
139-
res.status(400).json({ error: `Provider '${account.providerType}' does not support permission syncing.` });
140-
return;
141-
}
142-
143-
const jobId = await this.accountPermissionSyncer.schedulePermissionSyncForAccount(account);
144-
res.status(200).json({ jobId });
145-
}
146-
14763
private async experimental_addGithubRepo(req: Request, res: Response) {
14864
const schema = z.object({
14965
owner: z.string(),
@@ -196,7 +112,14 @@ export class Api {
196112
create: record,
197113
});
198114

199-
const [jobId ] = await this.repoIndexManager.createJobs([repo], RepoIndexingJobType.INDEX);
115+
const jobId = await this.jobManager.trigger(
116+
'repo-index',
117+
{
118+
repoId: repo.id,
119+
type: RepoIndexingJobType.INDEX,
120+
},
121+
{ priority: JOB_PRIORITIES.INTERACTIVE },
122+
);
200123

201124
res.status(200).json({ jobId, repoId: repo.id });
202125
}

0 commit comments

Comments
 (0)