Skip to content

Commit 8d0e084

Browse files
committed
feat: add admin quick queue management
Signed-off-by: ferhat elmas <elmas.ferhat@gmail.com>
1 parent b6b815f commit 8d0e084

23 files changed

Lines changed: 2879 additions & 34 deletions

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
"migrations:types": "tsx src/scripts/migrations-types.ts",
1313
"docs:export": "tsx ./src/scripts/export-docs.ts",
1414
"docs:export-ui": "tsx ./src/scripts/export-docs-ui.ts",
15+
"jobs:list": "tsx src/scripts/jobs-client.ts list",
16+
"jobs:backup": "tsx src/scripts/jobs-client.ts backup",
17+
"jobs:restore": "tsx src/scripts/jobs-client.ts restore",
1518
"pprof:capture": "tsx src/scripts/pprof-client.ts",
1619
"test:dummy-data": "tsx -r dotenv/config ./src/test/db/import-dummy-data.ts",
1720
"test:unit": "vitest run --config vitest.unit.config.ts",

src/http/routes/admin/queue.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ describe('admin queue routes', () => {
1919
it('passes sbReqId to the move jobs task', async () => {
2020
vi.resetModules()
2121

22-
const { mergeConfig } = await import('../../../config')
22+
const { getConfig, mergeConfig } = await import('../../../config')
23+
getConfig()
2324
mergeConfig({
2425
pgQueueEnable: true,
2526
adminApiKeys: 'test-admin-key',
@@ -71,7 +72,8 @@ describe('admin queue routes', () => {
7172
it('rejects move jobs requests without queue names', async () => {
7273
vi.resetModules()
7374

74-
const { mergeConfig } = await import('../../../config')
75+
const { getConfig, mergeConfig } = await import('../../../config')
76+
getConfig()
7577
mergeConfig({
7678
pgQueueEnable: true,
7779
adminApiKeys: 'test-admin-key',

src/http/routes/admin/queue.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1+
import { parseCommaSeparatedList } from '@internal/parser'
12
import { SYSTEM_TENANT } from '@internal/queue/constants'
3+
import {
4+
JOB_OVERFLOW_LIST_LIMIT_DEFAULT,
5+
JOB_OVERFLOW_RESTORE_LIMIT_DEFAULT,
6+
QueueOverflowStorePg,
7+
} from '@internal/queue/overflow'
8+
import { Queue } from '@internal/queue/queue'
29
import { MoveJobs } from '@storage/events'
310
import { FastifyInstance, RequestGenericInterface } from 'fastify'
411
import { FromSchema } from 'json-schema-to-ts'
@@ -7,6 +14,29 @@ import { registerApiKeyAuth } from '../../plugins/apikey'
714

815
const { pgQueueEnable } = getConfig()
916

17+
function getQueueOverflowStore() {
18+
return new QueueOverflowStorePg(Queue.getDb())
19+
}
20+
21+
const nonBlankStringSchema = {
22+
type: 'string',
23+
minLength: 1,
24+
pattern: '\\S',
25+
} as const
26+
27+
const stringListSchema = {
28+
type: 'array',
29+
minItems: 1,
30+
maxItems: 1000,
31+
items: nonBlankStringSchema,
32+
} as const
33+
34+
const positiveSafeIntegerSchema = {
35+
type: 'integer',
36+
minimum: 1,
37+
maximum: Number.MAX_SAFE_INTEGER,
38+
} as const
39+
1040
const moveJobsSchema = {
1141
body: {
1242
type: 'object',
@@ -26,10 +56,106 @@ const moveJobsSchema = {
2656
},
2757
} as const
2858

59+
const listQueueOverflowSchema = {
60+
description: 'List created pgBoss jobs from the live queue table or overflow backup table.',
61+
querystring: {
62+
type: 'object',
63+
properties: {
64+
source: {
65+
type: 'string',
66+
enum: ['job', 'backup'],
67+
default: 'job',
68+
},
69+
groupBy: {
70+
type: 'string',
71+
enum: ['summary', 'tenant'],
72+
default: 'summary',
73+
},
74+
name: nonBlankStringSchema,
75+
eventTypes: {
76+
...nonBlankStringSchema,
77+
description: 'Comma-separated event types to filter on.',
78+
},
79+
tenantRefs: {
80+
...nonBlankStringSchema,
81+
description: 'Comma-separated tenant refs to filter on.',
82+
},
83+
limit: {
84+
...positiveSafeIntegerSchema,
85+
default: JOB_OVERFLOW_LIST_LIMIT_DEFAULT,
86+
},
87+
},
88+
additionalProperties: false,
89+
},
90+
} as const
91+
92+
const countQueueOverflowSchema = {
93+
description: 'Count created pgBoss jobs in the live queue table.',
94+
} as const
95+
96+
const backupQueueOverflowSchema = {
97+
description: 'Move created pgBoss jobs into the overflow backup table.',
98+
body: {
99+
type: 'object',
100+
properties: {
101+
name: nonBlankStringSchema,
102+
eventTypes: stringListSchema,
103+
tenantRefs: stringListSchema,
104+
limit: positiveSafeIntegerSchema,
105+
confirmAll: {
106+
type: 'boolean',
107+
description: 'Required when no queue, event type, or tenant filter is supplied.',
108+
},
109+
},
110+
anyOf: [
111+
{ required: ['name'] },
112+
{ required: ['eventTypes'] },
113+
{ required: ['tenantRefs'] },
114+
{
115+
properties: {
116+
confirmAll: { type: 'boolean', enum: [true] },
117+
},
118+
required: ['confirmAll'],
119+
},
120+
],
121+
additionalProperties: false,
122+
},
123+
} as const
124+
125+
const restoreQueueOverflowSchema = {
126+
description:
127+
'Restore created pgBoss jobs from the overflow backup table in batches. Conflicting rows are dropped from the backup table because the live job table wins.',
128+
body: {
129+
type: 'object',
130+
properties: {
131+
name: nonBlankStringSchema,
132+
eventTypes: stringListSchema,
133+
tenantRefs: stringListSchema,
134+
limit: {
135+
...positiveSafeIntegerSchema,
136+
default: JOB_OVERFLOW_RESTORE_LIMIT_DEFAULT,
137+
},
138+
},
139+
additionalProperties: false,
140+
},
141+
} as const
142+
29143
interface MoveJobsRequestInterface extends RequestGenericInterface {
30144
Body: FromSchema<typeof moveJobsSchema.body>
31145
}
32146

147+
interface ListQueueOverflowRequestInterface extends RequestGenericInterface {
148+
Querystring: FromSchema<typeof listQueueOverflowSchema.querystring>
149+
}
150+
151+
interface BackupQueueOverflowRequestInterface extends RequestGenericInterface {
152+
Body: FromSchema<typeof backupQueueOverflowSchema.body>
153+
}
154+
155+
interface RestoreQueueOverflowRequestInterface extends RequestGenericInterface {
156+
Body: FromSchema<typeof restoreQueueOverflowSchema.body>
157+
}
158+
33159
export default async function routes(fastify: FastifyInstance) {
34160
registerApiKeyAuth(fastify)
35161

@@ -56,4 +182,75 @@ export default async function routes(fastify: FastifyInstance) {
56182
return reply.send({ message: 'Move jobs scheduled' })
57183
}
58184
)
185+
186+
fastify.get<ListQueueOverflowRequestInterface>(
187+
'/overflow',
188+
{ schema: { ...listQueueOverflowSchema, tags: ['queue'] } },
189+
async (req, reply) => {
190+
if (!pgQueueEnable) {
191+
return reply.status(400).send({ message: 'Queue is not enabled' })
192+
}
193+
194+
const store = getQueueOverflowStore()
195+
const data = await store.list({
196+
source: req.query.source,
197+
groupBy: req.query.groupBy,
198+
name: req.query.name,
199+
eventTypes: parseCommaSeparatedList(req.query.eventTypes),
200+
tenantRefs: parseCommaSeparatedList(req.query.tenantRefs),
201+
limit: req.query.limit,
202+
signal: req.signals.disconnect.signal,
203+
})
204+
205+
return reply.send(data)
206+
}
207+
)
208+
209+
fastify.get(
210+
'/overflow/count',
211+
{ schema: { ...countQueueOverflowSchema, tags: ['queue'] } },
212+
async (req, reply) => {
213+
if (!pgQueueEnable) {
214+
return reply.status(400).send({ message: 'Queue is not enabled' })
215+
}
216+
217+
const store = getQueueOverflowStore()
218+
const data = await store.countCreated({ signal: req.signals.disconnect.signal })
219+
return reply.send(data)
220+
}
221+
)
222+
223+
fastify.post<BackupQueueOverflowRequestInterface>(
224+
'/overflow/backup',
225+
{ schema: { ...backupQueueOverflowSchema, tags: ['queue'] } },
226+
async (req, reply) => {
227+
if (!pgQueueEnable) {
228+
return reply.status(400).send({ message: 'Queue is not enabled' })
229+
}
230+
231+
const store = getQueueOverflowStore()
232+
const data = await store.backup({
233+
...req.body,
234+
signal: req.signals.disconnect.signal,
235+
})
236+
return reply.send(data)
237+
}
238+
)
239+
240+
fastify.post<RestoreQueueOverflowRequestInterface>(
241+
'/overflow/restore',
242+
{ schema: { ...restoreQueueOverflowSchema, tags: ['queue'] } },
243+
async (req, reply) => {
244+
if (!pgQueueEnable) {
245+
return reply.status(400).send({ message: 'Queue is not enabled' })
246+
}
247+
248+
const store = getQueueOverflowStore()
249+
const data = await store.restore({
250+
...req.body,
251+
signal: req.signals.disconnect.signal,
252+
})
253+
return reply.send(data)
254+
}
255+
)
59256
}

src/internal/database/pg-connection.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,28 @@ describe('getPgCancelConnectionTarget', () => {
325325
})
326326

327327
describe('PgPoolExecutor', () => {
328+
it('keeps no-signal transaction checkout on the direct pool path', async () => {
329+
const addEventListener = vi.spyOn(AbortSignal.prototype, 'addEventListener')
330+
const client = Object.assign(new EventEmitter(), {
331+
query: vi.fn().mockResolvedValue({ rows: [] }),
332+
release: vi.fn(),
333+
}) as unknown as PoolClient & EventEmitter
334+
const pool = {
335+
connect: vi.fn().mockResolvedValue(client),
336+
} as unknown as Pool
337+
const executor = new PgPoolExecutor(pool)
338+
339+
try {
340+
const transaction = await executor.beginTransaction()
341+
await transaction.commit()
342+
343+
expect(pool.connect).toHaveBeenCalledOnce()
344+
expect(addEventListener).not.toHaveBeenCalled()
345+
} finally {
346+
addEventListener.mockRestore()
347+
}
348+
})
349+
328350
it('tracks checked-out client errors during direct queries', async () => {
329351
const socketError = new Error('socket reset')
330352
const client = Object.assign(new EventEmitter(), {
@@ -558,6 +580,50 @@ describe('PgPoolExecutor', () => {
558580
expect(pool.connect).not.toHaveBeenCalled()
559581
})
560582

583+
it('rejects an aborted transaction checkout and releases a late client', async () => {
584+
const controller = new AbortController()
585+
let resolveConnect: (client: PoolClient) => void = () => undefined
586+
const connectPromise = new Promise<PoolClient>((resolve) => {
587+
resolveConnect = resolve
588+
})
589+
const client = Object.assign(new EventEmitter(), {
590+
query: vi.fn().mockResolvedValue({ rows: [] }),
591+
release: vi.fn(),
592+
}) as unknown as PoolClient & EventEmitter
593+
const pool = {
594+
connect: vi.fn(() => connectPromise),
595+
} as unknown as Pool
596+
const executor = new PgPoolExecutor(pool)
597+
598+
const transactionPromise = executor.beginTransaction({
599+
signal: controller.signal,
600+
})
601+
controller.abort()
602+
603+
const result = await Promise.race([
604+
transactionPromise.then(
605+
() => ({ status: 'resolved' as const }),
606+
(error) => ({ status: 'rejected' as const, error })
607+
),
608+
waitForDrainCheck().then(() => ({ status: 'pending' as const })),
609+
])
610+
611+
resolveConnect(client)
612+
await waitForDrainCheck()
613+
614+
expect(result).toMatchObject({
615+
status: 'rejected',
616+
error: {
617+
name: 'AbortError',
618+
code: 'ABORT_ERR',
619+
message: 'Query was aborted',
620+
},
621+
})
622+
expect(client.query).not.toHaveBeenCalled()
623+
expect(client.release).toHaveBeenCalledOnce()
624+
expect(client.release).toHaveBeenCalledWith()
625+
})
626+
561627
it('rejects aborted queries without waiting for the pg query to settle', async () => {
562628
const controller = new AbortController()
563629
const release = vi.fn()

0 commit comments

Comments
 (0)