switchroom 0.21.3 → 0.21.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/handoff-briefing.sh +23 -1
- package/dist/cli/switchroom.js +79 -16
- package/dist/host-control/main.js +1 -1
- package/package.json +3 -2
- package/telegram-plugin/dist/gateway/gateway.js +360 -102
- package/telegram-plugin/gateway/gateway.ts +3 -1
- package/telegram-plugin/gateway/orphaned-db-sweep.ts +315 -0
- package/telegram-plugin/history.ts +328 -62
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +19 -4
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -2
- package/telegram-plugin/tests/orphaned-db-sweep.test.ts +713 -0
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outcome coverage for the orphaned-DB-fd sweep
|
|
3
|
+
* (`gateway/orphaned-db-sweep.ts` + `history.reopenHistory`).
|
|
4
|
+
*
|
|
5
|
+
* The core test REPRODUCES THE INCIDENT rather than mocking it: it opens a real
|
|
6
|
+
* history.db, checkpoints (standing in for the foreign process's exit
|
|
7
|
+
* checkpoint), unlinks the `-wal`/`-shm` out from under the live connection,
|
|
8
|
+
* and then proves both halves of the defect — the write still "succeeds", and
|
|
9
|
+
* the row is NOT in the on-disk database. No timers, no sleeps, no timing
|
|
10
|
+
* dependence.
|
|
11
|
+
*
|
|
12
|
+
* TWO MEASURED FACTS THIS TEST IS BUILT AROUND (both verified on bun 1.3.13,
|
|
13
|
+
* both of which invalidate the obvious way to write it):
|
|
14
|
+
*
|
|
15
|
+
* 1. During the orphan window a SECOND live connection to the same file cannot
|
|
16
|
+
* be READ from — SQLite's unix VFS keeps per-inode WAL-index state shared
|
|
17
|
+
* across connections in the process. So the "is it on disk?" probe CANNOT
|
|
18
|
+
* be a second `new Database` on the live path. It is instead a
|
|
19
|
+
* `snapshotOnDisk()` that copies `history.db` (+ `-wal` when one exists) to
|
|
20
|
+
* a scratch dir and opens the COPY — a different inode, and a faithful
|
|
21
|
+
* reading of what is durably on disk. A separate PROCESS opening the
|
|
22
|
+
* original reads the same thing, which is how we know the on-disk DB is not
|
|
23
|
+
* corrupt.
|
|
24
|
+
*
|
|
25
|
+
* 2. bun:sqlite's plain `db.close()` is a SOFT close that does not release the
|
|
26
|
+
* fds while prepared statements are un-finalized. `hardCloseDb` therefore
|
|
27
|
+
* FINALIZES every cached statement and then calls `close(true)`; the
|
|
28
|
+
* regression assertion for that is `detectOrphanedDbFds(stateDir)` being
|
|
29
|
+
* EMPTY after recovery.
|
|
30
|
+
*
|
|
31
|
+
* WHY THERE IS A DETERMINISM TEST UNDER AN ADVERSARIAL HEAP
|
|
32
|
+
* --------------------------------------------------------
|
|
33
|
+
* The first cut of this recovery leaned on `Bun.gc(true)` to make the
|
|
34
|
+
* un-finalized per-call statements collectable before `close(true)`. Measured
|
|
35
|
+
* on bun 1.3.13, that is NOT deterministic: with an essentially empty heap a
|
|
36
|
+
* single gc+close failed 13 times in 20, and with an ordinary arithmetic loop
|
|
37
|
+
* running between the writes and the close it failed 30 times in 30. The fix is
|
|
38
|
+
* the module-level statement cache in `history.ts`, whose entries are explicitly
|
|
39
|
+
* `.finalize()`d by `hardCloseDb`; with it, the same 30-iteration adversarial
|
|
40
|
+
* shape fails 0 times in 30 — and still 0 in 30 with `Bun.gc(true)` deleted
|
|
41
|
+
* entirely, which is the proof that the cache and not the gc is what makes the
|
|
42
|
+
* close deterministic. `'reopens deterministically under the heap shape that
|
|
43
|
+
* broke the gc-only version'` below is that measurement, pinned.
|
|
44
|
+
*
|
|
45
|
+
* NOTE ON THE `close(true)` ARGUMENT. A mutation of `close(true)` →
|
|
46
|
+
* `close()` inside `hardCloseDb` SURVIVES the incident-reproduction test: once
|
|
47
|
+
* every statement is finalized the soft close releases the fds too, so the
|
|
48
|
+
* integration test genuinely cannot see the difference. The `true` (throw on a
|
|
49
|
+
* still-busy handle) is an HONESTY property — never report a recovery that did
|
|
50
|
+
* not happen — so it is pinned one level down, on `hardCloseDb` itself, in the
|
|
51
|
+
* `describe('hardCloseDb')` block with a handle double.
|
|
52
|
+
*
|
|
53
|
+
* WHAT CANNOT BE TESTED DETERMINISTICALLY HERE, and what covers it instead:
|
|
54
|
+
*
|
|
55
|
+
* - The non-Linux early return in `detectOrphanedDbFds`. `/proc/self/fd` only
|
|
56
|
+
* exists on Linux; the guard is a platform branch a Linux CI runner cannot
|
|
57
|
+
* enter. Every `/proc`-dependent block below is `describe.skipIf`-guarded so
|
|
58
|
+
* a macOS developer gets a skip, not a spurious pass.
|
|
59
|
+
* - The 5-minute production cadence. The interval itself IS asserted (see
|
|
60
|
+
* `describe('startOrphanedDbSweep')`, which samples log output before and
|
|
61
|
+
* after `stop()` at a 5ms cadence); only the specific 5-minute default is
|
|
62
|
+
* left to code shape, since waiting for it would be a timing dependence.
|
|
63
|
+
* - The `gateway.ts` wiring line. gateway.ts boots a live gateway on import
|
|
64
|
+
* and is not unit-importable. Covered by `tsc --noEmit` for the call shape
|
|
65
|
+
* and `check-gateway-line-ratchet` for the size budget.
|
|
66
|
+
* - The no-corrupting-checkpoint property (that recovery never issues an
|
|
67
|
+
* explicit WAL checkpoint through the orphaned handle). Enforced by CODE
|
|
68
|
+
* SHAPE — there is no `wal_checkpoint` anywhere in `reopenHistory` — not by
|
|
69
|
+
* an assertion. A test cannot distinguish a safe checkpoint from a
|
|
70
|
+
* corrupting one without provoking real corruption.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
74
|
+
import {
|
|
75
|
+
mkdtempSync,
|
|
76
|
+
rmSync,
|
|
77
|
+
existsSync,
|
|
78
|
+
unlinkSync,
|
|
79
|
+
copyFileSync,
|
|
80
|
+
openSync,
|
|
81
|
+
closeSync,
|
|
82
|
+
writeFileSync,
|
|
83
|
+
symlinkSync,
|
|
84
|
+
} from 'fs'
|
|
85
|
+
import { tmpdir } from 'os'
|
|
86
|
+
import { basename, join } from 'path'
|
|
87
|
+
// bun-only (this file is vitest-excluded and runs under `bun test`): the
|
|
88
|
+
// on-disk snapshot probe, and the raw registry.db handle for the
|
|
89
|
+
// detection-only lane.
|
|
90
|
+
import { Database } from 'bun:sqlite'
|
|
91
|
+
import {
|
|
92
|
+
initHistory,
|
|
93
|
+
reopenHistory,
|
|
94
|
+
hardCloseDb,
|
|
95
|
+
getHistoryReopenFailure,
|
|
96
|
+
recordInbound,
|
|
97
|
+
recordOutbound,
|
|
98
|
+
checkpointWal,
|
|
99
|
+
query,
|
|
100
|
+
lookupMessageRoleAndText,
|
|
101
|
+
verifyHistoryWritable,
|
|
102
|
+
_resetForTests,
|
|
103
|
+
} from '../history.js'
|
|
104
|
+
import {
|
|
105
|
+
detectOrphanedDbFds,
|
|
106
|
+
runOrphanedDbSweepTick,
|
|
107
|
+
startOrphanedDbSweep,
|
|
108
|
+
} from '../gateway/orphaned-db-sweep.js'
|
|
109
|
+
import { GATEWAY_SIGNATURES } from '../../src/fleet-health/detect.js'
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* `/proc/self/fd` is Linux-only. Without this guard every detection test would
|
|
113
|
+
* pass VACUOUSLY on macOS — `detectOrphanedDbFds` returns `[]` there by design,
|
|
114
|
+
* so `expect(...).toEqual([])` is satisfied by the platform, not the code.
|
|
115
|
+
*/
|
|
116
|
+
const onLinux = process.platform === 'linux'
|
|
117
|
+
|
|
118
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
119
|
+
|
|
120
|
+
let stateDir: string
|
|
121
|
+
const scratchDirs: string[] = []
|
|
122
|
+
const openFds: number[] = []
|
|
123
|
+
|
|
124
|
+
beforeEach(() => {
|
|
125
|
+
stateDir = mkdtempSync(join(tmpdir(), 'orphaned-db-sweep-test-'))
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
afterEach(() => {
|
|
129
|
+
try { _resetForTests() } catch { /* an orphaned handle may refuse to close */ }
|
|
130
|
+
for (const fd of openFds.splice(0)) {
|
|
131
|
+
try { closeSync(fd) } catch { /* already gone */ }
|
|
132
|
+
}
|
|
133
|
+
for (const d of scratchDirs.splice(0)) rmSync(d, { recursive: true, force: true })
|
|
134
|
+
if (existsSync(stateDir)) rmSync(stateDir, { recursive: true, force: true })
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
/** A second temp dir, torn down with the test. */
|
|
138
|
+
function scratch(prefix: string): string {
|
|
139
|
+
const dir = mkdtempSync(join(tmpdir(), prefix))
|
|
140
|
+
scratchDirs.push(dir)
|
|
141
|
+
return dir
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* What a RESTART would see: copy the durable files to a scratch dir (a fresh
|
|
146
|
+
* inode, sidestepping the in-process WAL-index poisoning documented above) and
|
|
147
|
+
* read the copy. Deliberately does NOT go through the module singleton, whose
|
|
148
|
+
* writes may be landing in a deleted inode.
|
|
149
|
+
*/
|
|
150
|
+
function snapshotOnDisk(): number[] {
|
|
151
|
+
const dir = scratch('orphaned-db-sweep-snap-')
|
|
152
|
+
const src = join(stateDir, 'history.db')
|
|
153
|
+
const dst = join(dir, 'history.db')
|
|
154
|
+
copyFileSync(src, dst)
|
|
155
|
+
if (existsSync(src + '-wal')) copyFileSync(src + '-wal', dst + '-wal')
|
|
156
|
+
const probe = new Database(dst)
|
|
157
|
+
try {
|
|
158
|
+
const rows = probe.prepare('SELECT message_id FROM messages ORDER BY message_id').all() as {
|
|
159
|
+
message_id: number
|
|
160
|
+
}[]
|
|
161
|
+
return rows.map((r) => r.message_id)
|
|
162
|
+
} finally {
|
|
163
|
+
probe.close()
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* `ts` must be recent — `initHistory`'s retention prune runs on every (re)open
|
|
169
|
+
* and would delete rows backdated past the retention window, which would make
|
|
170
|
+
* the post-reopen assertions lie.
|
|
171
|
+
*/
|
|
172
|
+
function inbound(messageId: number, text: string): void {
|
|
173
|
+
recordInbound({
|
|
174
|
+
chat_id: '900001',
|
|
175
|
+
thread_id: null,
|
|
176
|
+
message_id: messageId,
|
|
177
|
+
user: 'tester',
|
|
178
|
+
user_id: '900001',
|
|
179
|
+
ts: Math.floor(Date.now() / 1000),
|
|
180
|
+
text,
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Unlink the WAL sidecars out from under the live connection, as the incident did. */
|
|
185
|
+
function unlinkSidecars(dbFile: string): void {
|
|
186
|
+
for (const suffix of ['-wal', '-shm']) {
|
|
187
|
+
const f = dbFile + suffix
|
|
188
|
+
expect(existsSync(f)).toBe(true)
|
|
189
|
+
unlinkSync(f)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Create a file, hold an fd on it, and unlink it — a deleted-inode fd that is
|
|
195
|
+
* NOT a database. The sweep must ignore it.
|
|
196
|
+
*/
|
|
197
|
+
function orphanNonDbFile(path: string): void {
|
|
198
|
+
writeFileSync(path, 'x')
|
|
199
|
+
openFds.push(openSync(path, 'r'))
|
|
200
|
+
unlinkSync(path)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Seed a `history.db` whose schema is entirely valid — every DDL statement,
|
|
205
|
+
* migration and index in `initHistory` succeeds against it — but whose
|
|
206
|
+
* `messages` table REJECTS the writer self-check's sentinel row.
|
|
207
|
+
*
|
|
208
|
+
* This is the only shape that separates "the DB opened and the schema is
|
|
209
|
+
* there" from "a row can actually be persisted", which is exactly the gap the
|
|
210
|
+
* post-reopen self-check exists to close: `initHistory` only WARNS when its own
|
|
211
|
+
* self-check fails and returns normally, so without the gate `reopenHistory`
|
|
212
|
+
* returns success on a DB that cannot take a write and the sweep logs "writes
|
|
213
|
+
* are durable again" on top of "WRITER SELF-CHECK FAILED". Uses a CHECK
|
|
214
|
+
* constraint rather than filesystem permissions deliberately: the test suite
|
|
215
|
+
* runs as root in some containers, where a chmod-based read-only fixture is
|
|
216
|
+
* silently bypassed and the test passes for the wrong reason.
|
|
217
|
+
*/
|
|
218
|
+
function seedSelfCheckPoisonedDb(): void {
|
|
219
|
+
const raw = new Database(join(stateDir, 'history.db'), { create: true })
|
|
220
|
+
try {
|
|
221
|
+
raw.exec(`
|
|
222
|
+
CREATE TABLE messages (
|
|
223
|
+
chat_id TEXT NOT NULL,
|
|
224
|
+
thread_id INTEGER,
|
|
225
|
+
message_id INTEGER NOT NULL,
|
|
226
|
+
role TEXT NOT NULL,
|
|
227
|
+
user TEXT,
|
|
228
|
+
user_id TEXT,
|
|
229
|
+
ts INTEGER NOT NULL,
|
|
230
|
+
text TEXT NOT NULL,
|
|
231
|
+
attachment_kind TEXT,
|
|
232
|
+
group_id INTEGER,
|
|
233
|
+
reply_to_message_id INTEGER,
|
|
234
|
+
reply_to_text TEXT,
|
|
235
|
+
kind TEXT,
|
|
236
|
+
PRIMARY KEY (chat_id, thread_id, message_id),
|
|
237
|
+
CHECK (text <> 'selfcheck')
|
|
238
|
+
)
|
|
239
|
+
`)
|
|
240
|
+
} finally {
|
|
241
|
+
raw.close(false)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The sorted bare file names behind a set of orphans, for exact-set assertions. */
|
|
246
|
+
function orphanNames(orphans: { target: string }[]): string[] {
|
|
247
|
+
return orphans
|
|
248
|
+
.map((o) => basename(o.target.replace(/ \(deleted\)$/, '')))
|
|
249
|
+
.sort()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
describe.skipIf(!onLinux)('orphaned-db-sweep — history.db recovery', () => {
|
|
253
|
+
it('reproduces the deleted-inode data loss and restores durable writes', async () => {
|
|
254
|
+
const dbFile = join(stateDir, 'history.db')
|
|
255
|
+
initHistory(stateDir, 30)
|
|
256
|
+
|
|
257
|
+
// 1. A row that reaches the main DB via a checkpoint. The checkpoint stands
|
|
258
|
+
// in for the foreign process's checkpoint-on-last-close.
|
|
259
|
+
inbound(1001, 'row A — before the unlink')
|
|
260
|
+
expect(checkpointWal()).toBe(true)
|
|
261
|
+
|
|
262
|
+
// 2. That foreign process's exit ALSO unlinked the sidecars. Our live
|
|
263
|
+
// connection keeps the deleted inodes mapped.
|
|
264
|
+
unlinkSidecars(dbFile)
|
|
265
|
+
|
|
266
|
+
// 3. THE DEFECT, half one: the write path still reports success.
|
|
267
|
+
expect(() => inbound(1002, 'row B — into the deleted inode')).not.toThrow()
|
|
268
|
+
|
|
269
|
+
// 4. THE DEFECT, half two: row B never reached disk. What a restart would
|
|
270
|
+
// see is A and NOT B. If SQLite's behaviour ever changes, the test's
|
|
271
|
+
// premise fails loudly right here instead of passing vacuously.
|
|
272
|
+
expect(snapshotOnDisk()).toEqual([1001])
|
|
273
|
+
|
|
274
|
+
// 5. Detection sees BOTH orphaned handles. The real incident had two
|
|
275
|
+
// (`-wal` and `-shm`); asserting the exact set means a detector that
|
|
276
|
+
// returns only the first one fails here.
|
|
277
|
+
const orphans = await detectOrphanedDbFds(stateDir)
|
|
278
|
+
expect(orphanNames(orphans)).toEqual(['history.db-shm', 'history.db-wal'])
|
|
279
|
+
|
|
280
|
+
// 6. The tick alarms loudly and reopens.
|
|
281
|
+
const lines: string[] = []
|
|
282
|
+
await runOrphanedDbSweepTick({
|
|
283
|
+
stateDir,
|
|
284
|
+
reopenHistory: () => reopenHistory(stateDir, 30),
|
|
285
|
+
log: (l) => lines.push(l),
|
|
286
|
+
})
|
|
287
|
+
const log = lines.join('')
|
|
288
|
+
expect(log).toContain('LOST')
|
|
289
|
+
expect(log).toContain(`${dbFile}-wal`)
|
|
290
|
+
expect(log).toContain('reopened history.db')
|
|
291
|
+
|
|
292
|
+
// …and the fleet-health L0 detector actually matches that alarm. Without
|
|
293
|
+
// this the signature and the log line drift apart silently and the
|
|
294
|
+
// alarm has no consumer.
|
|
295
|
+
expect(GATEWAY_SIGNATURES['orphaned-db-handle'].test(log)).toBe(true)
|
|
296
|
+
|
|
297
|
+
// 7. THE FIX, half one: the deleted-inode handles are actually GONE. A
|
|
298
|
+
// close that leaves un-finalized statements behind leaves them open
|
|
299
|
+
// (measured), so this is the assertion that pins the hard close.
|
|
300
|
+
expect(await detectOrphanedDbFds(stateDir)).toEqual([])
|
|
301
|
+
|
|
302
|
+
// 8. THE FIX, half two: writes are durable again — on disk, and visible to
|
|
303
|
+
// a restart. And nothing is stuck.
|
|
304
|
+
inbound(1003, 'row C — after the reopen')
|
|
305
|
+
expect(snapshotOnDisk()).toContain(1003)
|
|
306
|
+
expect(verifyHistoryWritable().ok).toBe(true)
|
|
307
|
+
expect(getHistoryReopenFailure()).toBeNull()
|
|
308
|
+
|
|
309
|
+
// …and through the module's own read path after a full re-init, which is
|
|
310
|
+
// what `get_recent_messages` does on the next boot.
|
|
311
|
+
_resetForTests()
|
|
312
|
+
initHistory(stateDir, 30)
|
|
313
|
+
const texts = query({ chat_id: '900001', limit: 50 }).map((m) => m.text)
|
|
314
|
+
expect(texts).toContain('row C — after the reopen')
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('does not alarm on a healthy open DB with a live WAL', async () => {
|
|
318
|
+
initHistory(stateDir, 30)
|
|
319
|
+
inbound(2001, 'healthy row')
|
|
320
|
+
// A live WAL exists and is held open — the ONLY difference from the orphan
|
|
321
|
+
// case is that it has not been unlinked.
|
|
322
|
+
expect(existsSync(join(stateDir, 'history.db-wal'))).toBe(true)
|
|
323
|
+
expect(await detectOrphanedDbFds(stateDir)).toEqual([])
|
|
324
|
+
|
|
325
|
+
const lines: string[] = []
|
|
326
|
+
const orphans = await runOrphanedDbSweepTick({ stateDir, log: (l) => lines.push(l) })
|
|
327
|
+
expect(orphans).toEqual([])
|
|
328
|
+
expect(lines).toEqual([])
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('reopens deterministically under the heap shape that broke the gc-only version', () => {
|
|
332
|
+
// The measured adversarial shape: real writes (which leave statements
|
|
333
|
+
// behind) plus ordinary arithmetic between the writes and the close. The
|
|
334
|
+
// gc-only implementation failed this 30/30; the statement cache passes it
|
|
335
|
+
// even with the gc removed. Twelve iterations keeps the test under a second
|
|
336
|
+
// while still being far past the 13/20 failure rate of the empty-heap case
|
|
337
|
+
// — a regression to gc-dependence cannot survive twelve rolls.
|
|
338
|
+
for (let i = 0; i < 12; i++) {
|
|
339
|
+
initHistory(stateDir, 30)
|
|
340
|
+
inbound(3000 + i, `row ${i}`)
|
|
341
|
+
recordOutbound({
|
|
342
|
+
chat_id: '900001',
|
|
343
|
+
thread_id: null,
|
|
344
|
+
message_ids: [4000 + i],
|
|
345
|
+
texts: [`reply ${i}`],
|
|
346
|
+
})
|
|
347
|
+
checkpointWal()
|
|
348
|
+
let churn = 0
|
|
349
|
+
for (let j = 0; j < 500_000; j++) churn += j % 7
|
|
350
|
+
expect(churn).toBeGreaterThan(0)
|
|
351
|
+
expect(() => reopenHistory(stateDir, 30)).not.toThrow()
|
|
352
|
+
expect(verifyHistoryWritable().ok).toBe(true)
|
|
353
|
+
}
|
|
354
|
+
})
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
describe.skipIf(!onLinux)('orphaned-db-sweep — detection filters', () => {
|
|
358
|
+
it('ignores deleted non-DB files in the state dir', async () => {
|
|
359
|
+
// `basename.includes('.db')` matched every one of these.
|
|
360
|
+
orphanNonDbFile(join(stateDir, 'scratch.tmp'))
|
|
361
|
+
orphanNonDbFile(join(stateDir, 'notes.dbg'))
|
|
362
|
+
orphanNonDbFile(join(stateDir, 'dump.dbf'))
|
|
363
|
+
orphanNonDbFile(join(stateDir, 'history.db.bak'))
|
|
364
|
+
orphanNonDbFile(join(stateDir, 'session.dbus'))
|
|
365
|
+
|
|
366
|
+
expect(await detectOrphanedDbFds(stateDir)).toEqual([])
|
|
367
|
+
|
|
368
|
+
const lines: string[] = []
|
|
369
|
+
await runOrphanedDbSweepTick({ stateDir, log: (l) => lines.push(l) })
|
|
370
|
+
expect(lines).toEqual([])
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('ignores a deleted DB outside the state dir', async () => {
|
|
374
|
+
// A SECOND temp dir, so the filtering is done by the prefix test and not
|
|
375
|
+
// by "there happen to be no orphaned fds at all".
|
|
376
|
+
const elsewhere = scratch('orphaned-db-sweep-elsewhere-')
|
|
377
|
+
const foreign = join(elsewhere, 'foreign.db')
|
|
378
|
+
const raw = new Database(foreign, { create: true })
|
|
379
|
+
try {
|
|
380
|
+
raw.exec('PRAGMA journal_mode = WAL')
|
|
381
|
+
raw.exec('CREATE TABLE t (id INTEGER PRIMARY KEY)')
|
|
382
|
+
raw.prepare('INSERT INTO t (id) VALUES (?)').run(1)
|
|
383
|
+
unlinkSidecars(foreign)
|
|
384
|
+
|
|
385
|
+
// The orphan is real and detectable — from its OWN dir…
|
|
386
|
+
expect(orphanNames(await detectOrphanedDbFds(elsewhere)))
|
|
387
|
+
.toEqual(['foreign.db-shm', 'foreign.db-wal'])
|
|
388
|
+
// …and invisible from ours.
|
|
389
|
+
expect(await detectOrphanedDbFds(stateDir)).toEqual([])
|
|
390
|
+
} finally {
|
|
391
|
+
raw.close(false)
|
|
392
|
+
}
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('detects through a symlinked state dir', async () => {
|
|
396
|
+
// `/proc/self/fd` targets are always fully resolved. Comparing them
|
|
397
|
+
// against an unresolved `TELEGRAM_STATE_DIR` disables detection silently
|
|
398
|
+
// and forever — the worst possible failure for a data-loss detector.
|
|
399
|
+
const linkParent = scratch('orphaned-db-sweep-link-')
|
|
400
|
+
const link = join(linkParent, 'state-link')
|
|
401
|
+
symlinkSync(stateDir, link)
|
|
402
|
+
|
|
403
|
+
initHistory(stateDir, 30)
|
|
404
|
+
inbound(5001, 'row via symlink')
|
|
405
|
+
checkpointWal()
|
|
406
|
+
unlinkSidecars(join(stateDir, 'history.db'))
|
|
407
|
+
|
|
408
|
+
expect(orphanNames(await detectOrphanedDbFds(link)))
|
|
409
|
+
.toEqual(['history.db-shm', 'history.db-wal'])
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
it('warns rather than reporting healthy when the state dir cannot be resolved', async () => {
|
|
413
|
+
const missing = join(stateDir, 'does-not-exist')
|
|
414
|
+
const lines: string[] = []
|
|
415
|
+
const orphans = await runOrphanedDbSweepTick({ stateDir: missing, log: (l) => lines.push(l) })
|
|
416
|
+
expect(orphans).toEqual([])
|
|
417
|
+
const log = lines.join('')
|
|
418
|
+
expect(log).toContain('cannot resolve stateDir')
|
|
419
|
+
expect(log).toContain('DISABLED')
|
|
420
|
+
})
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
describe.skipIf(!onLinux)('orphaned-db-sweep — registry.db and unowned lanes', () => {
|
|
424
|
+
it('alarms and demands a restart without touching the registry handle', async () => {
|
|
425
|
+
const registryFile = join(stateDir, 'registry.db')
|
|
426
|
+
const raw = new Database(registryFile, { create: true })
|
|
427
|
+
try {
|
|
428
|
+
raw.exec('PRAGMA journal_mode = WAL')
|
|
429
|
+
raw.exec('CREATE TABLE turns (id INTEGER PRIMARY KEY, note TEXT)')
|
|
430
|
+
raw.prepare('INSERT INTO turns (id, note) VALUES (?, ?)').run(1, 'before')
|
|
431
|
+
unlinkSidecars(registryFile)
|
|
432
|
+
|
|
433
|
+
const lines: string[] = []
|
|
434
|
+
// No reopenHistory wired: this lane must alarm off the state-dir prefix,
|
|
435
|
+
// not off the history.db filename.
|
|
436
|
+
const orphans = await runOrphanedDbSweepTick({ stateDir, log: (l) => lines.push(l) })
|
|
437
|
+
expect(orphanNames(orphans)).toEqual(['registry.db-shm', 'registry.db-wal'])
|
|
438
|
+
const log = lines.join('')
|
|
439
|
+
expect(log).toContain('registry.db')
|
|
440
|
+
expect(log).toContain('RESTART')
|
|
441
|
+
expect(GATEWAY_SIGNATURES['orphaned-db-handle'].test(log)).toBe(true)
|
|
442
|
+
|
|
443
|
+
// Detection ONLY: the raw handle is left alone, so its consumers (the
|
|
444
|
+
// by-value `turnsDb` captures in gateway.ts) are never handed a closed DB.
|
|
445
|
+
expect(() =>
|
|
446
|
+
raw.prepare('INSERT INTO turns (id, note) VALUES (?, ?)').run(2, 'after'),
|
|
447
|
+
).not.toThrow()
|
|
448
|
+
} finally {
|
|
449
|
+
raw.close(false)
|
|
450
|
+
}
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
it('names an unowned state-dir DB instead of raising a lane-less data-loss alarm', async () => {
|
|
454
|
+
const grants = join(stateDir, 'grants.db')
|
|
455
|
+
const raw = new Database(grants, { create: true })
|
|
456
|
+
try {
|
|
457
|
+
raw.exec('PRAGMA journal_mode = WAL')
|
|
458
|
+
raw.exec('CREATE TABLE g (id INTEGER PRIMARY KEY)')
|
|
459
|
+
raw.prepare('INSERT INTO g (id) VALUES (?)').run(1)
|
|
460
|
+
unlinkSidecars(grants)
|
|
461
|
+
|
|
462
|
+
const lines: string[] = []
|
|
463
|
+
await runOrphanedDbSweepTick({
|
|
464
|
+
stateDir,
|
|
465
|
+
reopenHistory: () => { throw new Error('history lane must not run') },
|
|
466
|
+
log: (l) => lines.push(l),
|
|
467
|
+
})
|
|
468
|
+
const log = lines.join('')
|
|
469
|
+
expect(log).toContain('grants.db-wal')
|
|
470
|
+
expect(log).toContain('no recovery lane owns')
|
|
471
|
+
expect(log).toContain('RESTART')
|
|
472
|
+
// The history lane is wired but must NOT have fired for a foreign DB.
|
|
473
|
+
expect(log).not.toContain('history.db')
|
|
474
|
+
} finally {
|
|
475
|
+
raw.close(false)
|
|
476
|
+
}
|
|
477
|
+
})
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
describe.skipIf(!onLinux)('orphaned-db-sweep — reopen failure lanes', () => {
|
|
481
|
+
it('reports a failed reopen honestly instead of claiming recovery', async () => {
|
|
482
|
+
initHistory(stateDir, 30)
|
|
483
|
+
inbound(6001, 'row before the unlink')
|
|
484
|
+
checkpointWal()
|
|
485
|
+
unlinkSidecars(join(stateDir, 'history.db'))
|
|
486
|
+
|
|
487
|
+
const lines: string[] = []
|
|
488
|
+
await runOrphanedDbSweepTick({
|
|
489
|
+
stateDir,
|
|
490
|
+
reopenHistory: () => { throw new Error('boom') },
|
|
491
|
+
log: (l) => lines.push(l),
|
|
492
|
+
})
|
|
493
|
+
const log = lines.join('')
|
|
494
|
+
expect(log).toContain('FAILED to reopen history.db')
|
|
495
|
+
expect(log).toContain('boom')
|
|
496
|
+
expect(log).toContain('RESTART')
|
|
497
|
+
// The success line must be gated on the reopen actually succeeding.
|
|
498
|
+
expect(log).not.toContain('writes are durable again')
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
it('says a history reopen is not wired when history is disabled', async () => {
|
|
502
|
+
initHistory(stateDir, 30)
|
|
503
|
+
inbound(6101, 'row before the unlink')
|
|
504
|
+
checkpointWal()
|
|
505
|
+
unlinkSidecars(join(stateDir, 'history.db'))
|
|
506
|
+
|
|
507
|
+
const lines: string[] = []
|
|
508
|
+
await runOrphanedDbSweepTick({ stateDir, reopenHistory: undefined, log: (l) => lines.push(l) })
|
|
509
|
+
const log = lines.join('')
|
|
510
|
+
expect(log).toContain('no reopen')
|
|
511
|
+
expect(log).toContain('RESTART')
|
|
512
|
+
expect(log).not.toContain('writes are durable again')
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
it('keeps alarming on a sticky reopen failure after the fds are gone', async () => {
|
|
516
|
+
// The nastiest shape: the close SUCCEEDED (fds released) and the re-init
|
|
517
|
+
// FAILED. There is no fd evidence left, so a purely fd-driven sweep would
|
|
518
|
+
// report healthy forever while every history read and write is dead.
|
|
519
|
+
let stuck: string | null = 'disk full'
|
|
520
|
+
const lines: string[] = []
|
|
521
|
+
const opts = {
|
|
522
|
+
stateDir,
|
|
523
|
+
reopenHistory: () => { throw new Error('still broken') },
|
|
524
|
+
historyReopenFailure: () => stuck,
|
|
525
|
+
log: (l: string) => lines.push(l),
|
|
526
|
+
}
|
|
527
|
+
expect(await detectOrphanedDbFds(stateDir)).toEqual([])
|
|
528
|
+
|
|
529
|
+
await runOrphanedDbSweepTick(opts)
|
|
530
|
+
await runOrphanedDbSweepTick(opts)
|
|
531
|
+
const log = lines.join('')
|
|
532
|
+
expect(log.match(/history\.db is CLOSED/g)?.length).toBe(2)
|
|
533
|
+
expect(log).toContain('disk full')
|
|
534
|
+
|
|
535
|
+
// …and goes quiet the moment the flag clears, so it cannot become noise
|
|
536
|
+
// the operator learns to ignore.
|
|
537
|
+
stuck = null
|
|
538
|
+
lines.length = 0
|
|
539
|
+
await runOrphanedDbSweepTick(opts)
|
|
540
|
+
expect(lines).toEqual([])
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
it('recovers off the sticky flag with no orphaned fd to trigger it', async () => {
|
|
544
|
+
initHistory(stateDir, 30)
|
|
545
|
+
const lines: string[] = []
|
|
546
|
+
await runOrphanedDbSweepTick({
|
|
547
|
+
stateDir,
|
|
548
|
+
reopenHistory: () => reopenHistory(stateDir, 30),
|
|
549
|
+
historyReopenFailure: () => 'previous reopen failed',
|
|
550
|
+
log: (l) => lines.push(l),
|
|
551
|
+
})
|
|
552
|
+
const log = lines.join('')
|
|
553
|
+
expect(log).toContain('history.db is CLOSED')
|
|
554
|
+
expect(log).toContain('recovered history.db')
|
|
555
|
+
expect(log).toContain('writes are durable again')
|
|
556
|
+
expect(getHistoryReopenFailure()).toBeNull()
|
|
557
|
+
expect(verifyHistoryWritable().ok).toBe(true)
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
it('refuses to call a reopen successful when the reopened DB cannot take a write', async () => {
|
|
561
|
+
seedSelfCheckPoisonedDb()
|
|
562
|
+
initHistory(stateDir, 30) // warns about the self-check, returns normally
|
|
563
|
+
|
|
564
|
+
expect(() => reopenHistory(stateDir, 30)).toThrow('post-reopen writer self-check failed')
|
|
565
|
+
|
|
566
|
+
// …and the sweep repeats that honestly rather than announcing durability.
|
|
567
|
+
const lines: string[] = []
|
|
568
|
+
await runOrphanedDbSweepTick({
|
|
569
|
+
stateDir,
|
|
570
|
+
reopenHistory: () => reopenHistory(stateDir, 30),
|
|
571
|
+
historyReopenFailure: getHistoryReopenFailure,
|
|
572
|
+
log: (l) => lines.push(l),
|
|
573
|
+
})
|
|
574
|
+
const log = lines.join('')
|
|
575
|
+
expect(log).toContain('FAILED to reopen history.db')
|
|
576
|
+
expect(log).not.toContain('writes are durable again')
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
it('clears the sticky failure once a later reopen genuinely succeeds', async () => {
|
|
580
|
+
seedSelfCheckPoisonedDb()
|
|
581
|
+
initHistory(stateDir, 30)
|
|
582
|
+
expect(() => reopenHistory(stateDir, 30)).toThrow()
|
|
583
|
+
expect(getHistoryReopenFailure()).toContain('post-reopen writer self-check failed')
|
|
584
|
+
|
|
585
|
+
// Repair the underlying cause (drop the poisoned file; the still-open
|
|
586
|
+
// handle onto it becomes an orphan, which is the sweep's other trigger)
|
|
587
|
+
// and let a tick recover.
|
|
588
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
589
|
+
const f = join(stateDir, 'history.db' + suffix)
|
|
590
|
+
if (existsSync(f)) unlinkSync(f)
|
|
591
|
+
}
|
|
592
|
+
const lines: string[] = []
|
|
593
|
+
await runOrphanedDbSweepTick({
|
|
594
|
+
stateDir,
|
|
595
|
+
reopenHistory: () => reopenHistory(stateDir, 30),
|
|
596
|
+
historyReopenFailure: getHistoryReopenFailure,
|
|
597
|
+
log: (l) => lines.push(l),
|
|
598
|
+
})
|
|
599
|
+
expect(lines.join('')).toContain('writes are durable again')
|
|
600
|
+
|
|
601
|
+
// A flag that never clears turns the recovered state into a permanent
|
|
602
|
+
// alarm — the operator learns to ignore it, and the next real outage is
|
|
603
|
+
// invisible. The next tick must be silent.
|
|
604
|
+
expect(getHistoryReopenFailure()).toBeNull()
|
|
605
|
+
lines.length = 0
|
|
606
|
+
await runOrphanedDbSweepTick({
|
|
607
|
+
stateDir,
|
|
608
|
+
reopenHistory: () => reopenHistory(stateDir, 30),
|
|
609
|
+
historyReopenFailure: getHistoryReopenFailure,
|
|
610
|
+
log: (l) => lines.push(l),
|
|
611
|
+
})
|
|
612
|
+
expect(lines).toEqual([])
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
it('degrades reads to empty instead of throwing when history is dead', () => {
|
|
616
|
+
initHistory(stateDir, 30)
|
|
617
|
+
inbound(6201, 'row before the outage')
|
|
618
|
+
|
|
619
|
+
// Make the re-init genuinely impossible: replace the state DIR with a FILE
|
|
620
|
+
// so `initHistory`'s mkdir throws. The close still succeeds, so this is the
|
|
621
|
+
// real end-to-end shape of the sticky failure, not a stub.
|
|
622
|
+
rmSync(stateDir, { recursive: true, force: true })
|
|
623
|
+
writeFileSync(stateDir, 'not a directory')
|
|
624
|
+
|
|
625
|
+
expect(() => reopenHistory(stateDir, 30)).toThrow()
|
|
626
|
+
expect(getHistoryReopenFailure()).not.toBeNull()
|
|
627
|
+
|
|
628
|
+
// The read paths degrade instead of exploding through their callers.
|
|
629
|
+
expect(query({ chat_id: '900001', limit: 10 })).toEqual([])
|
|
630
|
+
expect(lookupMessageRoleAndText('900001', 6201)).toBeNull()
|
|
631
|
+
|
|
632
|
+
unlinkSync(stateDir)
|
|
633
|
+
})
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
describe('hardCloseDb', () => {
|
|
637
|
+
/**
|
|
638
|
+
* A `Database` double with bun's close semantics: `close(true)` throws when
|
|
639
|
+
* statements are still outstanding, `close()` (soft) never does. This is
|
|
640
|
+
* where the `true` argument is pinned — see the note in the file header for
|
|
641
|
+
* why the integration test cannot see it.
|
|
642
|
+
*/
|
|
643
|
+
function fakeHandle(busy: boolean) {
|
|
644
|
+
const calls: (boolean | undefined)[] = []
|
|
645
|
+
return {
|
|
646
|
+
calls,
|
|
647
|
+
close(throwOnError?: boolean) {
|
|
648
|
+
calls.push(throwOnError)
|
|
649
|
+
if (throwOnError === true && busy) throw new Error('database is locked')
|
|
650
|
+
},
|
|
651
|
+
prepare() { throw new Error('unused') },
|
|
652
|
+
exec() { throw new Error('unused') },
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
it('closes with throwOnError so a stuck handle cannot be reported as recovered', () => {
|
|
657
|
+
const handle = fakeHandle(false)
|
|
658
|
+
hardCloseDb(handle as never)
|
|
659
|
+
// `close()` / `close(false)` would silently leave a busy handle behind and
|
|
660
|
+
// let the caller log "writes are durable again".
|
|
661
|
+
expect(handle.calls).toEqual([true])
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
it('propagates a close failure rather than swallowing it', () => {
|
|
665
|
+
const handle = fakeHandle(true)
|
|
666
|
+
expect(() => hardCloseDb(handle as never)).toThrow('database is locked')
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
it('finalizes every cached statement before closing', () => {
|
|
670
|
+
// The real determinism proof: after a hard close, re-opening and writing
|
|
671
|
+
// must work, which requires the cache to have been dropped along with the
|
|
672
|
+
// finalized statements. A cache that survived the close would hand the new
|
|
673
|
+
// connection statements bound to the old one.
|
|
674
|
+
initHistory(stateDir, 30)
|
|
675
|
+
inbound(7001, 'cached statement holder')
|
|
676
|
+
expect(verifyHistoryWritable().ok).toBe(true)
|
|
677
|
+
expect(() => reopenHistory(stateDir, 30)).not.toThrow()
|
|
678
|
+
expect(verifyHistoryWritable().ok).toBe(true)
|
|
679
|
+
expect(query({ chat_id: '900001', limit: 10 }).map((m) => m.message_id)).toEqual([7001])
|
|
680
|
+
})
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
describe('startOrphanedDbSweep', () => {
|
|
684
|
+
it('stops ticking after stop() is called', async () => {
|
|
685
|
+
// A sticky failure with no reopen wired makes every tick emit exactly one
|
|
686
|
+
// line, so the log line count IS the tick count — an observable the
|
|
687
|
+
// interval must actually drive.
|
|
688
|
+
const lines: string[] = []
|
|
689
|
+
const stop = startOrphanedDbSweep({
|
|
690
|
+
stateDir,
|
|
691
|
+
historyReopenFailure: () => 'stuck',
|
|
692
|
+
log: (l) => lines.push(l),
|
|
693
|
+
intervalMs: 5,
|
|
694
|
+
})
|
|
695
|
+
// Poll to a generous deadline rather than sampling a fixed window: under a
|
|
696
|
+
// loaded CI runner a 5ms interval can be starved, and a flaky assertion on
|
|
697
|
+
// a real property is worse than a slow one.
|
|
698
|
+
const deadline = Date.now() + 5_000
|
|
699
|
+
while (lines.length < 3 && Date.now() < deadline) await sleep(5)
|
|
700
|
+
// Multiple ticks: proves the interval repeats, not just fires once.
|
|
701
|
+
expect(lines.length).toBeGreaterThan(1)
|
|
702
|
+
|
|
703
|
+
stop()
|
|
704
|
+
// A tick is async, so one may be in flight when stop() lands. Let it settle
|
|
705
|
+
// before snapshotting — the property under test is "no NEW ticks start",
|
|
706
|
+
// not "no line is ever appended after the stop() call returns".
|
|
707
|
+
await sleep(60)
|
|
708
|
+
const atStop = lines.length
|
|
709
|
+
await sleep(250)
|
|
710
|
+
// A stop() that did not clear the interval would add ~50 more lines here.
|
|
711
|
+
expect(lines.length).toBe(atStop)
|
|
712
|
+
})
|
|
713
|
+
})
|