Files
orca/cloud/apps/push/src/durable-push-claim.test.ts
T
NeilandClaude da95225ba7 perf(push): keep retention sweeps from overlapping without slowing the drain (#23001)
* perf(push): keep retention sweeps from overlapping

* perf(push): drain a saturated retention sweep instead of idling out the tick

The overlap guard on the shared prune timer removed a side effect the sweeper had
been relying on: overlap was the only thing that let a backlog exceed the
50-batch-per-call cap inside one 60-second tick. With the guard, a sweep that
spent its whole budget went idle for the rest of the interval, so a large backlog
drained far slower exactly when retention matters most.

The timer is now a chained setTimeout rather than an interval. `deleteInBatches`
reports whether it exhausted its batch budget, `prune()` returns
`{ deleted, saturated }`, and a saturated sweep is rescheduled immediately. The
connection gate hands a freed slot to the longest waiter, so one serial sweeper
looping back to back still parks a single statement ahead of a worker claim: claim
latency keeps the value the guard bought while the maximum drain rate returns to
what it was before. The loop is self-limiting and stops once the backlog clears.

A sweep that has not settled a full interval after it started now logs
`orca_push_prune_overdue` with its target. Admission waits have no timeout, so a
lost slot release could previously wedge retention permanently and silently.

Chaining makes the overlap guard structural, so there is no flag to scope. The two
single-DELETE sweeps state through `unbatchedSweep` that they have no batch budget
to exhaust, which keeps the immediate-resume path readable as delivery-only.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-26 20:39:57 -07:00

222 lines
9.1 KiB
TypeScript

import { afterEach, expect, it } from 'vitest'
import type { PushDatabase } from './push-database.js'
import {
DELIVERY_LEASE_MS,
DurablePushStore,
PRUNE_BATCH_ROWS,
PRUNE_MAX_BATCHES
} from './durable-push-store.js'
import { PUSH_LIMITS } from '@orca-cloud/push-contract'
import {
CANDIDATE_SQL,
cleanupDurablePushFixtures,
DEVICE_HEAD_SQL,
durablePushTestDatabaseUrl,
fixture,
notification,
pauseAfter,
within
} from './durable-push-store.test-fixture.js'
afterEach(cleanupDurablePushFixtures)
async function batchCount(db: PushDatabase): Promise<number> {
const [row] = await db.query('SELECT COUNT(*) AS total FROM push_delivery_batches')
return Number(row?.total ?? 0)
}
it('leases exactly one delivery per device across concurrent claimers', async () => {
const { db, store, clock, advance } = await fixture()
for (let seq = 1; seq <= 5; seq++) {
await store.accept('host', 'phone', notification(seq))
advance(1)
}
const claims = await Promise.all(
Array.from({ length: 8 }, () => new DurablePushStore(db, clock).claim())
)
const leased = claims.filter((claim) => claim !== null)
expect(leased).toHaveLength(1)
expect(leased[0]?.notification.notificationSeq).toBe(1)
})
it.skipIf(!durablePushTestDatabaseUrl)(
'does not lease the row behind a head another claimer holds',
async () => {
const { db, store, clock, advance } = await fixture()
await store.accept('host', 'phone', notification(1))
advance(1)
await store.accept('host', 'phone', notification(2))
const paused = pauseAfter(db)
const first = new DurablePushStore(paused.wrapped, clock).claim()
await paused.atCandidate
try {
expect(await within(store.claim(), 2_000)).toBeNull()
} finally {
paused.release()
}
expect((await first)?.notification.notificationSeq).toBe(1)
expect(await store.claim()).toBeNull()
}
)
it.skipIf(!durablePushTestDatabaseUrl)(
'claims another device while one claim transaction is still open',
async () => {
const { db, store, clock, advance } = await fixture()
await store.accept('host', 'phone-a', notification(1))
advance(1)
await store.accept('host', 'phone-b', notification(2))
const paused = pauseAfter(db)
const first = new DurablePushStore(paused.wrapped, clock).claim()
await paused.atCandidate
try {
expect((await within(store.claim(), 2_000))?.registrationId).toBe('phone-b')
} finally {
paused.release()
}
expect((await first)?.registrationId).toBe('phone-a')
}
)
it.skipIf(!durablePushTestDatabaseUrl)(
'keeps a device exclusive when an earlier-sorting row lands mid-claim',
async () => {
const { db, store, clock } = await fixture()
await store.accept('host', 'phone', notification(1))
const paused = pauseAfter(db, DEVICE_HEAD_SQL)
const first = new DurablePushStore(paused.wrapped, clock).claim()
await paused.atCandidate
try {
// Another instance with a slower clock queues a row that now sorts first.
await db.query(
`INSERT INTO push_delivery_batches(batch_id, host_fingerprint, registration_id, kind, payload_json, state, due_at, expires_at, lease_until, attempts, created_at)
VALUES ('skewed', 'host', 'phone', 'alert', ?, 'pending', ?, ?, 0, 0, ?)`,
[JSON.stringify(notification(2)), clock() - 10, clock() + 60_000, clock() - 10]
)
expect(await within(store.claim(), 2_000)).toBeNull()
} finally {
paused.release()
}
expect((await first)?.notification.notificationSeq).toBe(1)
}
)
// Only the TTL term keeps the candidate scan off an unpruned backlog; expires_at is not indexed first.
it('never scans rows due before the notification TTL window', async () => {
const { db, store, clock } = await fixture()
const stale = clock() - PUSH_LIMITS.notificationTtlSeconds * 1000 - 1
const insert = `INSERT INTO push_delivery_batches(batch_id, host_fingerprint, registration_id, kind, payload_json, state, due_at, expires_at, lease_until, attempts, created_at)
VALUES (?, 'host', ?, 'alert', ?, 'pending', ?, ?, 0, 0, ?)`
// One unpruned backlog row, and one whose expires_at alone would admit it.
await db.query(insert, ['backlog', 'phone-a', JSON.stringify(notification(1)), stale, stale, stale])
await db.query(insert, ['past-ttl', 'phone-b', JSON.stringify(notification(2)), stale, clock() + 60_000, stale])
await store.accept('host', 'phone-c', notification(3))
const scanned: unknown[] = []
const capture: PushDatabase = {
dialect: db.dialect,
query: (sql, params) => db.query(sql, params),
close: () => db.close(),
lockQuotaScope: (key) => db.lockQuotaScope(key),
tryLockScope: (key) => db.tryLockScope(key),
tryLockSharedScope: (key) => db.tryLockSharedScope(key),
transaction: (run) =>
db.transaction((tx) =>
run({
...tx,
dialect: tx.dialect,
close: () => tx.close(),
transaction: (inner) => tx.transaction(inner),
lockQuotaScope: (key) => tx.lockQuotaScope(key),
tryLockScope: (key) => tx.tryLockScope(key),
tryLockSharedScope: (key) => tx.tryLockSharedScope(key),
query: async (sql, params) => {
const rows = await tx.query(sql, params)
if (sql.startsWith(CANDIDATE_SQL)) scanned.push(...rows.map((row) => row.batch_id))
return rows
}
})
)
}
expect((await new DurablePushStore(capture, clock).claim())?.registrationId).toBe('phone-c')
expect(scanned).toHaveLength(1)
expect(scanned).not.toContain('past-ttl')
})
it('keeps claim, exclusion and prune correct without the queue index', async () => {
const { db, store, clock, advance } = await fixture()
await db.query('DROP INDEX push_batches_pending_device')
await store.accept('host', 'phone', notification(1))
advance(1)
await store.accept('host', 'phone', notification(2))
await store.accept('host', 'phone-2', notification(3))
const claims = await Promise.all(
Array.from({ length: 4 }, () => new DurablePushStore(db, clock).claim())
)
const leased = claims.filter((claim) => claim !== null)
expect(leased.map((claim) => claim.notification.notificationSeq).sort()).toEqual([1, 3])
for (const claim of leased) await store.finish(claim)
expect((await store.claim())?.notification.notificationSeq).toBe(2)
advance(10 * 60_000)
expect(await store.prune()).toEqual({ deleted: 1, saturated: false })
expect(await batchCount(db)).toBe(0)
})
it('deletes a batch once it is finished, and keeps it only for a retry', async () => {
const { db, store, advance } = await fixture()
await store.accept('host', 'phone', notification(1))
advance(1)
await store.accept('host', 'phone-2', notification(2))
const retried = (await store.claim())!
await store.finish(retried, 1000)
expect(await store.pendingCount(retried.registrationId)).toBe(1)
const sent = (await store.claim())!
await store.finish(sent)
expect(await batchCount(db)).toBe(1)
advance(1000)
const retry = (await store.claim())!
expect(retry.id).toBe(retried.id)
await store.finish(retry, 10 * 60_000)
expect(await batchCount(db)).toBe(0)
})
it('frees a device held by a crashed worker once its lease lapses', async () => {
const { db, store, clock, advance } = await fixture()
await store.accept('host', 'phone', notification(1))
advance(1)
await store.accept('host', 'phone', notification(2))
const crashed = (await new DurablePushStore(db, clock).claim())!
advance(DELIVERY_LEASE_MS - 1)
expect(await store.claim()).toBeNull()
advance(1)
const reclaimed = (await store.claim())!
expect(reclaimed.id).toBe(crashed.id)
expect(reclaimed.attempts).toBe(2)
await store.finish(reclaimed)
expect((await store.claim())?.notification.notificationSeq).toBe(2)
})
it('prunes a large backlog in bounded calls without touching live or leased work', async () => {
const { db, store, clock, advance } = await fixture()
const perCall = PRUNE_BATCH_ROWS * PRUNE_MAX_BATCHES
const now = clock()
await db.query(
`INSERT INTO push_delivery_batches(batch_id, host_fingerprint, registration_id, kind, payload_json, state, due_at, expires_at, lease_until, attempts, created_at)
WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < ?)
SELECT 'old-' || n, 'host', 'phone-' || (n % 50), 'alert', '{}', 'done', ?, ?, 0, 1, ? FROM seq`,
[perCall + 500, now - 1, now - 1, now - 1]
)
await db.query(
`INSERT INTO push_delivery_batches(batch_id, host_fingerprint, registration_id, kind, payload_json, state, due_at, expires_at, lease_until, attempts, created_at)
VALUES ('leased', 'host', 'phone-x', 'alert', ?, 'pending', ?, ?, ?, 1, ?)`,
[JSON.stringify(notification(9)), now - 1, now - 1, now + 1000, now - 1]
)
await store.accept('host', 'phone-live', notification(1))
expect(await store.prune()).toEqual({ deleted: perCall, saturated: true })
expect(await store.prune()).toEqual({ deleted: 500, saturated: false })
expect(await store.prune()).toEqual({ deleted: 0, saturated: false })
advance(1000)
expect(await store.prune()).toEqual({ deleted: 1, saturated: false })
expect(await batchCount(db)).toBe(1)
expect(await store.pendingCount('phone-live')).toBe(1)
})