Repository navigation
FEATURE: Queue indexing and storage cleanup #36
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a0d1231
ecdf66a
4d02e21
cd84f2e
40219b2
a04b67f
3960c0b
5705cf2
b892e71
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| -- migrate:up | ||
| -- Jobs the queue gave up on. The Worker consumes the dead-letter queue | ||
| -- into this table so an operator (and the admin UI) can see what failed | ||
| -- and why, then re-send once the cause is fixed. Platform table: rows | ||
| -- are looked up by org, never joined into tenant queries, so no RLS. | ||
| CREATE TABLE failed_jobs ( | ||
| id text PRIMARY KEY, | ||
| org_id text, | ||
| kind text NOT NULL, | ||
| payload jsonb NOT NULL, | ||
| error text NOT NULL, | ||
| attempts integer NOT NULL, | ||
| failed_at timestamptz NOT NULL DEFAULT now(), | ||
| resolved_at timestamptz | ||
| ); | ||
|
|
||
| CREATE INDEX failed_jobs_org_idx ON failed_jobs (org_id, failed_at DESC); | ||
|
|
||
| -- migrate:down | ||
| DROP TABLE IF EXISTS failed_jobs; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| import { JobSchema, type FailedJob } from '@adrive/shared'; | ||
| import type { PgClient } from '@effect/sql-pg'; | ||
| import { Effect, Schema } from 'effect'; | ||
| import { StorageError } from './errors'; | ||
|
|
||
| export interface DeadLetter { | ||
| readonly id: string; | ||
| readonly body: unknown; | ||
| readonly attempts: number; | ||
| readonly error: string; | ||
| } | ||
|
|
||
| const decodeJob = Schema.decodeUnknownOption(JobSchema); | ||
|
|
||
| const FailedJobRow = Schema.Struct({ | ||
| id: Schema.String, | ||
| kind: Schema.String, | ||
| payload: Schema.Unknown, | ||
| error: Schema.String, | ||
| attempts: Schema.Int, | ||
| failed_at: Schema.String, | ||
| resolved_at: Schema.NullOr(Schema.String) | ||
| }); | ||
|
|
||
| const decodeRows = Schema.decodeUnknownOption(Schema.Array(FailedJobRow)); | ||
|
|
||
| // One row per dead-lettered message, keyed by the message id so the | ||
| // at-least-once DLQ delivery cannot double-record. A body that no longer | ||
| // decodes as a job is kept too (kind 'invalid', no org) so nothing the | ||
| // queue rejected disappears. | ||
| export const recordFailedJob = (sql: PgClient.PgClient, letter: DeadLetter) => | ||
| Effect.gen(function* () { | ||
| const job = decodeJob(letter.body); | ||
| const orgId = job._tag === 'Some' ? job.value.orgId : null; | ||
| const kind = job._tag === 'Some' ? job.value.kind : 'invalid'; | ||
| const rows = yield* sql<{ id: string }>` | ||
| INSERT INTO failed_jobs (id, org_id, kind, payload, error, attempts) | ||
| VALUES ( | ||
| ${letter.id}, ${orgId}, ${kind}, ${JSON.stringify(letter.body ?? null)}::jsonb, | ||
| ${letter.error}, ${letter.attempts} | ||
| ) | ||
| ON CONFLICT (id) DO NOTHING | ||
| RETURNING id`; | ||
| return { orgId, kind, recorded: rows.length === 1 }; | ||
| }).pipe( | ||
| Effect.mapError( | ||
| (cause) => new StorageError({ operation: 'record failed job', cause }) | ||
| ) | ||
| ); | ||
|
|
||
| export const listFailedJobs = ( | ||
| sql: PgClient.PgClient, | ||
| orgId: string, | ||
| limit = 100 | ||
| ) => | ||
| sql` | ||
| SELECT id, kind, payload, error, attempts, failed_at, resolved_at | ||
| FROM failed_jobs | ||
| WHERE org_id = ${orgId} | ||
| ORDER BY failed_at DESC, id | ||
| LIMIT ${Math.max(1, Math.min(limit, 500))}`.pipe( | ||
| Effect.map((rows): ReadonlyArray<FailedJob> => { | ||
| const decoded = decodeRows(rows); | ||
| return decoded._tag === 'Some' | ||
| ? decoded.value.map((row) => ({ | ||
| id: row.id, | ||
| kind: row.kind, | ||
| payload: row.payload, | ||
| error: row.error, | ||
| attempts: row.attempts, | ||
| failedAt: row.failed_at, | ||
| resolvedAt: row.resolved_at | ||
| })) | ||
| : []; | ||
|
Comment on lines
+63
to
+74
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
# 1) Inspect the PgClient layer configuration for type/transform settings.
fd -t f 'pg.ts' apps/web/src/lib/server --exec cat -n
# 2) Check how other row schemas in this repo type timestamptz columns.
rg -n -C4 'timestamptz|failed_at|created_at' --glob 'apps/web/src/lib/server/**/*.ts' -g '!**/node_modules/**' | rg -n -C4 'Schema\.(String|Date|DateFromSelf|Unknown)'Repository: davis7dotsh/aDrive Length of output: 11836 🏁 Script executed: #!/bin/bash
# Inspect the complete failed-jobs flow, its error type, and the admin caller.
cat -n apps/web/src/lib/server/failed-jobs.ts
printf '\n--- failed-jobs references ---\n'
rg -n -C4 'listFailedJobs|FailedJob|StorageError|decodeUnknownOption' apps/web/src --glob '*.ts' --glob '*.tsx'Repository: davis7dotsh/aDrive Length of output: 50375 Surface row-decode failures from If returned rows fail 🤖 Prompt for AI Agents |
||
| }), | ||
| Effect.mapError( | ||
| (cause) => new StorageError({ operation: 'list failed jobs', cause }) | ||
| ) | ||
| ); | ||
|
|
||
| // Optional operator alert. The URL is a secret rather than a var so it | ||
| // stays out of wrangler.jsonc; absent means no alert. Delivery is best | ||
| // effort: the rows are already committed and the batch is acked either | ||
| // way, so a webhook outage cannot dead-letter the dead letters. | ||
| export const alertWebhookUrl = (env: Env) => { | ||
| const value: unknown = Reflect.get(env, 'ALERT_WEBHOOK_URL'); | ||
| return typeof value === 'string' && value.length > 0 ? value : null; | ||
| }; | ||
|
|
||
| export const postFailedJobAlert = ( | ||
| url: string, | ||
| summary: { | ||
| readonly queue: string; | ||
| readonly recorded: number; | ||
| readonly kinds: ReadonlyArray<string>; | ||
| readonly orgIds: ReadonlyArray<string>; | ||
| } | ||
| ) => | ||
| Effect.tryPromise(async () => { | ||
| const response = await fetch(url, { | ||
| method: 'POST', | ||
| signal: AbortSignal.timeout(5_000), | ||
| headers: { 'content-type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| text: `adrive: ${summary.recorded} job(s) dead-lettered on ${summary.queue}`, | ||
| ...summary | ||
| }) | ||
| }); | ||
| await response.body?.cancel(); | ||
| return response; | ||
| }).pipe( | ||
| Effect.flatMap((response) => | ||
| response.ok | ||
| ? Effect.void | ||
| : Effect.fail(`Alert webhook returned HTTP ${response.status}`) | ||
| ), | ||
| Effect.catchCause((cause) => | ||
| Effect.sync(() => { | ||
| console.error( | ||
| JSON.stringify({ | ||
| message: 'failed job alert could not be delivered', | ||
| cause: String(cause) | ||
| }) | ||
| ); | ||
| }) | ||
| ) | ||
| ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| // Timing rules for queue-driven work. Pure so the consumer and the | ||
| // services that send jobs can be tested without a queue. | ||
|
|
||
| // Cloudflare Queues cap a message delay at 12 hours; anything later is | ||
| // re-sent by the consumer with the remainder when it arrives. | ||
| export const MAX_JOB_DELAY_SECONDS = 12 * 60 * 60; | ||
|
|
||
| // A job whose row is still pending or leased this long after it should | ||
| // have run is presumed lost and re-sent by the cron reconciliation. | ||
| export const STUCK_JOB_MS = 15 * 60 * 1_000; | ||
|
|
||
| export const delaySecondsUntil = (at: string | number, now = Date.now()) => { | ||
| const target = typeof at === 'number' ? at : new Date(at).getTime(); | ||
| if (!Number.isFinite(target)) return 0; | ||
| return Math.max( | ||
| 0, | ||
| Math.min(Math.ceil((target - now) / 1_000), MAX_JOB_DELAY_SECONDS) | ||
| ); | ||
| }; | ||
|
|
||
| // Transient failures back off by delivery count (message.attempts is 1 | ||
| // on the first delivery): a minute, then doubling up to an hour. | ||
| export const retryDelaySeconds = (attempts: number) => | ||
| Math.min(30 * 2 ** Math.max(0, attempts), 3_600); | ||
|
|
||
| export const stuckBefore = (now = Date.now()) => | ||
| new Date(now - STUCK_JOB_MS).toISOString(); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import { Effect, Schema } from 'effect'; | ||
| import { AppConfig } from '../config'; | ||
| import { verifyJobsRequest } from '../cron-auth'; | ||
| import { InvalidRequest, Unauthorized } from '../errors'; | ||
| import { readBoundedText } from '../request-json'; | ||
|
|
||
| // The batch JSON the Worker facade posts for either queue: verified | ||
| // against the HMAC over the exact body, then decoded. | ||
| const JobBatchBody = Schema.Struct({ | ||
| queue: Schema.String, | ||
| messages: Schema.Array( | ||
| Schema.Struct({ | ||
| id: Schema.String, | ||
| attempts: Schema.Int, | ||
| body: Schema.Unknown | ||
| }) | ||
| ) | ||
| }); | ||
|
|
||
| const MAX_BATCH_BYTES = 1024 * 1024; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -euo pipefail
# Verify the deployed consumer batch configuration.
rg -n -C4 '"max_batch_size"|dead_letter_queue|queue_name' apps/web/wrangler.jsoncRepository: davis7dotsh/aDrive Length of output: 1444 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- batch-request.ts ---'
cat -n apps/web/src/lib/server/jobs/batch-request.ts
printf '%s\n' '--- related batch endpoint and callers ---'
rg -n -C4 'batch-request|MAX_BATCH_BYTES|413|dead|jobs/dead|fetch\(' apps/web/src apps/web/wrangler.jsoncRepository: davis7dotsh/aDrive Length of output: 50374 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- queue configuration and job batch producers ---'
rg -n -C5 'max_batch_size|batchSize|batch_size|sendBatch|send\(|adrive-jobs(-dlq)?|jobs/dead' apps/web/src apps/web/wrangler.jsoncRepository: davis7dotsh/aDrive Length of output: 21948 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- facade batch forwarding ---'
rg -n -C8 'cloudflare-adapter|sendBatch|/api/internal/jobs|/api/internal/jobs/dead|status|retry|ack' scripts apps/web/src/routes/api/internal/jobs apps/web/src/lib/server/jobs
printf '%s\n' '--- queue route handlers ---'
cat -n apps/web/src/routes/api/internal/jobs/+server.ts
cat -n apps/web/src/routes/api/internal/jobs/dead/+server.tsRepository: davis7dotsh/aDrive Length of output: 50374 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- adapter files ---'
fd -i 'cloudflare-adapter' .
printf '%s\n' '--- adapter references ---'
rg -n -C10 'adrive-jobs|sendBatch|internal/jobs|internal/jobs/dead|fetch' scriptsRepository: davis7dotsh/aDrive Length of output: 1390 🌐 Web query:
💡 Result: The maximum message size for Cloudflare Queues is 128 KB [1][2]. Note that messages can include up to approximately 100 bytes of internal metadata that counts toward this total limit, and 1 KB is defined as 1,000 bytes [1][3]. The maximum consumer batch size for Cloudflare Queues is 100 messages [1][2]. Additionally, when using the sendBatch method to send messages to a queue, you are limited to a maximum of 100 messages or 256 KB in total per call [1][3]. Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- adapter lookup ---'
for f in $(fd -i 'cloudflare-adapter' .); do
echo "FILE: $f"
wc -l "$f"
cat -n "$f"
doneRepository: davis7dotsh/aDrive Length of output: 11650 Increase the request limit for dead-letter batches.
Set the limit from the provider message limit and configured 🤖 Prompt for AI Agents |
||
|
|
||
| const invalid = (status: 400 | 413, message: string) => | ||
| new InvalidRequest({ status, message }); | ||
|
|
||
| const unauthorized = () => | ||
| new Unauthorized({ message: 'Queue request is unauthorized' }); | ||
|
|
||
| export const readSignedBatch = (request: Request) => | ||
| Effect.gen(function* () { | ||
| const config = yield* AppConfig; | ||
| const text = yield* readBoundedText(request, { | ||
| maxBytes: MAX_BATCH_BYTES, | ||
| invalidLengthMessage: 'Queue batch is too large', | ||
| invalidTextMessage: 'Queue batch is invalid' | ||
| }); | ||
| const authorized = yield* Effect.tryPromise({ | ||
| try: () => | ||
| verifyJobsRequest( | ||
| config.maintenanceSecret, | ||
| request.headers.get('x-adrive-jobs-time'), | ||
| text, | ||
| request.headers.get('x-adrive-jobs-signature') | ||
| ), | ||
| catch: unauthorized | ||
| }); | ||
| if (!authorized) return yield* unauthorized(); | ||
|
|
||
| return yield* Effect.try({ | ||
| try: (): unknown => JSON.parse(text), | ||
| catch: () => invalid(400, 'Queue batch is invalid') | ||
| }).pipe( | ||
| Effect.flatMap(Schema.decodeUnknownEffect(JobBatchBody)), | ||
| Effect.mapError((cause) => | ||
| cause instanceof InvalidRequest | ||
| ? cause | ||
| : invalid(400, 'Queue batch is invalid') | ||
| ) | ||
| ); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: davis7dotsh/aDrive
Length of output: 2958
Add a resolution writer for
resolved_at.No repository code writes
failed_jobs.resolved_at;listFailedJobsonly filters byorg_idand returns the column. Add the resolution path that setsresolved_atand filters resolved rows as intended, or remove the column until that path exists.🤖 Prompt for AI Agents