fix(orchestration): compare stale-dispatch timestamps with julianday (#8514)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
dbe2481d57
commit
82573d70ae
|
|
@ -6,6 +6,20 @@ import Database from '../../sqlite/sync-database'
|
|||
import { OrchestrationDb } from './db'
|
||||
import type { MessageType } from './db'
|
||||
|
||||
// Overwrites the datetime('now')-seeded timestamps with explicit fixture values
|
||||
// so stale-detection assertions stay deterministic (no wall clock).
|
||||
function setDispatchTimes(
|
||||
d: OrchestrationDb,
|
||||
id: string,
|
||||
dispatchedAt: string,
|
||||
heartbeatAt: string | null = null
|
||||
): void {
|
||||
const sqlite = (d as unknown as { db: Database.Database }).db
|
||||
sqlite
|
||||
.prepare('UPDATE dispatch_contexts SET dispatched_at = ?, last_heartbeat_at = ? WHERE id = ?')
|
||||
.run(dispatchedAt, heartbeatAt, id)
|
||||
}
|
||||
|
||||
describe('OrchestrationDb', () => {
|
||||
let db: OrchestrationDb | undefined
|
||||
|
||||
|
|
@ -696,6 +710,67 @@ describe('OrchestrationDb', () => {
|
|||
expect(stale.map((s) => s.id)).toEqual([ctxB.id])
|
||||
})
|
||||
|
||||
// Regression for #8452: dispatched_at / last_heartbeat_at are written by
|
||||
// datetime('now') (space-format, e.g. "2026-07-12 12:00:00") while the
|
||||
// threshold is ISO ("...T11:55:00.000Z"). Raw TEXT ordering ranks the space
|
||||
// (0x20) below the 'T' (0x54) at index 10, flagging fresh same-date rows.
|
||||
it('getStaleDispatches ignores fresh SQLite space-format timestamps (#8452)', () => {
|
||||
const d = createDb()
|
||||
|
||||
// Fresh worker: dispatched 12:00, heartbeat 12:05 (space-format), both
|
||||
// after the 11:55 threshold → NOT stale.
|
||||
const fresh = d.createDispatchContext(d.createTask({ spec: 'fresh' }).id, 'term_fresh')
|
||||
setDispatchTimes(d, fresh.id, '2026-07-12 12:00:00', '2026-07-12 12:05:00')
|
||||
|
||||
// Legacy ISO-format fresh row (mixed-format table) stays fresh too.
|
||||
const legacy = d.createDispatchContext(d.createTask({ spec: 'legacy' }).id, 'term_legacy')
|
||||
setDispatchTimes(d, legacy.id, '2026-07-12T12:00:00.000Z', '2026-07-12T12:05:00.000Z')
|
||||
|
||||
// Genuinely hung: dispatched + heartbeated at 10:00, ~2h before threshold.
|
||||
const hung = d.createDispatchContext(d.createTask({ spec: 'hung' }).id, 'term_hung')
|
||||
setDispatchTimes(d, hung.id, '2026-07-12 10:00:00', '2026-07-12 10:00:00')
|
||||
|
||||
const stale = d.getStaleDispatches('2026-07-12T11:55:00.000Z')
|
||||
expect(stale.map((s) => s.id)).toEqual([hung.id])
|
||||
})
|
||||
|
||||
it('getStaleDispatches keeps a just-dispatched space-format row in the grace window (#8452)', () => {
|
||||
const d = createDb()
|
||||
|
||||
// Space-format dispatched_at one minute after the threshold, no heartbeat
|
||||
// yet → still inside the grace window, must not be flagged.
|
||||
const ctx = d.createDispatchContext(d.createTask({ spec: 'x' }).id, 'term_x')
|
||||
setDispatchTimes(d, ctx.id, '2026-07-12 12:00:00')
|
||||
|
||||
const stale = d.getStaleDispatches('2026-07-12T11:59:00.000Z')
|
||||
expect(stale).toEqual([])
|
||||
})
|
||||
|
||||
// Same-UTC-date midnight threshold: keeps the buggy space-vs-'T' compare in
|
||||
// play so this guards the fix at a day boundary (#8452; idea from @KMGeon's #8453).
|
||||
it('getStaleDispatches keeps a fresh row just after a UTC-midnight threshold (#8452)', () => {
|
||||
const d = createDb()
|
||||
|
||||
const ctx = d.createDispatchContext(d.createTask({ spec: 'midnight' }).id, 'term_midnight')
|
||||
setDispatchTimes(d, ctx.id, '2026-05-04 00:04:00')
|
||||
|
||||
const stale = d.getStaleDispatches('2026-05-04T00:00:00.000Z')
|
||||
expect(stale).toEqual([])
|
||||
})
|
||||
|
||||
// Guards the last_heartbeat_at half of the fix on its own: a worker
|
||||
// dispatched long before the threshold (stale under either format) that
|
||||
// just sent a fresh space-format heartbeat must stay fresh (#8452).
|
||||
it('getStaleDispatches keeps a live worker with a fresh space-format heartbeat (#8452)', () => {
|
||||
const d = createDb()
|
||||
|
||||
const ctx = d.createDispatchContext(d.createTask({ spec: 'live' }).id, 'term_live')
|
||||
setDispatchTimes(d, ctx.id, '2026-07-12 10:00:00', '2026-07-12 11:59:00')
|
||||
|
||||
const stale = d.getStaleDispatches('2026-07-12T11:55:00.000Z')
|
||||
expect(stale).toEqual([])
|
||||
})
|
||||
|
||||
it('getThreadMessagesFor returns only same-thread replies to a handle', () => {
|
||||
const d = createDb()
|
||||
const outbound = d.insertMessage({
|
||||
|
|
|
|||
|
|
@ -798,17 +798,20 @@ export class OrchestrationDb {
|
|||
// failed / circuit_broken row with an old-or-null last_heartbeat_at would
|
||||
// warn every tick (warning storm). Without `dispatched_at < :threshold`,
|
||||
// a freshly-dispatched worker would trip the warning during its first
|
||||
// heartbeat interval (false positive). Callers supply the threshold as an
|
||||
// ISO timestamp so the SQLite string-compare ordering works correctly
|
||||
// (ISO-8601 compares lexicographically in time order).
|
||||
// heartbeat interval (false positive). The stored columns are space-format
|
||||
// (datetime('now'), "2026-07-12 12:00:00") while the threshold is ISO-Z, so
|
||||
// raw TEXT ordering compares ' ' (0x20) below 'T' (0x54) at index 10 and
|
||||
// flags fresh same-date rows as stale (#8452). julianday() parses both
|
||||
// formats as UTC for a correct numeric comparison; a malformed timestamp
|
||||
// yields NULL, so that row simply isn't flagged.
|
||||
getStaleDispatches(thresholdIso: string): DispatchContextRow[] {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM dispatch_contexts
|
||||
WHERE status = 'dispatched'
|
||||
AND dispatched_at IS NOT NULL
|
||||
AND dispatched_at < ?
|
||||
AND (last_heartbeat_at IS NULL OR last_heartbeat_at < ?)`
|
||||
AND julianday(dispatched_at) < julianday(?)
|
||||
AND (last_heartbeat_at IS NULL OR julianday(last_heartbeat_at) < julianday(?))`
|
||||
)
|
||||
.all(thresholdIso, thresholdIso) as DispatchContextRow[]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue