@aryasaatvik/motel 0.3.4 → 0.4.0

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.
@@ -27,12 +27,12 @@ export class IngestError extends Schema.TaggedError<IngestError>()("IngestError"
27
27
 
28
28
  export const IngestRpcs = RpcGroup.make(
29
29
  Rpc.make("ingestTraces", {
30
- payload: { payload: Schema.Unknown },
30
+ payload: { payload: Schema.Unknown, bytes: Schema.optionalKey(Schema.Number) },
31
31
  success: Schema.Struct({ insertedSpans: Schema.Number }),
32
32
  error: IngestError,
33
33
  }),
34
34
  Rpc.make("ingestLogs", {
35
- payload: { payload: Schema.Unknown },
35
+ payload: { payload: Schema.Unknown, bytes: Schema.optionalKey(Schema.Number) },
36
36
  success: Schema.Struct({ insertedLogs: Schema.Number }),
37
37
  error: IngestError,
38
38
  }),
@@ -1,15 +1,15 @@
1
1
  import { Schema } from "effect"
2
- import * as Rpc from "effect/unstable/rpc/Rpc"
3
- import * as RpcGroup from "effect/unstable/rpc/RpcGroup"
4
2
 
5
- export class QueryError extends Schema.TaggedError<QueryError>()("QueryError", {
6
- message: Schema.String,
7
- }) {}
3
+ export class QueryError extends Schema.TaggedError<QueryError>()("QueryError", { message: Schema.String }) {}
4
+ export class QueryOverloaded extends Schema.TaggedError<QueryOverloaded>()("QueryOverloaded", { message: Schema.String }) {}
5
+ export class QueryDeadlineExceeded extends Schema.TaggedError<QueryDeadlineExceeded>()("QueryDeadlineExceeded", { message: Schema.String }) {}
6
+ export class QueryUnavailable extends Schema.TaggedError<QueryUnavailable>()("QueryUnavailable", { message: Schema.String }) {}
8
7
 
9
- export const QueryRpcs = RpcGroup.make(
10
- Rpc.make("query", {
11
- payload: { method: Schema.String, args: Schema.Array(Schema.Unknown) },
12
- success: Schema.Any,
13
- error: QueryError,
14
- }),
15
- )
8
+ export const QueryRequest = Schema.Struct({ id: Schema.Int, method: Schema.String, args: Schema.Array(Schema.Unknown) })
9
+ export const QueryReply = Schema.TaggedUnion({
10
+ ready: {},
11
+ result: { id: Schema.Int, value: Schema.Unknown },
12
+ error: { id: Schema.Int, message: Schema.String },
13
+ })
14
+ export type QueryRequest = typeof QueryRequest.Type
15
+ export type QueryReply = typeof QueryReply.Type
@@ -0,0 +1,102 @@
1
+ import { Database } from "bun:sqlite"
2
+ import { expect, test } from "bun:test"
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
4
+ import { tmpdir } from "node:os"
5
+ import { join } from "node:path"
6
+ import { pathToFileURL } from "node:url"
7
+ import { QueryScheduler } from "./queryScheduler.ts"
8
+ import { QueryDeadlineExceeded, QueryOverloaded, QueryUnavailable } from "./queryRpc.ts"
9
+ import { checkpointPassive } from "./retention.ts"
10
+
11
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
12
+ const fixture = (capacity = 2, deadlineMs = 800) => {
13
+ const root = mkdtempSync(join(tmpdir(), "motel-query-cancel-"))
14
+ const path = join(root, "db.sqlite")
15
+ const events = join(root, "events")
16
+ writeFileSync(events, "")
17
+ const db = new Database(path)
18
+ db.exec("PRAGMA journal_mode=WAL; CREATE TABLE t(x); INSERT INTO t VALUES(1); PRAGMA wal_checkpoint(PASSIVE)")
19
+ const worker = join(root, "readonly worker.ts")
20
+ writeFileSync(worker, `
21
+ import { Database } from "bun:sqlite"
22
+ import { appendFileSync } from "node:fs"
23
+ const db = new Database(${JSON.stringify(path)}, { readonly: true })
24
+ process.on("message", request => {
25
+ appendFileSync(${JSON.stringify(events)}, request.method + "\\n")
26
+ if (request.method === "slow") {
27
+ db.exec("BEGIN")
28
+ db.query("SELECT * FROM t").all()
29
+ appendFileSync(${JSON.stringify(events)}, "reader-held\\n")
30
+ db.query("WITH RECURSIVE n(x) AS (VALUES(0) UNION ALL SELECT x+1 FROM n WHERE x<1000000000) SELECT sum(x) FROM n").get()
31
+ }
32
+ process.send({_tag:"result", id:request.id, value:db.query("SELECT count(*) AS n FROM t").get()})
33
+ })
34
+ process.send({_tag:"ready"})
35
+ `)
36
+ const scheduler = new QueryScheduler({ workerUrl: pathToFileURL(worker), capacity, deadlineMs })
37
+ const held = async () => {
38
+ for (let i = 0; i < 100 && !readFileSync(events, "utf8").includes("reader-held"); i++) await sleep(5)
39
+ expect(readFileSync(events, "utf8")).toContain("reader-held")
40
+ }
41
+ return { scheduler, db, held, events: () => readFileSync(events, "utf8"), close: async () => { await scheduler.close(); db.close(); rmSync(root, { recursive: true, force: true }) } }
42
+ }
43
+
44
+ test("deadline stops executing SQLite, releases its reader, and allows the next useful query", async () => {
45
+ const f = fixture()
46
+ try {
47
+ const slow = f.scheduler.query("slow", [], new AbortController().signal).catch((error) => error)
48
+ await f.held()
49
+ f.db.exec("INSERT INTO t VALUES(2)")
50
+ expect(checkpointPassive(f.db).deferred).toBe(true)
51
+ await sleep(200)
52
+ const next = f.scheduler.query("short", [], new AbortController().signal)
53
+ await expect(f.scheduler.query("overflow", [], new AbortController().signal)).rejects.toBeInstanceOf(QueryOverloaded)
54
+ expect(await slow).toBeInstanceOf(QueryDeadlineExceeded)
55
+ expect(checkpointPassive(f.db).deferred).toBe(false)
56
+ expect(await next).toEqual({ n: 2 })
57
+ expect(f.events()).not.toContain("overflow")
58
+ } finally { await f.close() }
59
+ }, 5000)
60
+
61
+ test("cancelling queued work prevents dispatch without interrupting the current reader", async () => {
62
+ const f = fixture()
63
+ try {
64
+ const active = new AbortController()
65
+ const slow = f.scheduler.query("slow", [], active.signal).catch((error) => error)
66
+ await f.held()
67
+ const queued = new AbortController()
68
+ const obsolete = f.scheduler.query("obsolete", [], queued.signal).catch((error) => error)
69
+ queued.abort()
70
+ expect(await obsolete).toBeInstanceOf(QueryUnavailable)
71
+ f.db.exec("INSERT INTO t VALUES(2)")
72
+ expect(checkpointPassive(f.db).deferred).toBe(true)
73
+ active.abort()
74
+ expect(await slow).toBeInstanceOf(QueryUnavailable)
75
+ expect(checkpointPassive(f.db).deferred).toBe(false)
76
+ expect(await f.scheduler.query("short", [], new AbortController().signal)).toEqual({ n: 2 })
77
+ expect(f.events()).not.toContain("obsolete")
78
+ } finally { await f.close() }
79
+ }, 5000)
80
+
81
+ test("expired queued requests are removed before a replacement process can dispatch them", async () => {
82
+ const f = fixture(4, 400)
83
+ try {
84
+ const queries = ["slow", "expired-one", "expired-two", "expired-three"].map((method) => f.scheduler.query(method, [], new AbortController().signal).catch((error) => error))
85
+ const results = await Promise.all(queries)
86
+ for (const result of results) expect(result).toBeInstanceOf(QueryDeadlineExceeded)
87
+ expect(f.events()).not.toContain("expired-")
88
+ expect(await f.scheduler.query("short", [], new AbortController().signal)).toEqual({ n: 1 })
89
+ } finally { await f.close() }
90
+ }, 5000)
91
+
92
+
93
+ test("query processes receive the explicit runtime environment", async () => {
94
+ const root = mkdtempSync(join(tmpdir(), "motel-query-env-"))
95
+ const worker = join(root, "environment.ts")
96
+ writeFileSync(worker, `process.on("message", request => process.send({_tag:"result", id:request.id, value:process.env.MOTEL_OTEL_DB_PATH})); process.send({_tag:"ready"})`)
97
+ const databasePath = join(root, "isolated.sqlite")
98
+ const scheduler = new QueryScheduler({ workerUrl: pathToFileURL(worker), capacity: 1, deadlineMs: 2000, environment: { ...process.env, MOTEL_OTEL_DB_PATH: databasePath } })
99
+ try {
100
+ expect(await scheduler.query("path", [], new AbortController().signal)).toBe(databasePath)
101
+ } finally { await scheduler.close(); rmSync(root, { recursive: true, force: true }) }
102
+ })
@@ -0,0 +1,157 @@
1
+ import { fileURLToPath } from "node:url"
2
+ import { Schema } from "effect"
3
+ import { QueryDeadlineExceeded, QueryError, QueryOverloaded, QueryReply, QueryUnavailable, type QueryRequest } from "./queryRpc.js"
4
+
5
+ type Pending = {
6
+ readonly request: QueryRequest
7
+ readonly resolve: (value: unknown) => void
8
+ readonly reject: (error: Error) => void
9
+ readonly signal: AbortSignal
10
+ timer?: ReturnType<typeof setTimeout>
11
+ onAbort?: () => void
12
+ cancelled?: Error
13
+ generation?: QueryProcess
14
+ }
15
+
16
+ /** One readonly process. SIGKILL is required to interrupt synchronous SQLite native code. */
17
+ class QueryProcess {
18
+ readonly child: ReturnType<typeof Bun.spawn>
19
+ readonly ready: Promise<void>
20
+ closed = false
21
+ private stopping?: Promise<void>
22
+ private call?: { id: number; resolve: (value: unknown) => void; reject: (error: Error) => void }
23
+
24
+ constructor(workerUrl: URL, environment: NodeJS.ProcessEnv) {
25
+ const ready = Promise.withResolvers<void>()
26
+ this.ready = ready.promise
27
+ // A process may exit between requests; keep its bootstrap rejection observed.
28
+ void this.ready.catch(() => {})
29
+ this.child = Bun.spawn([process.execPath, fileURLToPath(workerUrl)], {
30
+ stdin: "ignore", stdout: "ignore", stderr: "inherit",
31
+ env: environment,
32
+ serialization: "advanced",
33
+ ipc: (data: unknown) => {
34
+ let reply: QueryReply
35
+ try { reply = Schema.decodeUnknownSync(QueryReply)(data) }
36
+ catch {
37
+ ready.reject(new QueryUnavailable({ message: "Invalid reply from query process" }))
38
+ void this.stop()
39
+ return
40
+ }
41
+ if (this.closed) return
42
+ if (reply._tag === "ready") { ready.resolve(); return }
43
+ if (this.call?.id !== reply.id) return
44
+ const call = this.call
45
+ this.call = undefined
46
+ if (reply._tag === "result") call.resolve(reply.value)
47
+ else call.reject(new QueryError({ message: reply.message }))
48
+ },
49
+ onExit: () => {
50
+ this.closed = true
51
+ const error = new QueryUnavailable({ message: "Readonly query process exited" })
52
+ ready.reject(error)
53
+ this.call?.reject(error)
54
+ this.call = undefined
55
+ },
56
+ })
57
+ }
58
+
59
+ request(request: QueryRequest) {
60
+ return new Promise<unknown>((resolve, reject) => {
61
+ if (this.closed) { reject(new QueryUnavailable({ message: "Readonly query process is closed" })); return }
62
+ this.call = { id: request.id, resolve, reject }
63
+ try { this.child.send(request) }
64
+ catch (error) { this.call = undefined; reject(new QueryUnavailable({ message: String(error) })) }
65
+ })
66
+ }
67
+
68
+ stop(): Promise<void> {
69
+ return this.stopping ??= (async () => {
70
+ this.closed = true
71
+ this.child.kill("SIGKILL")
72
+ await this.child.exited
73
+ })()
74
+ }
75
+ }
76
+
77
+ /** FIFO admission includes startup and queue time in each request's deadline. */
78
+ export class QueryScheduler {
79
+ private readonly queued: Pending[] = []
80
+ private readonly outstanding = new Set<Pending>()
81
+ private active?: Pending
82
+ private generation?: QueryProcess
83
+ private nextId = 0
84
+ private closed = false
85
+
86
+ constructor(private readonly options: { workerUrl: URL; capacity: number; deadlineMs: number; environment?: NodeJS.ProcessEnv }) {}
87
+
88
+ query(method: string, args: readonly unknown[], signal: AbortSignal): Promise<unknown> {
89
+ if (this.closed) return Promise.reject(new QueryUnavailable({ message: "Query scheduler is closed" }))
90
+ if (signal.aborted) return Promise.reject(new QueryUnavailable({ message: "Query cancelled before admission" }))
91
+ if (this.outstanding.size >= this.options.capacity) return Promise.reject(new QueryOverloaded({ message: "Readonly query capacity is exhausted; retry later" }))
92
+ return new Promise((resolve, reject) => {
93
+ const entry: Pending = { request: { id: ++this.nextId, method, args }, resolve, reject, signal }
94
+ entry.onAbort = () => this.cancel(entry, new QueryUnavailable({ message: "Query cancelled by caller" }))
95
+ entry.timer = setTimeout(() => this.cancel(entry, new QueryDeadlineExceeded({ message: `Query exceeded its ${this.options.deadlineMs} ms budget` })), this.options.deadlineMs)
96
+ signal.addEventListener("abort", entry.onAbort, { once: true })
97
+ this.outstanding.add(entry)
98
+ this.queued.push(entry)
99
+ this.pump()
100
+ })
101
+ }
102
+
103
+ private finish(entry: Pending) {
104
+ clearTimeout(entry.timer)
105
+ if (entry.onAbort) entry.signal.removeEventListener("abort", entry.onAbort)
106
+ this.outstanding.delete(entry)
107
+ }
108
+
109
+ private cancel(entry: Pending, error: Error) {
110
+ if (!this.outstanding.has(entry) || entry.cancelled) return
111
+ entry.cancelled = error
112
+ if (this.active === entry) {
113
+ // Do not release admission or report a deadline until the OS has closed the
114
+ // process, including its SQLite connection and any retained WAL snapshot.
115
+ void entry.generation?.stop()
116
+ } else {
117
+ const index = this.queued.indexOf(entry)
118
+ if (index !== -1) this.queued.splice(index, 1)
119
+ this.finish(entry)
120
+ entry.reject(error)
121
+ }
122
+ }
123
+
124
+ private pump() {
125
+ if (this.active || this.closed) return
126
+ const entry = this.queued.shift()
127
+ if (!entry) return
128
+ this.active = entry
129
+ void this.execute(entry)
130
+ }
131
+
132
+ private async execute(entry: Pending) {
133
+ try {
134
+ const generation = this.generation && !this.generation.closed ? this.generation : new QueryProcess(this.options.workerUrl, this.options.environment ?? { ...process.env })
135
+ this.generation = generation
136
+ entry.generation = generation
137
+ await generation.ready
138
+ if (entry.cancelled) throw entry.cancelled
139
+ const value = await generation.request(entry.request)
140
+ if (entry.cancelled) throw entry.cancelled
141
+ entry.resolve(value)
142
+ } catch (error) {
143
+ if (entry.cancelled) await entry.generation?.stop()
144
+ entry.reject(entry.cancelled ?? (error instanceof Error ? error : new QueryUnavailable({ message: String(error) })))
145
+ } finally {
146
+ this.finish(entry)
147
+ this.active = undefined
148
+ this.pump()
149
+ }
150
+ }
151
+
152
+ async close() {
153
+ this.closed = true
154
+ for (const entry of [...this.outstanding]) this.cancel(entry, new QueryUnavailable({ message: "Query scheduler is closing" }))
155
+ await this.generation?.stop()
156
+ }
157
+ }
@@ -0,0 +1,170 @@
1
+ import { Database } from "bun:sqlite"
2
+ import { expect, test } from "bun:test"
3
+ import { mkdtempSync, rmSync } from "node:fs"
4
+ import { tmpdir } from "node:os"
5
+ import { join } from "node:path"
6
+ import { checkpointPassive, installRetentionSchema, mergeFts, repairSearchRows, retainBatch } from "./retention.ts"
7
+
8
+ const schema = (db: Database) => {
9
+ db.exec(`PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 15000;
10
+ CREATE TABLE spans(trace_id TEXT, span_id TEXT, start_time_ms INTEGER DEFAULT 1, end_time_ms INTEGER DEFAULT 2, PRIMARY KEY(trace_id, span_id));
11
+ CREATE TABLE span_attributes(trace_id TEXT, span_id TEXT, key TEXT, value TEXT);
12
+ CREATE INDEX attrs_trace ON span_attributes(trace_id);
13
+ CREATE TABLE trace_summaries(trace_id TEXT PRIMARY KEY, started_at_ms INTEGER, ended_at_ms INTEGER, active_span_count INTEGER);
14
+ CREATE TABLE logs(id INTEGER PRIMARY KEY, trace_id TEXT, timestamp_ms INTEGER, severity_text TEXT);
15
+ CREATE INDEX logs_trace ON logs(trace_id);
16
+ CREATE TABLE log_attributes(log_id INTEGER, key TEXT, value TEXT);
17
+ CREATE INDEX attrs_log ON log_attributes(log_id);
18
+ CREATE TABLE motel_maintenance(key TEXT PRIMARY KEY, value TEXT);
19
+ CREATE VIRTUAL TABLE span_operation_fts USING fts5(trace_id UNINDEXED, span_id UNINDEXED, operation_name);
20
+ CREATE VIRTUAL TABLE log_body_fts USING fts5(log_id UNINDEXED, body);
21
+ `)
22
+ installRetentionSchema(db)
23
+ }
24
+ const options = { cutoff: 100, maxBytes: Number.MAX_SAFE_INTEGER, traces: 10, logs: 10, rows: 24 }
25
+ const count = (db: Database, table: string) => (db.query(`SELECT count(*) AS n FROM ${table}`).get() as { n: number }).n
26
+
27
+ test("huge traces disappear atomically, drain by rows, survive reopen, and preserve active traces", () => {
28
+ const root = mkdtempSync(join(tmpdir(), "motel-retention-"))
29
+ const path = join(root, "test.sqlite")
30
+ let db = new Database(path)
31
+ try {
32
+ schema(db)
33
+ db.exec("INSERT INTO trace_summaries VALUES ('large', 1, 2, 0), ('active', 1, 0, 1), ('boundary', 1, 100, 0)")
34
+ db.transaction(() => {
35
+ for (let index = 0; index < 200; index++) {
36
+ db.query("INSERT INTO spans(trace_id, span_id) VALUES ('large', ?)").run(String(index))
37
+ for (let attr = 0; attr < 4; attr++) db.query("INSERT INTO span_attributes VALUES ('large', ?, ?, 'value')").run(String(index), String(attr))
38
+ }
39
+ db.exec("INSERT INTO spans(trace_id, span_id) VALUES ('active','a'), ('boundary','b'); INSERT INTO logs VALUES (1, 'large', 1000, 'INFO'); INSERT INTO log_attributes VALUES (1,'key','value')")
40
+ })()
41
+ const result = retainBatch(db, options)
42
+ expect(result.rows).toBeLessThanOrEqual(options.rows)
43
+ expect(count(db, "spans")).toBe(202)
44
+ expect(count(db, "retained_spans")).toBe(2)
45
+ expect(count(db, "retained_trace_summaries")).toBe(2)
46
+ expect(count(db, "retained_logs")).toBe(0)
47
+ db.close()
48
+ db = new Database(path)
49
+ expect(count(db, "retention_traces")).toBe(1)
50
+ let passes = 0
51
+ while (count(db, "retention_traces") > 0 && passes++ < 200) {
52
+ expect(retainBatch(db, options).rows).toBeLessThanOrEqual(options.rows)
53
+ expect(count(db, "retained_spans")).toBe(2)
54
+ }
55
+ expect(passes).toBeLessThan(200)
56
+ expect(count(db, "spans")).toBe(2)
57
+ expect(count(db, "span_attributes")).toBe(0)
58
+ expect(count(db, "logs")).toBe(0)
59
+ expect(count(db, "log_attributes")).toBe(0)
60
+ } finally { db.close(); rmSync(root, { recursive: true, force: true }) }
61
+ })
62
+
63
+ test("failed cleanup rolls back its visibility marker and rows", () => {
64
+ const db = new Database(":memory:")
65
+ try {
66
+ schema(db)
67
+ db.exec(`INSERT INTO trace_summaries VALUES ('trace',1,2,0); INSERT INTO spans(trace_id, span_id) VALUES ('trace','span');
68
+ INSERT INTO span_attributes VALUES ('trace','span','key','value');
69
+ CREATE TRIGGER fail_delete BEFORE DELETE ON span_attributes BEGIN SELECT RAISE(ABORT, 'injected failure'); END;`)
70
+ expect(() => retainBatch(db, options)).toThrow("injected failure")
71
+ expect(count(db, "retention_traces")).toBe(0)
72
+ expect(count(db, "retained_spans")).toBe(1)
73
+ expect(count(db, "span_attributes")).toBe(1)
74
+ db.exec("DROP TRIGGER fail_delete")
75
+ retainBatch(db, options)
76
+ expect(count(db, "retained_spans")).toBe(0)
77
+ } finally { db.close() }
78
+ })
79
+
80
+ test("PASSIVE reports an incomplete checkpoint without waiting for a held reader", () => {
81
+ const root = mkdtempSync(join(tmpdir(), "motel-checkpoint-"))
82
+ const path = join(root, "test.sqlite")
83
+ const writer = new Database(path)
84
+ let reader: Database | undefined
85
+ try {
86
+ schema(writer)
87
+ checkpointPassive(writer)
88
+ reader = new Database(path, { readonly: true })
89
+ reader.exec("BEGIN")
90
+ reader.query("SELECT * FROM spans").all()
91
+ writer.exec("INSERT INTO spans(trace_id, span_id) VALUES ('trace','span')")
92
+ const start = performance.now()
93
+ const result = checkpointPassive(writer)
94
+ expect(performance.now() - start).toBeLessThan(500)
95
+ expect(result.deferred).toBe(true)
96
+ expect(result.log).toBeGreaterThan(result.checkpointed)
97
+ reader.exec("ROLLBACK")
98
+ expect(checkpointPassive(writer).deferred).toBe(false)
99
+ } finally { reader?.close(); writer.close(); rmSync(root, { recursive: true, force: true }) }
100
+ })
101
+
102
+ test("explicit FTS merging consolidates segments and preserves search results", () => {
103
+ const db = new Database(":memory:")
104
+ try {
105
+ schema(db)
106
+ db.exec("INSERT INTO log_body_fts(log_body_fts, rank) VALUES ('automerge', 0); INSERT INTO log_body_fts(log_body_fts, rank) VALUES ('crisismerge', 1000)")
107
+ for (let index = 0; index < 40; index++) db.query("INSERT INTO log_body_fts(log_id, body) VALUES (?, 'searchable token')").run(String(index))
108
+ const before = count(db, "log_body_fts_idx")
109
+ mergeFts(db, 100)
110
+ expect(count(db, "log_body_fts_idx")).toBeLessThan(before)
111
+ expect((db.query("SELECT count(*) AS n FROM log_body_fts WHERE log_body_fts MATCH 'searchable'").get() as { n: number }).n).toBe(40)
112
+ db.exec("DROP TABLE log_body_fts; CREATE TABLE log_body_fts(broken TEXT)")
113
+ expect(() => mergeFts(db, 100)).toThrow()
114
+ } finally { db.close() }
115
+ })
116
+
117
+ test("legacy orphan repair advances a bounded cursor and maps live FTS rowids", () => {
118
+ const db = new Database(":memory:")
119
+ try {
120
+ schema(db)
121
+ for (let index = 1; index <= 20; index++) {
122
+ db.query("INSERT INTO logs VALUES (?,NULL,1000,'INFO')").run(index)
123
+ db.query("INSERT INTO log_attributes VALUES (?,'key','value')").run(index)
124
+ db.query("INSERT INTO log_body_fts(log_id,body) VALUES (?,'searchable')").run(String(index))
125
+ }
126
+ db.exec("INSERT INTO log_attributes VALUES (999,'orphan','value'); INSERT INTO log_body_fts(log_id,body) VALUES ('999','orphan')")
127
+ repairSearchRows(db, 5)
128
+ expect(count(db, "log_search_rows")).toBe(5)
129
+ expect(count(db, "log_attributes")).toBe(21)
130
+ for (let pass = 0; pass < 5; pass++) repairSearchRows(db, 5)
131
+ expect(count(db, "log_attributes")).toBe(20)
132
+ expect(count(db, "log_body_fts")).toBe(20)
133
+ expect(count(db, "log_search_rows")).toBe(20)
134
+ const before = db.query("SELECT total_changes() AS n").get() as { n: number }
135
+ for (let pass = 0; pass < 5; pass++) repairSearchRows(db, 5)
136
+ expect(db.query("SELECT total_changes() AS n").get()).toEqual(before)
137
+ const expired = retainBatch(db, { ...options, cutoff: 2000, logs: 1, rows: 100 })
138
+ expect(expired.markedLogs).toBe(1)
139
+ expect(count(db, "logs")).toBe(19)
140
+ expect(count(db, "log_body_fts")).toBe(19)
141
+ } finally { db.close() }
142
+ })
143
+
144
+ test("retention selectors use ordered partial indexes", () => {
145
+ const db = new Database(":memory:")
146
+ try {
147
+ schema(db)
148
+ const plan = db.query("EXPLAIN QUERY PLAN SELECT trace_id FROM trace_summaries WHERE active_span_count = 0 AND ended_at_ms > 0 AND ended_at_ms < ? ORDER BY ended_at_ms, trace_id LIMIT 100").all(100) as { detail: string }[]
149
+ expect(plan.some(({ detail }) => detail.includes("idx_retention_ended"))).toBe(true)
150
+ expect(plan.some(({ detail }) => detail.includes("TEMP B-TREE"))).toBe(false)
151
+ } finally { db.close() }
152
+ })
153
+
154
+
155
+ test("stale upgraded summaries cannot evict active spans under age or size retention", () => {
156
+ for (const maxBytes of [Number.MAX_SAFE_INTEGER, 0]) {
157
+ const db = new Database(":memory:")
158
+ try {
159
+ schema(db)
160
+ db.exec("INSERT INTO trace_summaries VALUES ('stale', 1, 2, 0)")
161
+ db.exec("INSERT INTO spans(trace_id,span_id,start_time_ms,end_time_ms) VALUES ('stale','running',1,0)")
162
+ retainBatch(db, { ...options, maxBytes })
163
+ expect(count(db, "retained_spans")).toBe(1)
164
+ expect(count(db, "retention_traces")).toBe(0)
165
+ db.exec("UPDATE spans SET end_time_ms = 2")
166
+ retainBatch(db, { ...options, maxBytes })
167
+ expect(count(db, "retained_spans")).toBe(0)
168
+ } finally { db.close() }
169
+ }
170
+ })
@@ -0,0 +1,158 @@
1
+ import type { Database } from "bun:sqlite"
2
+
3
+ /** Additive schema: unfinished eviction is hidden from every public query. */
4
+ export const installRetentionSchema = (db: Database) => {
5
+ db.exec(`
6
+ CREATE INDEX IF NOT EXISTS idx_retention_active_spans ON spans(trace_id) WHERE end_time_ms <= 0 OR end_time_ms < start_time_ms;
7
+ CREATE INDEX IF NOT EXISTS idx_retention_ended ON trace_summaries(ended_at_ms, trace_id) WHERE active_span_count = 0 AND ended_at_ms > 0;
8
+ CREATE INDEX IF NOT EXISTS idx_retention_started ON trace_summaries(started_at_ms, trace_id) WHERE active_span_count = 0;
9
+ CREATE INDEX IF NOT EXISTS idx_logs_severity_nocase_cursor ON logs(severity_text COLLATE NOCASE, timestamp_ms DESC, id DESC);
10
+ CREATE INDEX IF NOT EXISTS idx_logs_timestamp ON logs(timestamp_ms, id);
11
+ CREATE TABLE IF NOT EXISTS retention_traces (trace_id TEXT PRIMARY KEY);
12
+ CREATE TABLE IF NOT EXISTS retention_logs (id INTEGER PRIMARY KEY);
13
+ CREATE TABLE IF NOT EXISTS span_search_rows (id INTEGER PRIMARY KEY, trace_id TEXT NOT NULL, span_id TEXT NOT NULL);
14
+ CREATE INDEX IF NOT EXISTS idx_span_search_identity ON span_search_rows(trace_id, span_id);
15
+ CREATE TABLE IF NOT EXISTS log_search_rows (id INTEGER PRIMARY KEY, log_id INTEGER NOT NULL);
16
+ CREATE INDEX IF NOT EXISTS idx_log_search_identity ON log_search_rows(log_id);
17
+ CREATE VIEW IF NOT EXISTS retained_spans AS SELECT spans.rowid AS rowid, spans.* FROM spans
18
+ WHERE NOT EXISTS (SELECT 1 FROM retention_traces d WHERE d.trace_id = spans.trace_id);
19
+ CREATE VIEW IF NOT EXISTS retained_trace_summaries AS SELECT * FROM trace_summaries
20
+ WHERE NOT EXISTS (SELECT 1 FROM retention_traces d WHERE d.trace_id = trace_summaries.trace_id);
21
+ CREATE VIEW IF NOT EXISTS retained_logs AS SELECT * FROM logs
22
+ WHERE NOT EXISTS (SELECT 1 FROM retention_traces d WHERE d.trace_id = logs.trace_id)
23
+ AND NOT EXISTS (SELECT 1 FROM retention_logs d WHERE d.id = logs.id);
24
+ `)
25
+ }
26
+
27
+ export type Checkpoint = { busy: number; log: number; checkpointed: number; deferred: boolean }
28
+
29
+ /** PASSIVE never waits for a reader to release its WAL snapshot. */
30
+ export const checkpointPassive = (db: Database): Checkpoint => {
31
+ const result = db.query("PRAGMA wal_checkpoint(PASSIVE)").get() as { busy: number; log: number; checkpointed: number }
32
+ return { ...result, deferred: result.busy !== 0 || result.checkpointed < result.log }
33
+ }
34
+
35
+ /** FTS table presence is checked explicitly; genuine SQLite errors propagate. */
36
+ export const mergeFts = (db: Database, pages: number) => {
37
+ for (const name of ["span_attr_fts", "log_body_fts", "span_operation_fts"]) {
38
+ if (db.query("SELECT 1 FROM sqlite_master WHERE name = ? AND type = 'table'").get(name)) {
39
+ db.query(`INSERT INTO ${name}(${name}, rank) VALUES ('merge', ?)`).run(pages)
40
+ }
41
+ }
42
+ }
43
+
44
+ type RetentionOptions = { cutoff: number; maxBytes: number; traces: number; logs: number; rows: number }
45
+
46
+ /**
47
+ * Marks whole traces/logs atomically, then drains bounded dependent-row batches.
48
+ * Row budgets bound cardinality, not bytes inside a single telemetry field. Markers survive
49
+ * crashes; writers discard arrivals for marked traces until their eviction finishes.
50
+ */
51
+ export const retainBatch = (db: Database, options: RetentionOptions) => db.transaction(() => {
52
+ const pageCount = (db.query("PRAGMA page_count").get() as { page_count: number }).page_count
53
+ const free = (db.query("PRAGMA freelist_count").get() as { freelist_count: number }).freelist_count
54
+ const pageSize = (db.query("PRAGMA page_size").get() as { page_size: number }).page_size
55
+ const oversized = (pageCount - free) * pageSize > options.maxBytes
56
+ const pending = (db.query("SELECT count(*) AS n FROM retention_traces").get() as { n: number }).n
57
+ const slots = Math.max(0, options.traces - pending)
58
+ // Upgrade-era summaries may undercount active spans. The partial index verifies
59
+ // actual span state before either age- or size-based eviction can select a trace.
60
+ if (slots > 0) {
61
+ db.query(`INSERT OR IGNORE INTO retention_traces SELECT trace_id FROM trace_summaries
62
+ WHERE active_span_count = 0 AND ended_at_ms > 0 AND ended_at_ms < ?
63
+ AND NOT EXISTS (SELECT 1 FROM retention_traces d WHERE d.trace_id = trace_summaries.trace_id)
64
+ AND NOT EXISTS (SELECT 1 FROM spans s WHERE s.trace_id = trace_summaries.trace_id AND (s.end_time_ms <= 0 OR s.end_time_ms < s.start_time_ms))
65
+ ORDER BY ended_at_ms, trace_id LIMIT ?`).run(options.cutoff, slots)
66
+ if (oversized) {
67
+ const remaining = Math.max(0, options.traces - (db.query("SELECT count(*) AS n FROM retention_traces").get() as { n: number }).n)
68
+ db.query(`INSERT OR IGNORE INTO retention_traces SELECT trace_id FROM trace_summaries
69
+ WHERE active_span_count = 0 AND NOT EXISTS (SELECT 1 FROM retention_traces d WHERE d.trace_id = trace_summaries.trace_id)
70
+ AND NOT EXISTS (SELECT 1 FROM spans s WHERE s.trace_id = trace_summaries.trace_id AND (s.end_time_ms <= 0 OR s.end_time_ms < s.start_time_ms))
71
+ ORDER BY started_at_ms, trace_id LIMIT ?`).run(remaining)
72
+ }
73
+ }
74
+ const traceIds = db.query("SELECT trace_id FROM retention_traces ORDER BY trace_id LIMIT ?").all(options.traces) as { trace_id: string }[]
75
+ // Correlated logs share trace visibility, including logs that have not yet been selected.
76
+ for (const { trace_id } of traceIds) {
77
+ const available = Math.max(0, options.logs - (db.query("SELECT count(*) AS n FROM retention_logs").get() as { n: number }).n)
78
+ if (available === 0) break
79
+ db.query(`INSERT OR IGNORE INTO retention_logs SELECT id FROM logs WHERE trace_id = ?
80
+ AND NOT EXISTS (SELECT 1 FROM retention_logs d WHERE d.id = logs.id) LIMIT ?`).run(trace_id, available)
81
+ }
82
+ const available = Math.max(0, options.logs - (db.query("SELECT count(*) AS n FROM retention_logs").get() as { n: number }).n)
83
+ db.query(`INSERT OR IGNORE INTO retention_logs SELECT id FROM logs WHERE timestamp_ms < ?
84
+ AND NOT EXISTS (SELECT 1 FROM retention_logs d WHERE d.id = logs.id)
85
+ ORDER BY timestamp_ms, id LIMIT ?`).run(oversized ? Number.MAX_SAFE_INTEGER : options.cutoff, available)
86
+
87
+ let remaining = options.rows
88
+ const remove = (sql: string, ...params: (string | number)[]) => {
89
+ if (remaining <= 0) return
90
+ const result = db.query(sql).run(...params, remaining)
91
+ remaining -= Number(result.changes)
92
+ }
93
+ // Split the row budget between traces and logs so neither stream starves the other.
94
+ remaining = Math.ceil(options.rows / 2)
95
+ for (const { trace_id } of traceIds) {
96
+ if (remaining <= 0) break
97
+ remove("DELETE FROM span_attributes WHERE rowid IN (SELECT rowid FROM span_attributes WHERE trace_id = ? LIMIT ?)", trace_id)
98
+ if (db.query("SELECT 1 FROM span_attributes WHERE trace_id = ? LIMIT 1").get(trace_id)) continue
99
+ const searches = db.query("SELECT id FROM span_search_rows WHERE trace_id = ? LIMIT ?").all(trace_id, remaining) as { id: number }[]
100
+ for (const { id } of searches) {
101
+ db.query("DELETE FROM span_operation_fts WHERE rowid = ?").run(id)
102
+ db.query("DELETE FROM span_search_rows WHERE id = ?").run(id)
103
+ remaining--
104
+ }
105
+ if (db.query("SELECT 1 FROM span_search_rows WHERE trace_id = ? LIMIT 1").get(trace_id)) continue
106
+ remove("DELETE FROM spans WHERE rowid IN (SELECT rowid FROM spans WHERE trace_id = ? LIMIT ?)", trace_id)
107
+ if (!db.query("SELECT 1 FROM spans WHERE trace_id = ? LIMIT 1").get(trace_id)
108
+ && !db.query("SELECT 1 FROM logs WHERE trace_id = ? LIMIT 1").get(trace_id)) {
109
+ db.query("DELETE FROM trace_summaries WHERE trace_id = ?").run(trace_id)
110
+ db.query("DELETE FROM retention_traces WHERE trace_id = ?").run(trace_id)
111
+ }
112
+ }
113
+ const traceRows = Math.ceil(options.rows / 2) - remaining
114
+ remaining = options.rows - traceRows
115
+ const logIds = db.query("SELECT id FROM retention_logs ORDER BY id LIMIT ?").all(options.logs) as { id: number }[]
116
+ for (const { id } of logIds) {
117
+ if (remaining <= 0) break
118
+ remove("DELETE FROM log_attributes WHERE rowid IN (SELECT rowid FROM log_attributes WHERE log_id = ? LIMIT ?)", id)
119
+ if (db.query("SELECT 1 FROM log_attributes WHERE log_id = ? LIMIT 1").get(id)) continue
120
+ const searches = db.query("SELECT id FROM log_search_rows WHERE log_id = ? LIMIT ?").all(id, remaining) as { id: number }[]
121
+ for (const search of searches) {
122
+ db.query("DELETE FROM log_body_fts WHERE rowid = ?").run(search.id)
123
+ db.query("DELETE FROM log_search_rows WHERE id = ?").run(search.id)
124
+ remaining--
125
+ }
126
+ if (remaining <= 0 || db.query("SELECT 1 FROM log_search_rows WHERE log_id = ? LIMIT 1").get(id)) continue
127
+ db.query("DELETE FROM logs WHERE id = ?").run(id)
128
+ db.query("DELETE FROM retention_logs WHERE id = ?").run(id)
129
+ remaining--
130
+ }
131
+ return { pending: oversized || traceIds.length > 0 || logIds.length > 0, rows: options.rows - remaining, markedTraces: traceIds.length, markedLogs: logIds.length }
132
+ })()
133
+
134
+ /** Bounded repair scans only rowids beyond its durable high-water mark; idle passes do not write. */
135
+ export const repairSearchRows = (db: Database, limit: number) => db.transaction(() => {
136
+ for (const table of ["log_attributes", "log_body_fts", "span_operation_fts"] as const) {
137
+ if (!db.query("SELECT 1 FROM sqlite_master WHERE name = ?").get(table)) continue
138
+ const key = `retention_repair_${table}`
139
+ const marker = (db.query("SELECT value FROM motel_maintenance WHERE key = ?").get(key) as { value: string } | null)?.value
140
+ const cursor = Number(marker ?? 0)
141
+ const columns = table === "span_operation_fts" ? "trace_id, span_id" : "log_id"
142
+ const rows = db.query(`SELECT rowid AS repair_id, ${columns} FROM ${table} WHERE rowid > ? ORDER BY rowid LIMIT ?`).all(cursor, limit) as { repair_id: number; log_id?: number | string; trace_id?: string; span_id?: string }[]
143
+ for (const row of rows) {
144
+ const exists = table === "span_operation_fts"
145
+ ? db.query("SELECT 1 FROM spans WHERE trace_id = ? AND span_id = ?").get(row.trace_id!, row.span_id!)
146
+ : db.query("SELECT 1 FROM logs WHERE id = ?").get(Number(row.log_id))
147
+ if (!exists) {
148
+ db.query(`DELETE FROM ${table} WHERE rowid = ?`).run(row.repair_id)
149
+ if (table !== "log_attributes") db.query(`DELETE FROM ${table === "log_body_fts" ? "log_search_rows" : "span_search_rows"} WHERE id = ?`).run(row.repair_id)
150
+ } else if (table === "span_operation_fts") {
151
+ db.query("INSERT OR REPLACE INTO span_search_rows VALUES (?, ?, ?)").run(row.repair_id, row.trace_id!, row.span_id!)
152
+ } else if (table === "log_body_fts") {
153
+ db.query("INSERT OR REPLACE INTO log_search_rows VALUES (?, ?)").run(row.repair_id, Number(row.log_id))
154
+ }
155
+ }
156
+ if (rows.length > 0) db.query("INSERT OR REPLACE INTO motel_maintenance VALUES (?, ?)").run(key, String(rows.at(-1)!.repair_id))
157
+ }
158
+ })()
@@ -0,0 +1,10 @@
1
+ // Keep the IPC owner outside SQLite's native call stack. If the daemon/TUI exits
2
+ // unexpectedly, disconnect can terminate this process even while its thread is
3
+ // executing a long SQLite query. A worker thread alone cannot interrupt that call.
4
+ const worker = new Worker(new URL("./telemetryQueryWorker.ts", import.meta.url))
5
+ worker.addEventListener("message", ({ data }) => process.send?.(data))
6
+ worker.addEventListener("error", () => process.exit(1))
7
+ worker.addEventListener("close", () => process.exit(1))
8
+ process.on("message", (message) => worker.postMessage(message))
9
+ process.on("disconnect", () => process.exit(0))
10
+ process.on("SIGTERM", () => process.exit(0))