@aryasaatvik/motel 0.5.0 → 0.5.2
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/AGENTS.md +2 -2
- package/package.json +6 -6
- package/skills/motel-debug/references/effect.md +1 -1
- package/src/cli.ts +1 -1
- package/src/httpApi.ts +1 -1
- package/src/localServer.ts +5 -5
- package/src/mcp.ts +1 -1
- package/src/motel.ts +1 -1
- package/src/services/AsyncIngest.ts +3 -3
- package/src/services/TelemetryStore.ts +23 -10
- package/src/services/ingestRpc.ts +2 -2
- package/src/services/retention.test.ts +150 -1
- package/src/services/retention.ts +44 -1
- package/src/services/telemetryWorker.ts +1 -1
- package/src/telemetry.test.ts +224 -0
- package/src/ui/aiState.ts +1 -1
- package/src/ui/atoms.ts +1 -1
- package/src/ui/serviceSelection.test.ts +1 -1
- package/web/dist/assets/index-BZhmmoqP.js +21 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-BuWb6t2M.js +0 -21
package/AGENTS.md
CHANGED
|
@@ -90,7 +90,7 @@ The repo is wired up with `@effect/language-service` as a `tsconfig.json` `plugi
|
|
|
90
90
|
- `src/ui/useKeyboardNav.ts` centralises the keyboard handlers and
|
|
91
91
|
cross-pane navigation state transitions.
|
|
92
92
|
- `src/motel.ts` composes and runs the public Effect command tree; `src/cli.ts` exports typed telemetry query leaves and handlers.
|
|
93
|
-
- `src/runtime.ts` wires the Effect
|
|
93
|
+
- `src/runtime.ts` wires the Effect runtime and OTEL trace + log exporters.
|
|
94
94
|
- `src/localServer.ts` starts the local Bun OTLP/query server.
|
|
95
95
|
- `src/httpApi.ts` defines the typed Effect HttpApi surface and OpenAPI spec for the local server.
|
|
96
96
|
- `src/httpListPolicy.ts` owns pure HTTP list/search parameter decoding, bounds, cursors, and pagination metadata shaping.
|
|
@@ -118,7 +118,7 @@ The repo is wired up with `@effect/language-service` as a `tsconfig.json` `plugi
|
|
|
118
118
|
## Effect Observability Guidance
|
|
119
119
|
- Inspect the target repo’s existing Effect runtime and observability wiring before adding anything new.
|
|
120
120
|
- Prefer the repo’s existing Effect-native observability APIs if available.
|
|
121
|
-
- If `effect/
|
|
121
|
+
- If `effect/observability` is already the best fit, prefer it over adding `@effect/opentelemetry`.
|
|
122
122
|
- Only add new OpenTelemetry SDK packages when the repo already uses them or they are clearly required.
|
|
123
123
|
- Merge telemetry into the main runtime once, not per-feature.
|
|
124
124
|
- Prefer structured log annotations so fields like `sessionID`, `modelID`, `providerID`, and `tool` are queryable.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aryasaatvik/motel",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "A local OpenTelemetry ingest + TUI viewer for development, backed by SQLite.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -98,10 +98,10 @@
|
|
|
98
98
|
"typescript": "^6.0.3"
|
|
99
99
|
},
|
|
100
100
|
"dependencies": {
|
|
101
|
-
"@effect/atom-react": "4.0.0
|
|
102
|
-
"@effect/opentelemetry": "4.0.0
|
|
103
|
-
"@effect/platform-bun": "4.0.0
|
|
104
|
-
"@effect/platform-node-shared": "4.0.0
|
|
101
|
+
"@effect/atom-react": "4.0.0",
|
|
102
|
+
"@effect/opentelemetry": "4.0.0",
|
|
103
|
+
"@effect/platform-bun": "4.0.0",
|
|
104
|
+
"@effect/platform-node-shared": "4.0.0",
|
|
105
105
|
"@opentelemetry/api": "^1.9.0",
|
|
106
106
|
"@opentelemetry/exporter-logs-otlp-http": "^0.219.0",
|
|
107
107
|
"@opentelemetry/exporter-trace-otlp-http": "^0.219.0",
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
"@opentelemetry/sdk-trace-node": "^2.8.0",
|
|
113
113
|
"@opentui/core": "0.4.2",
|
|
114
114
|
"@opentui/react": "0.4.2",
|
|
115
|
-
"effect": "4.0.0
|
|
115
|
+
"effect": "4.0.0",
|
|
116
116
|
"protobufjs": "7.6.4",
|
|
117
117
|
"react": "^19.2.7",
|
|
118
118
|
"scheduler": "^0.27.0"
|
|
@@ -6,7 +6,7 @@ Apply these only when the target repo already uses Effect or `@effect/*`.
|
|
|
6
6
|
|
|
7
7
|
- Inspect the existing runtime and observability wiring before adding anything new.
|
|
8
8
|
- Prefer the repo's existing Effect-native observability APIs if they already exist.
|
|
9
|
-
- If `effect/
|
|
9
|
+
- If `effect/observability` is already the best fit, prefer it over adding new OTEL packages.
|
|
10
10
|
- Merge telemetry into the main runtime once, not per feature or per request path.
|
|
11
11
|
|
|
12
12
|
## Instrumentation
|
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Console, Effect, Option, References } from "effect"
|
|
2
|
-
import { Argument, Command } from "effect/
|
|
2
|
+
import { Argument, Command } from "effect/cli"
|
|
3
3
|
import { config } from "./config.js"
|
|
4
4
|
import { otelServerInstructions } from "./instructions.js"
|
|
5
5
|
import { attributeFiltersFromArgs, isAttributeFilterToken } from "./queryFilters.js"
|
package/src/httpApi.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Schema } from "effect"
|
|
2
|
-
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/
|
|
2
|
+
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/http-api"
|
|
3
3
|
import {
|
|
4
4
|
AiCallSummary, AiCallDetail,
|
|
5
5
|
FacetItem, StatsItem, TraceSpanStatus,
|
package/src/localServer.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
2
|
import { Effect, FileSystem, Layer } from "effect"
|
|
3
3
|
import { config } from "./config.js"
|
|
4
|
-
import { HttpApiBuilder, HttpApiScalar } from "effect/
|
|
5
|
-
import * as HttpMiddleware from "effect/
|
|
6
|
-
import * as HttpRouter from "effect/
|
|
7
|
-
import * as HttpServerResponse from "effect/
|
|
8
|
-
import * as HttpStaticServer from "effect/
|
|
4
|
+
import { HttpApiBuilder, HttpApiScalar } from "effect/http-api"
|
|
5
|
+
import * as HttpMiddleware from "effect/http/HttpMiddleware"
|
|
6
|
+
import * as HttpRouter from "effect/http/HttpRouter"
|
|
7
|
+
import * as HttpServerResponse from "effect/http/HttpServerResponse"
|
|
8
|
+
import * as HttpStaticServer from "effect/http/HttpStaticServer"
|
|
9
9
|
import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"
|
|
10
10
|
import { MotelHttpApi } from "./httpApi.js"
|
|
11
11
|
import { AI_LIST, LOG_LIST, LOG_STATS, SPAN_LIST, TRACE_LIST, TRACE_STATS, listMeta, logCursorArgs, paginateLogs, paginateSummaries, parseLimit, parseListParams as decodeListParams, parseLookbackMinutes, requestUrl as decodeRequestUrl, traceCursorArgs, type ListBounds, type ListParams } from "./httpListPolicy.js"
|
package/src/mcp.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { BunRuntime, BunStdio } from "@effect/platform-bun"
|
|
3
3
|
import { Effect, Layer, Logger, Schema } from "effect"
|
|
4
4
|
import { MOTEL_VERSION } from "./registry.js"
|
|
5
|
-
import { McpProtocol, McpServer, Tool, Toolkit } from "effect/
|
|
5
|
+
import { McpProtocol, McpServer, Tool, Toolkit } from "effect/ai"
|
|
6
6
|
import { TraceSpanStatus } from "./domain.js"
|
|
7
7
|
import { MotelClient, MotelClientLive } from "./motelClient.js"
|
|
8
8
|
import { Locator, LocatorLive } from "./locator.js"
|
package/src/motel.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { BunRuntime, BunServices } from "@effect/platform-bun"
|
|
4
4
|
import { Console, Effect, Layer } from "effect"
|
|
5
|
-
import { CliOutput, Command, Flag } from "effect/
|
|
5
|
+
import { CliOutput, Command, Flag } from "effect/cli"
|
|
6
6
|
import packageJson from "../package.json" with { type: "json" }
|
|
7
7
|
import { queryCommands } from "./cli.js"
|
|
8
8
|
import { applyManagedDaemonEnv } from "./daemon.js"
|
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
|
|
22
22
|
import * as BunWorker from "@effect/platform-bun/BunWorker"
|
|
23
23
|
import { Context, Effect, Latch, Layer, Scope, Schema } from "effect"
|
|
24
|
-
import * as RpcClient from "effect/
|
|
25
|
-
import type { RpcClientError } from "effect/
|
|
26
|
-
import type { WorkerError } from "effect/
|
|
24
|
+
import * as RpcClient from "effect/rpc/RpcClient"
|
|
25
|
+
import type { RpcClientError } from "effect/rpc/RpcClientError"
|
|
26
|
+
import type { WorkerError } from "effect/workers/WorkerError"
|
|
27
27
|
import { IngestProgress, WriterEvent, type IngestReadiness } from "../ingestReadiness.ts"
|
|
28
28
|
import { IngestRpcs } from "./ingestRpc.ts"
|
|
29
29
|
|
|
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite"
|
|
|
2
2
|
import * as BunFileSystem from "@effect/platform-bun/BunFileSystem"
|
|
3
3
|
import { dirname } from "node:path"
|
|
4
4
|
import { Cause, Clock, Effect, FileSystem, Layer, Schedule, Context } from "effect"
|
|
5
|
-
import { installRetentionSchema, checkpointPassive, mergeFts, retainBatch, repairSearchRows } from "./retention.js"
|
|
5
|
+
import { installRetentionSchema, checkpointPassive, mergeFts, retainBatch, repairSearchRows, SPAN_SEARCH_MAPPING_READY_KEY } from "./retention.js"
|
|
6
6
|
import { WriterDiagnostics } from "../ingestReadiness.ts"
|
|
7
7
|
import { config } from "../config.js"
|
|
8
8
|
import type { AiCallDetail, AiCallSummary, FacetItem, LogItem, SpanItem, StatsItem, TraceItem, TraceSummaryItem, TraceSpanEvent, TraceSpanItem } from "../domain.js"
|
|
@@ -872,14 +872,21 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
|
|
|
872
872
|
query.run(...entries.flatMap(([key, value]) => [traceId, spanId, key, value]))
|
|
873
873
|
}
|
|
874
874
|
const deleteSpanOperationSearch = db.query(`DELETE FROM span_operation_fts WHERE trace_id = ? AND span_id = ?`)
|
|
875
|
+
const spanSearchMappingReady = hasRetention ? db.query("SELECT 1 FROM motel_maintenance WHERE key = ? AND value = 'complete'") : null
|
|
876
|
+
const deleteMappedSpanOperationSearch = hasRetention ? db.query(`DELETE FROM span_operation_fts WHERE rowid IN (
|
|
877
|
+
SELECT id FROM span_search_rows WHERE trace_id = ? AND span_id = ?
|
|
878
|
+
)`) : null
|
|
875
879
|
const insertSpanOperationSearch = db.query(`INSERT INTO span_operation_fts (trace_id, span_id, operation_name) VALUES (?, ?, ?)`)
|
|
876
880
|
const deleteSpanOperationSearchManyByCount = new Map<number, ReturnType<Database["query"]>>()
|
|
877
881
|
const insertSpanOperationSearchManyByCount = new Map<number, ReturnType<Database["query"]>>()
|
|
878
882
|
const updateSpanOperationSearchMany = (operations: ReadonlyArray<readonly [string, string, string]>) => {
|
|
879
883
|
if (operations.length === 0) return
|
|
884
|
+
const mappedDelete = spanSearchMappingReady !== null && deleteMappedSpanOperationSearch !== null
|
|
885
|
+
&& spanSearchMappingReady.get(SPAN_SEARCH_MAPPING_READY_KEY) !== null ? deleteMappedSpanOperationSearch : null
|
|
880
886
|
if (operations.length === 1) {
|
|
881
887
|
const [traceId, spanId, operationName] = operations[0]!
|
|
882
|
-
|
|
888
|
+
if (mappedDelete !== null) mappedDelete.run(traceId, spanId)
|
|
889
|
+
else deleteSpanOperationSearch.run(traceId, spanId)
|
|
883
890
|
db.query("DELETE FROM span_search_rows WHERE trace_id = ? AND span_id = ?").run(traceId, spanId)
|
|
884
891
|
const result = insertSpanOperationSearch.run(traceId, spanId, operationName)
|
|
885
892
|
mapSpanSearch?.run(Number(result.lastInsertRowid), traceId, spanId)
|
|
@@ -891,7 +898,9 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
|
|
|
891
898
|
deleteQuery = db.query(`DELETE FROM span_operation_fts WHERE ${operations.map(() => "(trace_id = ? AND span_id = ?)").join(" OR ")}`)
|
|
892
899
|
deleteSpanOperationSearchManyByCount.set(operations.length, deleteQuery)
|
|
893
900
|
}
|
|
894
|
-
|
|
901
|
+
if (mappedDelete !== null) {
|
|
902
|
+
for (const [traceId, spanId] of operations) mappedDelete.run(traceId, spanId)
|
|
903
|
+
} else deleteQuery.run(...operations.flatMap(([traceId, spanId]) => [traceId, spanId]))
|
|
895
904
|
for (const [traceId, spanId] of operations) db.query("DELETE FROM span_search_rows WHERE trace_id = ? AND span_id = ?").run(traceId, spanId)
|
|
896
905
|
|
|
897
906
|
let insertQuery = insertSpanOperationSearchManyByCount.get(operations.length)
|
|
@@ -904,6 +913,13 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
|
|
|
904
913
|
const firstId = Number(result.lastInsertRowid) - operations.length + 1
|
|
905
914
|
for (const [index, [traceId, spanId]] of operations.entries()) mapSpanSearch?.run(firstId + index, traceId, spanId)
|
|
906
915
|
}
|
|
916
|
+
// An optional search-index failure must leave every FTS row mapped.
|
|
917
|
+
const updateSpanOperationSearch = db.transaction((operations: Parameters<typeof updateSpanOperationSearchMany>[0]) => {
|
|
918
|
+
const BATCH_SIZE = 500
|
|
919
|
+
for (let offset = 0; offset < operations.length; offset += BATCH_SIZE) {
|
|
920
|
+
updateSpanOperationSearchMany(operations.slice(offset, offset + BATCH_SIZE))
|
|
921
|
+
}
|
|
922
|
+
})
|
|
907
923
|
const insertLogAttribute = db.query(`INSERT INTO log_attributes (log_id, key, value) VALUES (?, ?, ?)`)
|
|
908
924
|
const logAttributeInsertManyByCount = new Map<number, ReturnType<Database["query"]>>()
|
|
909
925
|
const insertLogAttributesMany = (logId: number, attributes: Readonly<Record<string, string>>) => {
|
|
@@ -979,8 +995,8 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
|
|
|
979
995
|
logs: config.otel.retentionLogBatch,
|
|
980
996
|
rows: config.otel.retentionRowBatch,
|
|
981
997
|
})
|
|
982
|
-
|
|
983
|
-
|
|
998
|
+
const spanMappingPending = repairSearchRows(db, Math.min(100, config.otel.retentionRowBatch))
|
|
999
|
+
maintenancePending = (result.pending && result.rows > 0) || spanMappingPending
|
|
984
1000
|
mergeFts(db, 100)
|
|
985
1001
|
checkpoint()
|
|
986
1002
|
})
|
|
@@ -1114,12 +1130,9 @@ const makeTelemetryStoreEffect = (opts: TelemetryStoreOptions) =>
|
|
|
1114
1130
|
}
|
|
1115
1131
|
}
|
|
1116
1132
|
try {
|
|
1117
|
-
|
|
1118
|
-
for (let offset = 0; offset < touchedOperations.length; offset += BATCH_SIZE) {
|
|
1119
|
-
updateSpanOperationSearchMany(touchedOperations.slice(offset, offset + BATCH_SIZE))
|
|
1120
|
-
}
|
|
1133
|
+
updateSpanOperationSearch(touchedOperations)
|
|
1121
1134
|
} catch {
|
|
1122
|
-
// FTS is optional.
|
|
1135
|
+
// FTS is optional; its nested transaction restores the index and mapping together.
|
|
1123
1136
|
}
|
|
1124
1137
|
for (const traceId of touchedTraceIds) {
|
|
1125
1138
|
upsertTraceSummary.run(traceId)
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { Schema } from "effect"
|
|
21
|
-
import * as Rpc from "effect/
|
|
22
|
-
import * as RpcGroup from "effect/
|
|
21
|
+
import * as Rpc from "effect/rpc/Rpc"
|
|
22
|
+
import * as RpcGroup from "effect/rpc/RpcGroup"
|
|
23
23
|
|
|
24
24
|
export class IngestError extends Schema.TaggedError<IngestError>()("IngestError", {
|
|
25
25
|
message: Schema.String,
|
|
@@ -3,7 +3,7 @@ import { expect, test } from "bun:test"
|
|
|
3
3
|
import { mkdtempSync, rmSync } from "node:fs"
|
|
4
4
|
import { tmpdir } from "node:os"
|
|
5
5
|
import { join } from "node:path"
|
|
6
|
-
import { checkpointPassive, installRetentionSchema, mergeFts, repairSearchRows, retainBatch } from "./retention.ts"
|
|
6
|
+
import { checkpointPassive, installRetentionSchema, mergeFts, repairSearchRows, retainBatch, SPAN_SEARCH_MAPPING_READY_KEY } from "./retention.ts"
|
|
7
7
|
|
|
8
8
|
const schema = (db: Database) => {
|
|
9
9
|
db.exec(`PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 15000;
|
|
@@ -168,3 +168,152 @@ test("stale upgraded summaries cannot evict active spans under age or size reten
|
|
|
168
168
|
} finally { db.close() }
|
|
169
169
|
}
|
|
170
170
|
})
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
test("operation mapping readiness waits for complete legacy repair and stays idle after reopen", () => {
|
|
174
|
+
const root = mkdtempSync(join(tmpdir(), "motel-mapping-ready-"))
|
|
175
|
+
const path = join(root, "test.sqlite")
|
|
176
|
+
let db = new Database(path)
|
|
177
|
+
try {
|
|
178
|
+
schema(db)
|
|
179
|
+
expect(db.query("SELECT value FROM motel_maintenance WHERE key = ?").get(SPAN_SEARCH_MAPPING_READY_KEY)).toEqual({ value: "complete" })
|
|
180
|
+
db.query("DELETE FROM motel_maintenance WHERE key = ?").run(SPAN_SEARCH_MAPPING_READY_KEY)
|
|
181
|
+
db.exec("INSERT INTO spans(trace_id,span_id) VALUES ('a','s'), ('b','s'); INSERT INTO span_operation_fts(trace_id,span_id,operation_name) VALUES ('a','s','alpha'), ('b','s','beta'), ('orphan','s','orphan')")
|
|
182
|
+
// All three unmapped historical rows are below the pre-upgrade durable cursor.
|
|
183
|
+
db.exec("INSERT OR REPLACE INTO motel_maintenance VALUES ('retention_repair_span_operation_fts', '999')")
|
|
184
|
+
db.exec("CREATE TRIGGER refuse_mapping_marker BEFORE INSERT ON motel_maintenance WHEN NEW.key = 'span_search_mapping_complete' BEGIN SELECT RAISE(ABORT, 'marker failure'); END")
|
|
185
|
+
expect(() => installRetentionSchema(db)).toThrow("marker failure")
|
|
186
|
+
expect(db.query("SELECT value FROM motel_maintenance WHERE key = 'retention_repair_span_operation_fts'").get()).toEqual({ value: "999" })
|
|
187
|
+
expect(db.query("SELECT value FROM motel_maintenance WHERE key = ?").get(SPAN_SEARCH_MAPPING_READY_KEY)).toBeNull()
|
|
188
|
+
db.exec("DROP TRIGGER refuse_mapping_marker")
|
|
189
|
+
installRetentionSchema(db)
|
|
190
|
+
expect(db.query("SELECT value FROM motel_maintenance WHERE key = 'retention_repair_span_operation_fts'").get()).toBeNull()
|
|
191
|
+
expect(repairSearchRows(db, 1)).toBe(true)
|
|
192
|
+
expect(count(db, "span_search_rows")).toBe(1)
|
|
193
|
+
expect(db.query("SELECT value FROM motel_maintenance WHERE key = ?").get(SPAN_SEARCH_MAPPING_READY_KEY)).toEqual({ value: "repairing:3" })
|
|
194
|
+
db.close()
|
|
195
|
+
db = new Database(path)
|
|
196
|
+
installRetentionSchema(db)
|
|
197
|
+
expect(db.query("SELECT value FROM motel_maintenance WHERE key = 'retention_repair_span_operation_fts'").get()).toEqual({ value: "1" })
|
|
198
|
+
expect(repairSearchRows(db, 1)).toBe(true)
|
|
199
|
+
expect(count(db, "span_search_rows")).toBe(2)
|
|
200
|
+
expect(db.query("SELECT value FROM motel_maintenance WHERE key = ?").get(SPAN_SEARCH_MAPPING_READY_KEY)).toEqual({ value: "repairing:3" })
|
|
201
|
+
expect(repairSearchRows(db, 1)).toBe(false)
|
|
202
|
+
expect(count(db, "span_operation_fts")).toBe(2)
|
|
203
|
+
expect(db.query("SELECT value FROM motel_maintenance WHERE key = ?").get(SPAN_SEARCH_MAPPING_READY_KEY)).toEqual({ value: "complete" })
|
|
204
|
+
expect(repairSearchRows(db, 1)).toBe(false)
|
|
205
|
+
expect(db.query("SELECT value FROM motel_maintenance WHERE key = ?").get(SPAN_SEARCH_MAPPING_READY_KEY)).toEqual({ value: "complete" })
|
|
206
|
+
db.close()
|
|
207
|
+
db = new Database(path)
|
|
208
|
+
installRetentionSchema(db)
|
|
209
|
+
expect(db.query("SELECT value FROM motel_maintenance WHERE key = 'retention_repair_span_operation_fts'").get()).toEqual({ value: "3" })
|
|
210
|
+
expect(db.query("SELECT value FROM motel_maintenance WHERE key = ?").get(SPAN_SEARCH_MAPPING_READY_KEY)).toEqual({ value: "complete" })
|
|
211
|
+
const before = db.query("SELECT total_changes() AS n").get()
|
|
212
|
+
expect(repairSearchRows(db, 1)).toBe(false)
|
|
213
|
+
expect(db.query("SELECT total_changes() AS n").get()).toEqual(before)
|
|
214
|
+
} finally { db.close(); rmSync(root, { recursive: true, force: true }) }
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
test("a final partial span repair returns idle despite remaining log repair", () => {
|
|
219
|
+
const db = new Database(":memory:")
|
|
220
|
+
try {
|
|
221
|
+
schema(db)
|
|
222
|
+
db.query("DELETE FROM motel_maintenance WHERE key = ?").run(SPAN_SEARCH_MAPPING_READY_KEY)
|
|
223
|
+
db.exec("INSERT INTO spans(trace_id, span_id) VALUES ('a', 's'), ('b', 's'); INSERT INTO span_operation_fts(trace_id, span_id, operation_name) VALUES ('a', 's', 'alpha'), ('b', 's', 'beta')")
|
|
224
|
+
for (let id = 1; id <= 7; id++) {
|
|
225
|
+
db.query("INSERT INTO logs VALUES (?, NULL, 1000, 'INFO')").run(id)
|
|
226
|
+
db.query("INSERT INTO log_attributes VALUES (?, 'key', 'value')").run(id)
|
|
227
|
+
db.query("INSERT INTO log_body_fts(log_id, body) VALUES (?, 'searchable')").run(String(id))
|
|
228
|
+
}
|
|
229
|
+
installRetentionSchema(db)
|
|
230
|
+
expect(repairSearchRows(db, 3)).toBe(false)
|
|
231
|
+
expect(count(db, "span_search_rows")).toBe(2)
|
|
232
|
+
expect(db.query("SELECT value FROM motel_maintenance WHERE key = ?").get(SPAN_SEARCH_MAPPING_READY_KEY)).toEqual({ value: "complete" })
|
|
233
|
+
expect(count(db, "log_body_fts")).toBe(7)
|
|
234
|
+
expect(count(db, "log_search_rows")).toBe(3)
|
|
235
|
+
} finally { db.close() }
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test("an unavailable optional span index does not request fast repair retries", () => {
|
|
239
|
+
const db = new Database(":memory:")
|
|
240
|
+
try {
|
|
241
|
+
schema(db)
|
|
242
|
+
db.query("UPDATE motel_maintenance SET value = 'repairing:3' WHERE key = ?").run(SPAN_SEARCH_MAPPING_READY_KEY)
|
|
243
|
+
db.exec("DROP TABLE span_operation_fts")
|
|
244
|
+
expect(repairSearchRows(db, 3)).toBe(false)
|
|
245
|
+
expect(db.query("SELECT value FROM motel_maintenance WHERE key = ?").get(SPAN_SEARCH_MAPPING_READY_KEY)).toEqual({ value: "repairing:3" })
|
|
246
|
+
} finally { db.close() }
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
test("legacy span mapping finishes its fixed range despite mapped arrivals and reopen", () => {
|
|
251
|
+
const root = mkdtempSync(join(tmpdir(), "motel-mapping-target-"))
|
|
252
|
+
const path = join(root, "test.sqlite")
|
|
253
|
+
let db = new Database(path)
|
|
254
|
+
const state = () => db.query("SELECT value FROM motel_maintenance WHERE key = ?").get(SPAN_SEARCH_MAPPING_READY_KEY)
|
|
255
|
+
const append = (id: number, mapped: boolean) => {
|
|
256
|
+
db.query("INSERT INTO spans(trace_id, span_id) VALUES (?, 's')").run(String(id))
|
|
257
|
+
db.query("INSERT INTO span_operation_fts(rowid, trace_id, span_id, operation_name) VALUES (?, ?, 's', 'operation')").run(id, String(id))
|
|
258
|
+
if (mapped) db.query("INSERT INTO span_search_rows VALUES (?, ?, 's')").run(id, String(id))
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
schema(db)
|
|
262
|
+
db.query("DELETE FROM motel_maintenance WHERE key = ?").run(SPAN_SEARCH_MAPPING_READY_KEY)
|
|
263
|
+
for (let id = 1; id <= 4; id++) append(id, false)
|
|
264
|
+
db.exec("INSERT OR REPLACE INTO motel_maintenance VALUES ('retention_repair_span_operation_fts', '999')")
|
|
265
|
+
installRetentionSchema(db)
|
|
266
|
+
const captured = state()
|
|
267
|
+
expect(repairSearchRows(db, 2)).toBe(true)
|
|
268
|
+
for (let id = 5; id <= 8; id++) append(id, true)
|
|
269
|
+
db.close()
|
|
270
|
+
db = new Database(path)
|
|
271
|
+
installRetentionSchema(db)
|
|
272
|
+
expect(state()).toEqual(captured)
|
|
273
|
+
// Complete the exact full-sized final batch without scanning the mapped arrivals.
|
|
274
|
+
expect(repairSearchRows(db, 2)).toBe(false)
|
|
275
|
+
expect(state()).toEqual({ value: "complete" })
|
|
276
|
+
expect(captured).toEqual({ value: "repairing:4" })
|
|
277
|
+
expect(count(db, "span_search_rows")).toBe(8)
|
|
278
|
+
expect(db.query("SELECT value FROM motel_maintenance WHERE key = 'retention_repair_span_operation_fts'").get()).toEqual({ value: "4" })
|
|
279
|
+
// Ordinary incremental repair still visits later rows, without requesting fast cadence.
|
|
280
|
+
expect(repairSearchRows(db, 2)).toBe(false)
|
|
281
|
+
expect(db.query("SELECT value FROM motel_maintenance WHERE key = 'retention_repair_span_operation_fts'").get()).toEqual({ value: "6" })
|
|
282
|
+
expect(repairSearchRows(db, 2)).toBe(false)
|
|
283
|
+
const before = db.query("SELECT total_changes() AS n").get()
|
|
284
|
+
expect(repairSearchRows(db, 2)).toBe(false)
|
|
285
|
+
expect(db.query("SELECT total_changes() AS n").get()).toEqual(before)
|
|
286
|
+
} finally { db.close(); rmSync(root, { recursive: true, force: true }) }
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test("fixed-range repair rolls back failures and completes after its target row is removed", () => {
|
|
290
|
+
const db = new Database(":memory:")
|
|
291
|
+
try {
|
|
292
|
+
schema(db)
|
|
293
|
+
db.query("DELETE FROM motel_maintenance WHERE key = ?").run(SPAN_SEARCH_MAPPING_READY_KEY)
|
|
294
|
+
db.exec("INSERT INTO spans(trace_id,span_id) VALUES ('a','s'),('b','s'); INSERT INTO span_operation_fts(trace_id,span_id,operation_name) VALUES ('a','s','alpha'),('b','s','beta')")
|
|
295
|
+
installRetentionSchema(db)
|
|
296
|
+
expect(repairSearchRows(db, 1)).toBe(true)
|
|
297
|
+
db.exec("CREATE TRIGGER refuse_repair BEFORE INSERT ON span_search_rows WHEN NEW.trace_id = 'b' BEGIN SELECT RAISE(ABORT, 'repair failure'); END")
|
|
298
|
+
expect(() => repairSearchRows(db, 1)).toThrow("repair failure")
|
|
299
|
+
expect(db.query("SELECT value FROM motel_maintenance WHERE key = ?").get(SPAN_SEARCH_MAPPING_READY_KEY)).toEqual({ value: "repairing:2" })
|
|
300
|
+
expect(db.query("SELECT value FROM motel_maintenance WHERE key = 'retention_repair_span_operation_fts'").get()).toEqual({ value: "1" })
|
|
301
|
+
db.exec("DROP TRIGGER refuse_repair; DELETE FROM span_operation_fts WHERE rowid = 2")
|
|
302
|
+
expect(repairSearchRows(db, 1)).toBe(false)
|
|
303
|
+
expect(db.query("SELECT value FROM motel_maintenance WHERE key = ?").get(SPAN_SEARCH_MAPPING_READY_KEY)).toEqual({ value: "complete" })
|
|
304
|
+
} finally { db.close() }
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test("malformed persistent span migration state fails without resetting its cursor", () => {
|
|
308
|
+
for (const state of ["repairing", "repairing:0", "repairing:1.5", "repairing:9007199254740992", "unknown"]) {
|
|
309
|
+
const db = new Database(":memory:")
|
|
310
|
+
try {
|
|
311
|
+
schema(db)
|
|
312
|
+
db.query("UPDATE motel_maintenance SET value = ? WHERE key = ?").run(state, SPAN_SEARCH_MAPPING_READY_KEY)
|
|
313
|
+
db.exec("INSERT OR REPLACE INTO motel_maintenance VALUES ('retention_repair_span_operation_fts', '7')")
|
|
314
|
+
expect(() => installRetentionSchema(db)).toThrow("Invalid span search mapping migration state")
|
|
315
|
+
expect(() => repairSearchRows(db, 1)).toThrow("Invalid span search mapping migration state")
|
|
316
|
+
expect(db.query("SELECT value FROM motel_maintenance WHERE key = 'retention_repair_span_operation_fts'").get()).toEqual({ value: "7" })
|
|
317
|
+
} finally { db.close() }
|
|
318
|
+
}
|
|
319
|
+
})
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite"
|
|
2
2
|
|
|
3
|
+
export const SPAN_SEARCH_MAPPING_READY_KEY = "span_search_mapping_complete"
|
|
4
|
+
|
|
5
|
+
const spanMappingTarget = (state: string): number | null => {
|
|
6
|
+
if (state === "complete") return null
|
|
7
|
+
const match = /^repairing:([1-9][0-9]*)$/.exec(state)
|
|
8
|
+
const target = match === null ? NaN : Number(match[1])
|
|
9
|
+
if (!Number.isSafeInteger(target)) throw new Error("Invalid span search mapping migration state")
|
|
10
|
+
return target
|
|
11
|
+
}
|
|
12
|
+
|
|
3
13
|
/** Additive schema: unfinished eviction is hidden from every public query. */
|
|
4
14
|
export const installRetentionSchema = (db: Database) => {
|
|
5
15
|
db.exec(`
|
|
@@ -22,6 +32,21 @@ export const installRetentionSchema = (db: Database) => {
|
|
|
22
32
|
WHERE NOT EXISTS (SELECT 1 FROM retention_traces d WHERE d.trace_id = logs.trace_id)
|
|
23
33
|
AND NOT EXISTS (SELECT 1 FROM retention_logs d WHERE d.id = logs.id);
|
|
24
34
|
`)
|
|
35
|
+
if (db.query("SELECT 1 FROM sqlite_master WHERE name = 'span_operation_fts' AND type = 'table'").get()) {
|
|
36
|
+
db.transaction(() => {
|
|
37
|
+
const marker = db.query("SELECT value FROM motel_maintenance WHERE key = ?").get(SPAN_SEARCH_MAPPING_READY_KEY) as { value: string } | null
|
|
38
|
+
if (marker !== null) {
|
|
39
|
+
spanMappingTarget(marker.value)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
// Earlier optional index failures can strand unmapped rows behind the old cursor.
|
|
43
|
+
db.query("DELETE FROM motel_maintenance WHERE key = 'retention_repair_span_operation_fts'").run()
|
|
44
|
+
const last = db.query("SELECT rowid AS target FROM span_operation_fts ORDER BY rowid DESC LIMIT 1").get() as { target: number } | null
|
|
45
|
+
const state = last === null ? "complete" : `repairing:${last.target}`
|
|
46
|
+
spanMappingTarget(state)
|
|
47
|
+
db.query("INSERT INTO motel_maintenance(key, value) VALUES (?, ?)").run(SPAN_SEARCH_MAPPING_READY_KEY, state)
|
|
48
|
+
}).immediate()
|
|
49
|
+
}
|
|
25
50
|
}
|
|
26
51
|
|
|
27
52
|
export type Checkpoint = { busy: number; log: number; checkpointed: number; deferred: boolean }
|
|
@@ -133,13 +158,23 @@ export const retainBatch = (db: Database, options: RetentionOptions) => db.trans
|
|
|
133
158
|
|
|
134
159
|
/** Bounded repair scans only rowids beyond its durable high-water mark; idle passes do not write. */
|
|
135
160
|
export const repairSearchRows = (db: Database, limit: number) => db.transaction(() => {
|
|
161
|
+
let spanMappingPending = false
|
|
136
162
|
for (const table of ["log_attributes", "log_body_fts", "span_operation_fts"] as const) {
|
|
137
163
|
if (!db.query("SELECT 1 FROM sqlite_master WHERE name = ?").get(table)) continue
|
|
138
164
|
const key = `retention_repair_${table}`
|
|
139
165
|
const marker = (db.query("SELECT value FROM motel_maintenance WHERE key = ?").get(key) as { value: string } | null)?.value
|
|
140
166
|
const cursor = Number(marker ?? 0)
|
|
141
167
|
const columns = table === "span_operation_fts" ? "trace_id, span_id" : "log_id"
|
|
142
|
-
|
|
168
|
+
let target: number | null = null
|
|
169
|
+
if (table === "span_operation_fts") {
|
|
170
|
+
const state = db.query("SELECT value FROM motel_maintenance WHERE key = ?").get(SPAN_SEARCH_MAPPING_READY_KEY) as { value: string } | null
|
|
171
|
+
if (state === null) throw new Error("Missing span search mapping migration state")
|
|
172
|
+
target = spanMappingTarget(state.value)
|
|
173
|
+
}
|
|
174
|
+
const rows = (target === null
|
|
175
|
+
? db.query(`SELECT rowid AS repair_id, ${columns} FROM ${table} WHERE rowid > ? ORDER BY rowid LIMIT ?`).all(cursor, limit)
|
|
176
|
+
: db.query(`SELECT rowid AS repair_id, ${columns} FROM ${table} WHERE rowid > ? AND rowid <= ? ORDER BY rowid LIMIT ?`).all(cursor, target, limit)
|
|
177
|
+
) as { repair_id: number; log_id?: number | string; trace_id?: string; span_id?: string }[]
|
|
143
178
|
for (const row of rows) {
|
|
144
179
|
const exists = table === "span_operation_fts"
|
|
145
180
|
? db.query("SELECT 1 FROM spans WHERE trace_id = ? AND span_id = ?").get(row.trace_id!, row.span_id!)
|
|
@@ -154,5 +189,13 @@ export const repairSearchRows = (db: Database, limit: number) => db.transaction(
|
|
|
154
189
|
}
|
|
155
190
|
}
|
|
156
191
|
if (rows.length > 0) db.query("INSERT OR REPLACE INTO motel_maintenance VALUES (?, ?)").run(key, String(rows.at(-1)!.repair_id))
|
|
192
|
+
if (target !== null) {
|
|
193
|
+
const nextCursor = rows.at(-1)?.repair_id ?? cursor
|
|
194
|
+
spanMappingPending = db.query("SELECT 1 FROM span_operation_fts WHERE rowid > ? AND rowid <= ? LIMIT 1").get(nextCursor, target) !== null
|
|
195
|
+
if (!spanMappingPending) {
|
|
196
|
+
db.query("UPDATE motel_maintenance SET value = 'complete' WHERE key = ?").run(SPAN_SEARCH_MAPPING_READY_KEY)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
157
199
|
}
|
|
200
|
+
return spanMappingPending
|
|
158
201
|
})()
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { BunRuntime } from "@effect/platform-bun"
|
|
18
18
|
import * as BunWorkerRunner from "@effect/platform-bun/BunWorkerRunner"
|
|
19
19
|
import { Effect, Layer } from "effect"
|
|
20
|
-
import * as RpcServer from "effect/
|
|
20
|
+
import * as RpcServer from "effect/rpc/RpcServer"
|
|
21
21
|
import type { OtlpLogExportRequest, OtlpTraceExportRequest } from "../otlp.ts"
|
|
22
22
|
import { WriterDiagnostics, type WriterEvent } from "../ingestReadiness.ts"
|
|
23
23
|
import { IngestError, IngestRpcs } from "./ingestRpc.ts"
|