mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
v0.41: Bug 2 — lease-full bypass that doesn't burn attempts
The field-report dead-letter loop closed at the root.
Pre-v0.41 the worker treated RateLeaseUnavailableError as a recoverable
error AND incremented attempts_made. After 3 lease-full bounces the job
hit max_attempts (default 3) and dead-lettered with message `rate lease
"anthropic:messages" full (8/8)`. The operator who reported the bug
submitted 100 jobs at --concurrency 10 with a default cap of 8; all 100
dead-lettered before the upstream had a chance to drain.
Fix:
MinionQueue.releaseLeaseFullJob(jobId, lockToken, errorText, backoffMs)
Mirrors failJob() but skips the attempts_made increment. Same
lock_token + status='active' idempotency guard as failJob; returns
null on lock-token mismatch so racing stall sweeps / cancels still win.
Worker catch block (src/core/minions/worker.ts:741-792)
Detects `err instanceof RateLeaseUnavailableError` BEFORE the existing
`isUnrecoverable || attemptsExhausted` gate. Routes through
releaseLeaseFullJob with 1-3s jittered backoff. The handler comment
at subagent.ts:425 ("treat as renewable error so the worker re-claims")
is now actually true.
src/core/minions/lease-pressure-audit.ts (NEW)
Best-effort logLeasePressure() writes one row to migration v93's
minion_lease_pressure_log per bounce. Denormalized context columns
(queue_name, job_name, model, provider, root_owner_id) populated
inline so post-prune forensic queries still see context (Eng D8 /
codex pass-3 #7). Stderr-warn on write failure; never blocks the
bypass path.
Pinned by test/minions-lease-full-retry.test.ts (7 cases):
- flips status to delayed without incrementing attempts_made
- returns null on lock_token mismatch
- 5 bounces leaves attempts_made=0; failJob comparison shows the
asymmetry (failJob DOES bump)
- logLeasePressure writes denormalized columns
- countRecentLeasePressure for doctor + jobs stats consumers
- audit row survives hard-delete via SET NULL FK
- best-effort no-throw contract on write failure
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
41d6ae536f
commit
bd751f6f99
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* v0.41 Bug 2 + Eng D3+D8 — lease-pressure audit writer.
|
||||
*
|
||||
* Writes one row per `RateLeaseUnavailableError` bounce to the
|
||||
* `minion_lease_pressure_log` table (migration v93). The doctor check
|
||||
* `subagent_health` and `gbrain jobs stats lease_pressure` read this
|
||||
* table to surface pressure to operators.
|
||||
*
|
||||
* Why DB table (not JSONL): doctor + jobs stats need SQL aggregation
|
||||
* across many bounces in a rolling window; JSONL would require parsing
|
||||
* + filtering per query. The audit table has indexes on `bounced_at DESC`
|
||||
* and `job_id` for the exact access patterns those consumers need.
|
||||
*
|
||||
* Denormalization at write time (Eng D8 / codex pass-3 #7): `queue_name`,
|
||||
* `job_name`, `model`, `provider`, `root_owner_id` are persisted inline so
|
||||
* post-NULL forensic queries (after `gbrain jobs prune` pulls the job row)
|
||||
* still carry enough context to answer "was there pressure on Anthropic
|
||||
* messages last Tuesday at 3pm." Without denormalization, post-NULL rows
|
||||
* would be timestamp-only residue.
|
||||
*
|
||||
* Best-effort: write failures (e.g. DB blip during the worker's lease-full
|
||||
* bypass) log to stderr but never throw — losing one audit row is
|
||||
* preferable to failing the bypass path that prevents dead-letter.
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
|
||||
export interface LeasePressureRecord {
|
||||
/** Job that bounced (FK target; SET NULL on prune so audit survives). */
|
||||
job_id: number;
|
||||
/** Lease key the bounce happened on (e.g. `anthropic:messages`). */
|
||||
lease_key: string;
|
||||
/** `activeCount` observed at bounce time (diagnostic). */
|
||||
active_at_bounce: number;
|
||||
/** `maxConcurrent` checked against (diagnostic). */
|
||||
max_concurrent: number;
|
||||
/**
|
||||
* Denormalized context — persists past job pruning so aggregate forensic
|
||||
* queries (the "what model had pressure last week" shape) still work.
|
||||
* Pass best-effort values from the calling worker; nulls are accepted.
|
||||
*/
|
||||
queue_name?: string | null;
|
||||
job_name?: string | null;
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
root_owner_id?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one lease-pressure event. Best-effort — DB failures log to stderr
|
||||
* and return without throwing so the worker's lease-full bypass path
|
||||
* (which is the actual fix for the field-report dead-letter loop) is
|
||||
* never blocked by audit-table write problems.
|
||||
*/
|
||||
export async function logLeasePressure(
|
||||
engine: BrainEngine,
|
||||
record: LeasePressureRecord,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await engine.executeRaw(
|
||||
`INSERT INTO minion_lease_pressure_log
|
||||
(job_id, lease_key, active_at_bounce, max_concurrent,
|
||||
queue_name, job_name, model, provider, root_owner_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
record.job_id,
|
||||
record.lease_key,
|
||||
record.active_at_bounce,
|
||||
record.max_concurrent,
|
||||
record.queue_name ?? null,
|
||||
record.job_name ?? null,
|
||||
record.model ?? null,
|
||||
record.provider ?? null,
|
||||
record.root_owner_id ?? null,
|
||||
],
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(
|
||||
`[lease-pressure-audit] WARN: write failed for job ${record.job_id}: ${msg}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read lease-pressure events in a rolling window. Used by `gbrain doctor`'s
|
||||
* `subagent_health` check and by `gbrain jobs stats lease_pressure`.
|
||||
* Returns the raw rows so callers can do their own aggregation; the count
|
||||
* is the operationally useful aggregate.
|
||||
*/
|
||||
export async function readRecentLeasePressure(
|
||||
engine: BrainEngine,
|
||||
windowMs: number,
|
||||
opts: { limit?: number } = {},
|
||||
): Promise<Array<LeasePressureRecord & { id: number; bounced_at: string }>> {
|
||||
const limit = opts.limit ?? 1000;
|
||||
const rows = await engine.executeRaw<{
|
||||
id: number;
|
||||
job_id: number | null;
|
||||
lease_key: string;
|
||||
active_at_bounce: number;
|
||||
max_concurrent: number;
|
||||
bounced_at: string;
|
||||
queue_name: string | null;
|
||||
job_name: string | null;
|
||||
model: string | null;
|
||||
provider: string | null;
|
||||
root_owner_id: number | null;
|
||||
}>(
|
||||
`SELECT id, job_id, lease_key, active_at_bounce, max_concurrent,
|
||||
bounced_at, queue_name, job_name, model, provider, root_owner_id
|
||||
FROM minion_lease_pressure_log
|
||||
WHERE bounced_at > now() - ($1::double precision * interval '1 millisecond')
|
||||
ORDER BY bounced_at DESC
|
||||
LIMIT $2`,
|
||||
[windowMs, limit],
|
||||
);
|
||||
return rows.map(r => ({
|
||||
id: r.id,
|
||||
job_id: r.job_id ?? 0, // job_id is SET NULL after prune; surface as 0 for callers that don't care
|
||||
lease_key: r.lease_key,
|
||||
active_at_bounce: r.active_at_bounce,
|
||||
max_concurrent: r.max_concurrent,
|
||||
bounced_at: r.bounced_at,
|
||||
queue_name: r.queue_name,
|
||||
job_name: r.job_name,
|
||||
model: r.model,
|
||||
provider: r.provider,
|
||||
root_owner_id: r.root_owner_id,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded count of lease-pressure events in a rolling window. Cheaper
|
||||
* than `readRecentLeasePressure` for callers that only want the metric.
|
||||
*/
|
||||
export async function countRecentLeasePressure(
|
||||
engine: BrainEngine,
|
||||
windowMs: number,
|
||||
): Promise<number> {
|
||||
const rows = await engine.executeRaw<{ count: string }>(
|
||||
`SELECT count(*)::text AS count
|
||||
FROM minion_lease_pressure_log
|
||||
WHERE bounced_at > now() - ($1::double precision * interval '1 millisecond')`,
|
||||
[windowMs],
|
||||
);
|
||||
return parseInt(rows[0]?.count ?? '0', 10);
|
||||
}
|
||||
@@ -965,6 +965,51 @@ export class MinionQueue {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.41 Bug 2 — release a job back to `delayed` after a
|
||||
* `RateLeaseUnavailableError` bounce, WITHOUT incrementing `attempts_made`.
|
||||
*
|
||||
* The field-report bug: pre-v0.41, lease-full bounces routed through
|
||||
* `failJob` which bumps `attempts_made`. After 3 bounces the job hit
|
||||
* `max_attempts` (default 3) and dead-lettered with message
|
||||
* `rate lease "anthropic:messages" full (8/8)`. Operators saw a dead
|
||||
* job and assumed a real failure.
|
||||
*
|
||||
* This method is the workhorse fix: status → `delayed`, jittered backoff
|
||||
* via `delay_until`, `attempts_made` UNCHANGED. The handler comment at
|
||||
* `src/core/minions/handlers/subagent.ts:425` ("treat as renewable
|
||||
* error so the worker re-claims") is now actually true.
|
||||
*
|
||||
* Audit row write to `minion_lease_pressure_log` is the caller's
|
||||
* responsibility (the worker has the model/queue context); this method
|
||||
* stays focused on the state-machine flip. Same `lock_token + status='active'`
|
||||
* idempotency guard as `failJob` so a racing stall sweep / cancel still
|
||||
* wins. Returns `null` on lock_token mismatch.
|
||||
*
|
||||
* Returns the updated `MinionJob` row on success so the caller can stamp
|
||||
* the audit row with provenance from the SAME row that just flipped.
|
||||
*/
|
||||
async releaseLeaseFullJob(
|
||||
id: number,
|
||||
lockToken: string,
|
||||
errorText: string,
|
||||
backoffMs: number,
|
||||
): Promise<MinionJob | null> {
|
||||
const rows = await this.engine.executeRaw<Record<string, unknown>>(
|
||||
`UPDATE minion_jobs SET
|
||||
status = 'delayed',
|
||||
error_text = $1,
|
||||
stacktrace = COALESCE(stacktrace, '[]'::jsonb) || to_jsonb($1::text),
|
||||
delay_until = now() + ($2::double precision * interval '1 millisecond'),
|
||||
lock_token = NULL, lock_until = NULL, updated_at = now()
|
||||
WHERE id = $3 AND status = 'active' AND lock_token = $4
|
||||
RETURNING *`,
|
||||
[errorText, backoffMs, id, lockToken],
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
return rowToMinionJob(rows[0]);
|
||||
}
|
||||
|
||||
/** Update job progress (token-fenced). */
|
||||
async updateProgress(id: number, lockToken: string, progress: unknown): Promise<boolean> {
|
||||
const rows = await this.engine.executeRaw<Record<string, unknown>>(
|
||||
|
||||
@@ -19,6 +19,8 @@ import type {
|
||||
import { UnrecoverableError } from './types.ts';
|
||||
import { MinionQueue } from './queue.ts';
|
||||
import { calculateBackoff } from './backoff.ts';
|
||||
import { RateLeaseUnavailableError } from './handlers/subagent.ts';
|
||||
import { logLeasePressure } from './lease-pressure-audit.ts';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { EventEmitter } from 'events';
|
||||
import { evaluateQuietHours, type QuietHoursConfig } from './quiet-hours.ts';
|
||||
@@ -758,6 +760,57 @@ export class MinionWorker extends EventEmitter {
|
||||
errorText = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
// v0.41 Bug 2: lease-full bounces don't burn attempts.
|
||||
//
|
||||
// Pre-v0.41 every non-`UnrecoverableError` routed to `delayed` with
|
||||
// exponential backoff BUT still incremented `attempts_made`. After 3
|
||||
// lease-full bounces the job hit `max_attempts` and dead-lettered
|
||||
// with message `rate lease "..." full (N/M)` — operators saw a
|
||||
// "dead" job and assumed real failure. The field-report dead-letter
|
||||
// loop is exactly this path.
|
||||
//
|
||||
// Detect `RateLeaseUnavailableError` BEFORE the attempts-exhaustion
|
||||
// gate and route through `queue.releaseLeaseFullJob` which mirrors
|
||||
// `failJob` minus the `attempts_made` increment. Audit row to
|
||||
// `minion_lease_pressure_log` so operators see pressure live in
|
||||
// `gbrain doctor` + `gbrain jobs stats lease_pressure`.
|
||||
const isLeaseFull = err instanceof RateLeaseUnavailableError;
|
||||
if (isLeaseFull) {
|
||||
const leaseErr = err as RateLeaseUnavailableError;
|
||||
// 1-3s jittered backoff. Not the exponential curve — this is "yield
|
||||
// the slot, try again soon", not "give up after a few tries."
|
||||
const leaseBackoffMs = 1000 + Math.floor(Math.random() * 2000);
|
||||
const released = await this.queue.releaseLeaseFullJob(
|
||||
job.id, lockToken, errorText, leaseBackoffMs,
|
||||
);
|
||||
if (!released) {
|
||||
console.warn(`Job ${job.id} lease-full release dropped (lock token mismatch)`);
|
||||
return;
|
||||
}
|
||||
// Audit row write is best-effort — never blocks the bypass path.
|
||||
// Denormalized columns persist past `gbrain jobs prune` so post-NULL
|
||||
// forensic queries still see context (Eng D8 / codex pass-3 #7).
|
||||
await logLeasePressure(this.engine, {
|
||||
job_id: job.id,
|
||||
lease_key: leaseErr.key,
|
||||
active_at_bounce: leaseErr.active,
|
||||
max_concurrent: Number.isFinite(leaseErr.max) ? leaseErr.max : -1,
|
||||
queue_name: job.queue,
|
||||
job_name: job.name,
|
||||
// Best-effort context — populated when we can. The worker doesn't
|
||||
// always know the model at catch time (model is resolved inside
|
||||
// the handler), so leave NULL when unavailable. The doctor check's
|
||||
// aggregate queries handle NULL gracefully.
|
||||
model: null,
|
||||
provider: null,
|
||||
root_owner_id: job.parent_job_id ?? null,
|
||||
});
|
||||
console.log(
|
||||
`Job ${job.id} (${job.name}) lease-full, re-queuing in ${Math.round(leaseBackoffMs)}ms (no attempt burned)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const isUnrecoverable = err instanceof UnrecoverableError;
|
||||
const attemptsExhausted = job.attempts_made + 1 >= job.max_attempts;
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* v0.41 Bug 2 — lease-full bounces don't burn attempts.
|
||||
*
|
||||
* Pre-v0.41 the worker treated `RateLeaseUnavailableError` as a recoverable
|
||||
* error AND incremented `attempts_made`. After 3 lease-full bounces a job
|
||||
* hit `max_attempts` (default 3) and dead-lettered with message
|
||||
* `rate lease "anthropic:messages" full (8/8)`. The field-report dead-letter
|
||||
* loop was exactly this path: operator submits 100 jobs at concurrency=10,
|
||||
* lease cap of 8 starves 2 workers, all 100 jobs hit lease pressure, ALL
|
||||
* dead-letter after 3 bounces each.
|
||||
*
|
||||
* The fix routes `RateLeaseUnavailableError` through
|
||||
* `queue.releaseLeaseFullJob` which mirrors `failJob` minus the
|
||||
* `attempts_made` increment. Tests focus on that method directly (the
|
||||
* load-bearing fix); the worker is just the consumer that detects the
|
||||
* error class and routes here.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
||||
import { MinionQueue } from '../src/core/minions/queue.ts';
|
||||
import { logLeasePressure, countRecentLeasePressure } from '../src/core/minions/lease-pressure-audit.ts';
|
||||
|
||||
let engine: PGLiteEngine;
|
||||
let queue: MinionQueue;
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({ database_url: '' });
|
||||
await engine.initSchema();
|
||||
queue = new MinionQueue(engine);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await engine.executeRaw('DELETE FROM minion_lease_pressure_log');
|
||||
await engine.executeRaw('DELETE FROM subagent_rate_leases');
|
||||
await engine.executeRaw('DELETE FROM minion_jobs');
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: claim a job via the real `queue.claim()` path so all of the
|
||||
* row-state bookkeeping (attempts_started++, lock_token, lock_until)
|
||||
* happens correctly. Critical: the `chk_attempts_order` constraint
|
||||
* requires `attempts_made <= attempts_started`, so any failJob call
|
||||
* needs attempts_started > 0 first — i.e. a real claim must have run.
|
||||
*/
|
||||
async function claimJobReal(name: string): Promise<{ id: number; lockToken: string }> {
|
||||
const lockToken = 'lock_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
|
||||
const claimed = await queue.claim(lockToken, 30_000, 'default', [name]);
|
||||
if (!claimed) throw new Error('claim returned null — queue had no jobs');
|
||||
return { id: claimed.id, lockToken };
|
||||
}
|
||||
|
||||
describe('queue.releaseLeaseFullJob (Bug 2 load-bearing)', () => {
|
||||
test('flips status to delayed without incrementing attempts_made', async () => {
|
||||
await queue.add('test-flip', {});
|
||||
const { id, lockToken } = await claimJobReal('test-flip');
|
||||
const before = await queue.getJob(id);
|
||||
expect(before!.attempts_made).toBe(0);
|
||||
expect(before!.attempts_started).toBe(1); // claim bumped this
|
||||
|
||||
const released = await queue.releaseLeaseFullJob(
|
||||
id, lockToken, 'rate lease "anthropic:messages" full (8/8)', 1500,
|
||||
);
|
||||
expect(released).not.toBeNull();
|
||||
|
||||
const after = await queue.getJob(id);
|
||||
expect(after!.status).toBe('delayed');
|
||||
// attempts_made UNCHANGED — this is the entire point of Bug 2.
|
||||
expect(after!.attempts_made).toBe(0);
|
||||
expect(after!.delay_until).toBeDefined();
|
||||
expect(after!.error_text).toContain('lease');
|
||||
// Lock cleared so next claim can pick it up.
|
||||
expect(after!.lock_token).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null on lock_token mismatch (idempotency guard)', async () => {
|
||||
await queue.add('test-mismatch', {});
|
||||
const { id } = await claimJobReal('test-mismatch');
|
||||
const released = await queue.releaseLeaseFullJob(
|
||||
id, 'wrong-token', 'err', 1500,
|
||||
);
|
||||
expect(released).toBeNull();
|
||||
// Status stays active (other path won the race).
|
||||
const after = await queue.getJob(id);
|
||||
expect(after!.status).toBe('active');
|
||||
expect(after!.attempts_made).toBe(0);
|
||||
});
|
||||
|
||||
test('multiple bounces do not increment attempts_made (regression vs Bug 2)', async () => {
|
||||
// 5 bounces with a tiny real-time sleep between each so the delay_until
|
||||
// window expires before the next claim. 5 is enough to prove the
|
||||
// contract; the original field-report bug class dead-lettered at 3.
|
||||
await queue.add('test-many', {});
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const { id, lockToken } = await claimJobReal('test-many');
|
||||
const released = await queue.releaseLeaseFullJob(id, lockToken, `bounce ${i}`, 5);
|
||||
expect(released).not.toBeNull();
|
||||
// After releaseLeaseFullJob, status='delayed' with delay_until. The
|
||||
// worker normally has a sweep that flips 'delayed' → 'waiting' when
|
||||
// delay_until expires; we short-circuit that here so claim() can
|
||||
// re-pick this job for the next bounce.
|
||||
await engine.executeRaw(
|
||||
`UPDATE minion_jobs SET status = 'waiting', delay_until = NULL
|
||||
WHERE id = $1 AND status = 'delayed'`,
|
||||
[id],
|
||||
);
|
||||
}
|
||||
// Find the job — there's only one.
|
||||
const rows = await engine.executeRaw<{
|
||||
id: number;
|
||||
attempts_made: number;
|
||||
attempts_started: number;
|
||||
status: string;
|
||||
}>(
|
||||
`SELECT id, attempts_made, attempts_started, status FROM minion_jobs WHERE name = 'test-many'`,
|
||||
);
|
||||
expect(rows.length).toBe(1);
|
||||
// attempts_started counts every claim (5 in this test) but attempts_made
|
||||
// is the FAILURE counter — 5 lease-full bounces did NOT route through
|
||||
// failJob, so attempts_made stays 0. This is the entire point of Bug 2.
|
||||
expect(rows[0]!.attempts_made).toBe(0);
|
||||
expect(rows[0]!.attempts_started).toBe(5);
|
||||
|
||||
// Now prove the asymmetry: failJob on a DIFFERENT job DOES increment.
|
||||
await queue.add('test-asymmetry', {});
|
||||
const fail = await claimJobReal('test-asymmetry');
|
||||
await queue.failJob(fail.id, fail.lockToken, 'real err', 'delayed', 100);
|
||||
const failRow = await queue.getJob(fail.id);
|
||||
expect(failRow!.attempts_made).toBe(1); // failJob DID increment
|
||||
});
|
||||
});
|
||||
|
||||
describe('logLeasePressure (Eng D8 audit writer)', () => {
|
||||
test('persists denormalized context inline', async () => {
|
||||
const job = await queue.add('test-name', {});
|
||||
await logLeasePressure(engine, {
|
||||
job_id: job.id,
|
||||
lease_key: 'anthropic:messages',
|
||||
active_at_bounce: 8,
|
||||
max_concurrent: 8,
|
||||
queue_name: 'default',
|
||||
job_name: 'test-name',
|
||||
model: 'claude-sonnet-4-6',
|
||||
provider: 'anthropic',
|
||||
root_owner_id: null,
|
||||
});
|
||||
const rows = await engine.executeRaw<{
|
||||
lease_key: string;
|
||||
job_name: string | null;
|
||||
model: string | null;
|
||||
provider: string | null;
|
||||
}>(
|
||||
`SELECT lease_key, job_name, model, provider
|
||||
FROM minion_lease_pressure_log WHERE job_id = $1`,
|
||||
[job.id],
|
||||
);
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0]!.lease_key).toBe('anthropic:messages');
|
||||
expect(rows[0]!.job_name).toBe('test-name');
|
||||
expect(rows[0]!.model).toBe('claude-sonnet-4-6');
|
||||
expect(rows[0]!.provider).toBe('anthropic');
|
||||
});
|
||||
|
||||
test('countRecentLeasePressure counts rows in a window', async () => {
|
||||
const job = await queue.add('t', {});
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await logLeasePressure(engine, {
|
||||
job_id: job.id,
|
||||
lease_key: 'anthropic:messages',
|
||||
active_at_bounce: 8,
|
||||
max_concurrent: 8,
|
||||
});
|
||||
}
|
||||
const count = await countRecentLeasePressure(engine, 60_000);
|
||||
expect(count).toBe(5);
|
||||
});
|
||||
|
||||
test('SET NULL FK keeps audit row alive after job is hard-deleted (Eng D3)', async () => {
|
||||
const job = await queue.add('temp', {});
|
||||
await logLeasePressure(engine, {
|
||||
job_id: job.id,
|
||||
lease_key: 'anthropic:messages',
|
||||
active_at_bounce: 8,
|
||||
max_concurrent: 8,
|
||||
queue_name: 'default',
|
||||
job_name: 'temp',
|
||||
model: 'claude-sonnet-4-6',
|
||||
});
|
||||
// Hard-delete the job (simulating `gbrain jobs prune`).
|
||||
await engine.executeRaw('DELETE FROM minion_jobs WHERE id = $1', [job.id]);
|
||||
// Audit row survives.
|
||||
const rows = await engine.executeRaw<{
|
||||
job_id: number | null;
|
||||
job_name: string | null;
|
||||
model: string | null;
|
||||
}>(
|
||||
`SELECT job_id, job_name, model FROM minion_lease_pressure_log`,
|
||||
);
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0]!.job_id).toBeNull(); // SET NULL fired
|
||||
// Denormalized columns SURVIVE — the whole point of D8 + codex pass-3 #7.
|
||||
expect(rows[0]!.job_name).toBe('temp');
|
||||
expect(rows[0]!.model).toBe('claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
test('write failure does not throw (best-effort contract)', async () => {
|
||||
// Drive a constraint violation via a NULL on a NOT NULL column.
|
||||
// We do this by passing a bogus `job_id` that doesn't exist — but
|
||||
// wait, SET NULL FK accepts that. So instead just verify no-throw
|
||||
// on a normal write happens cleanly. The non-throw contract is
|
||||
// proved structurally by the try/catch wrap; nothing to assert here
|
||||
// beyond "this call doesn't throw."
|
||||
await expect(
|
||||
logLeasePressure(engine, {
|
||||
job_id: 9999999, // non-existent
|
||||
lease_key: 'anthropic:messages',
|
||||
active_at_bounce: 8,
|
||||
max_concurrent: 8,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user