@aryasaatvik/motel 0.3.5 → 0.5.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.
@@ -0,0 +1,160 @@
1
+ import { Database } from "bun:sqlite"
2
+ import { expect, test } from "bun:test"
3
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs"
4
+ import { tmpdir } from "node:os"
5
+ import { join } from "node:path"
6
+ import { Effect } from "effect"
7
+ import { createDaemonManager } from "./daemon.ts"
8
+ import { IngestProgress, type IngestReadiness } from "./ingestReadiness.ts"
9
+
10
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
11
+
12
+ test("idle readiness, overload age, empty commits, and terminal failure are distinct", () => {
13
+ const progress = new IngestProgress()
14
+ expect(progress.snapshot().state).toBe("starting")
15
+ progress.receive({ _tag: "ready" })
16
+ expect(progress.snapshot(Date.now() + 86_400_000).state).toBe("ready")
17
+ const finish = progress.begin(42, 100)
18
+ expect(progress.snapshot(5100)).toMatchObject({ state: "overloaded", outstandingBytes: 42, outstandingRequests: 1 })
19
+ progress.receive({ _tag: "commit", records: 0, at: 500 })
20
+ expect(progress.snapshot().lastCommitAt).toBeNull()
21
+ finish()
22
+ finish()
23
+ expect(progress.snapshot()).toMatchObject({ state: "ready", outstandingBytes: 0, outstandingRequests: 0 })
24
+ progress.fail()
25
+ progress.receive({ _tag: "ready" })
26
+ expect(progress.snapshot().state).toBe("failed")
27
+ })
28
+
29
+ test("cached readiness bypasses a blocked writer and acknowledgement follows commit", async () => {
30
+ const runtimeDir = mkdtempSync(join(tmpdir(), "motel-readiness-"))
31
+ const databasePath = join(runtimeDir, "telemetry.sqlite")
32
+ const port = 31000 + Math.floor(Math.random() * 2000)
33
+ const manager = createDaemonManager({ runtimeDir, databasePath, port })
34
+ let lock: Database | undefined
35
+ try {
36
+ await Effect.runPromise(manager.ensure)
37
+ const base = `http://127.0.0.1:${port}`
38
+ const readiness = async () => {
39
+ const response = await fetch(`${base}/api/readiness`, { signal: AbortSignal.timeout(1500) })
40
+ return await response.json() as IngestReadiness
41
+ }
42
+ expect((await readiness()).state).toBe("ready")
43
+ lock = new Database(databasePath)
44
+ lock.exec("BEGIN IMMEDIATE")
45
+ const body = JSON.stringify({ resourceLogs: [{ resource: { attributes: [{ key: "service.name", value: { stringValue: "readiness-test" } }] }, scopeLogs: [{ logRecords: [{ timeUnixNano: String(BigInt(Date.now()) * 1_000_000n), body: { stringValue: "commit barrier" } }] }] }] })
46
+ let acknowledged = false
47
+ const ingestion = fetch(`${base}/v1/logs`, { method: "POST", headers: { "content-type": "application/json" }, body }).then(async (response) => {
48
+ expect(response.status).toBe(200)
49
+ acknowledged = true
50
+ return response.json()
51
+ })
52
+ await sleep(150)
53
+ const snapshot = await readiness()
54
+ expect(snapshot.outstandingRequests).toBe(1)
55
+ expect(snapshot.outstandingBytes).toBe(Buffer.byteLength(body))
56
+ expect(acknowledged).toBe(false)
57
+ expect(lock.query("SELECT count(*) AS n FROM logs WHERE body = 'commit barrier'").get()).toEqual({ n: 0 })
58
+ lock.exec("COMMIT")
59
+ expect(await ingestion).toEqual({ insertedLogs: 1 })
60
+ for (let i = 0; i < 50 && (await readiness()).committedRecords === 0; i++) await sleep(10)
61
+ expect(await readiness()).toMatchObject({ state: "ready", outstandingRequests: 0, outstandingBytes: 0, committedRecords: 1 })
62
+ expect(lock.query("SELECT count(*) AS n FROM logs WHERE body = 'commit barrier'").get()).toEqual({ n: 1 })
63
+ expect((await Effect.runPromise(manager.getStatus)).readiness?.state).toBe("ready")
64
+ } finally {
65
+ try { lock?.exec("ROLLBACK") } catch { /* The successful path committed. */ }
66
+ lock?.close()
67
+ await Effect.runPromise(manager.stop)
68
+ rmSync(runtimeDir, { recursive: true, force: true })
69
+ }
70
+ }, 30000)
71
+
72
+ test("a writer bootstrap failure is reported without mistaking liveness for readiness", async () => {
73
+ const root = mkdtempSync(join(tmpdir(), "motel-writer-failure-"))
74
+ const port = 35000 + Math.floor(Math.random() * 1000)
75
+ const child = Bun.spawn([process.execPath, "src/server.ts"], {
76
+ cwd: join(import.meta.dir, ".."),
77
+ env: { ...process.env, MOTEL_OTEL_DB_PATH: root, MOTEL_RUNTIME_DIR: root, MOTEL_OTEL_HOST: "127.0.0.1", MOTEL_OTEL_PORT: String(port), MOTEL_OTEL_BASE_URL: `http://127.0.0.1:${port}`, MOTEL_OTEL_ENABLED: "false" },
78
+ stdout: "ignore", stderr: "ignore",
79
+ })
80
+ try {
81
+ let state: string | undefined
82
+ const until = Date.now() + 10000
83
+ while (Date.now() < until) {
84
+ try {
85
+ const response = await fetch(`http://127.0.0.1:${port}/api/readiness`, { signal: AbortSignal.timeout(500) })
86
+ state = (await response.json() as IngestReadiness).state
87
+ if (state === "failed") {
88
+ expect(response.status).toBe(503)
89
+ break
90
+ }
91
+ } catch { /* Wait for the HTTP listener independently of writer bootstrap. */ }
92
+ await sleep(25)
93
+ }
94
+ expect(state).toBe("failed")
95
+ expect((await fetch(`http://127.0.0.1:${port}/api/health`)).status).toBe(200)
96
+ } finally {
97
+ child.kill("SIGTERM")
98
+ await Promise.race([child.exited, sleep(1000)])
99
+ if (child.exitCode === null) child.kill("SIGKILL")
100
+ await child.exited
101
+ rmSync(root, { recursive: true, force: true })
102
+ }
103
+ }, 15000)
104
+
105
+
106
+ test("completed maintenance cannot hide another operation that is still running", () => {
107
+ const progress = new IngestProgress()
108
+ progress.receive({ _tag: "maintenance", value: { operation: "retention", startedAt: 10, durationMs: null, outcome: "running" } })
109
+ progress.receive({ _tag: "maintenance", value: { operation: "reclaim", startedAt: 20, durationMs: null, outcome: "running" } })
110
+ progress.receive({ _tag: "maintenance", value: { operation: "reclaim", startedAt: 20, durationMs: 5, outcome: "completed" } })
111
+ expect(progress.snapshot().maintenance).toMatchObject({ operation: "retention", outcome: "running" })
112
+ progress.receive({ _tag: "maintenance", value: { operation: "retention", startedAt: 10, durationMs: 30, outcome: "completed" } })
113
+ expect(progress.snapshot().maintenance).toMatchObject({ operation: "retention", outcome: "completed", durationMs: 30 })
114
+ })
115
+
116
+ test("simultaneous cold starts from different workdirs converge on one managed daemon", async () => {
117
+ const root = mkdtempSync(join(tmpdir(), "motel-concurrent-start-"))
118
+ const workdirs = [join(root, "a"), join(root, "b")]
119
+ for (const workdir of workdirs) mkdirSync(workdir)
120
+ const port = 36000 + Math.floor(Math.random() * 1000)
121
+ const managers = workdirs.map((workdir) => createDaemonManager({ runtimeDir: root, databasePath: join(root, "db.sqlite"), port, workdir, startTimeoutMs: 5000, gracefulStopTimeoutMs: 500, forceStopTimeoutMs: 500 }))
122
+ try {
123
+ const states = await Promise.all(managers.map((manager) => Effect.runPromise(manager.ensure)))
124
+ expect(states.every((state) => state.running && state.managed)).toBe(true)
125
+ expect(states[0]!.pid).toBe(states[1]!.pid)
126
+ } finally {
127
+ await Effect.runPromise(managers[0]!.stop)
128
+ rmSync(root, { recursive: true, force: true })
129
+ }
130
+ }, 30000)
131
+
132
+ test("ensure preserves a live daemon when its ingest readiness budget expires", async () => {
133
+ const root = mkdtempSync(join(tmpdir(), "motel-busy-ensure-"))
134
+ const databasePath = join(root, "db.sqlite")
135
+ const port = 37000 + Math.floor(Math.random() * 1000)
136
+ const manager = createDaemonManager({ runtimeDir: root, databasePath, port })
137
+ const impatient = createDaemonManager({ runtimeDir: root, databasePath, port, startTimeoutMs: 150 })
138
+ let lock: Database | undefined
139
+ try {
140
+ const started = await Effect.runPromise(manager.ensure)
141
+ lock = new Database(databasePath)
142
+ lock.exec("BEGIN IMMEDIATE")
143
+ const write = fetch(`http://127.0.0.1:${port}/v1/logs`, {
144
+ method: "POST", headers: { "content-type": "application/json" },
145
+ body: JSON.stringify({ resourceLogs: [{ scopeLogs: [{ logRecords: [{ body: { stringValue: "blocked writer" } }] }] }] }),
146
+ })
147
+ await sleep(30)
148
+ await expect(Effect.runPromise(impatient.ensure)).rejects.toThrow("process was preserved")
149
+ const health = await (await fetch(`http://127.0.0.1:${port}/api/health`)).json() as { pid: number }
150
+ expect(started.pid).toBe(health.pid)
151
+ lock.exec("ROLLBACK")
152
+ expect((await write).status).toBe(200)
153
+ expect((await Effect.runPromise(manager.ensure)).pid).toBe(started.pid)
154
+ } finally {
155
+ try { lock?.exec("ROLLBACK") } catch {}
156
+ lock?.close()
157
+ await Effect.runPromise(manager.stop)
158
+ rmSync(root, { recursive: true, force: true })
159
+ }
160
+ }, 15000)
@@ -0,0 +1,100 @@
1
+ import { Context, Schema } from "effect"
2
+
3
+ const Checkpoint = Schema.Struct({
4
+ busy: Schema.Number, log: Schema.Number, checkpointed: Schema.Number, deferred: Schema.Boolean,
5
+ })
6
+
7
+ const Maintenance = Schema.Struct({
8
+ operation: Schema.String,
9
+ startedAt: Schema.Number,
10
+ durationMs: Schema.NullOr(Schema.Number),
11
+ outcome: Schema.Literals(["running", "completed", "failed"]),
12
+ })
13
+
14
+ /** Cached writer diagnostics; reading this snapshot never dispatches SQLite work. */
15
+ export const IngestReadiness = Schema.Struct({
16
+ state: Schema.Literals(["starting", "ready", "overloaded", "failed"]),
17
+ outstandingRequests: Schema.Number,
18
+ outstandingBytes: Schema.Number,
19
+ oldestRequestAgeMs: Schema.Number,
20
+ lastCommitAt: Schema.NullOr(Schema.Number),
21
+ committedRecords: Schema.Number,
22
+ maintenance: Schema.NullOr(Maintenance),
23
+ checkpoint: Schema.NullOr(Checkpoint),
24
+ })
25
+ export type IngestReadiness = typeof IngestReadiness.Type
26
+
27
+ export const WriterEvent = Schema.TaggedUnion({
28
+ ready: {},
29
+ checkpoint: { value: Checkpoint },
30
+ commit: { records: Schema.Number, at: Schema.Number },
31
+ maintenance: { value: Maintenance },
32
+ })
33
+ export type WriterEvent = typeof WriterEvent.Type
34
+
35
+ /** Worker-local publisher defaults to no-op for standalone store consumers. */
36
+ export const WriterDiagnostics = Context.Reference<(event: WriterEvent) => void>("motel/WriterDiagnostics", {
37
+ defaultValue: (): ((event: WriterEvent) => void) => () => {},
38
+ })
39
+
40
+ /** Tracks awaiting callers, not durable queued work. Cancellation does not undo a commit. */
41
+ export class IngestProgress {
42
+ private ready = false
43
+ private failed = false
44
+ private readonly requests = new Map<symbol, { at: number; bytes: number }>()
45
+ private lastCommitAt: number | null = null
46
+ private committedRecords = 0
47
+ private checkpoint: IngestReadiness["checkpoint"] = null
48
+ private maintenance: IngestReadiness["maintenance"] = null
49
+ private readonly activeMaintenance = new Map<string, NonNullable<IngestReadiness["maintenance"]>>()
50
+
51
+ begin(bytes: number, now = Date.now()) {
52
+ const id = Symbol()
53
+ this.requests.set(id, { at: now, bytes })
54
+ return () => { this.requests.delete(id) }
55
+ }
56
+
57
+ fail() { this.failed = true }
58
+
59
+ receive(event: WriterEvent) {
60
+ switch (event._tag) {
61
+ case "checkpoint": this.checkpoint = event.value; break
62
+ case "ready": this.ready = true; break
63
+ case "commit":
64
+ if (event.records > 0) {
65
+ this.lastCommitAt = event.at
66
+ this.committedRecords += event.records
67
+ }
68
+ break
69
+ case "maintenance": {
70
+ const value = event.value
71
+ if (value.outcome === "running") this.activeMaintenance.set(value.operation, value)
72
+ else {
73
+ if (this.activeMaintenance.get(value.operation)?.startedAt === value.startedAt) this.activeMaintenance.delete(value.operation)
74
+ this.maintenance = value
75
+ }
76
+ break
77
+ }
78
+ }
79
+ }
80
+
81
+ snapshot(now = Date.now()): IngestReadiness {
82
+ let oldestRequestAgeMs = 0
83
+ let outstandingBytes = 0
84
+ for (const request of this.requests.values()) {
85
+ oldestRequestAgeMs = Math.max(oldestRequestAgeMs, now - request.at)
86
+ outstandingBytes += request.bytes
87
+ }
88
+ return {
89
+ state: this.failed ? "failed" : !this.ready ? "starting" :
90
+ oldestRequestAgeMs >= 5000 || this.requests.size >= 64 ? "overloaded" : "ready",
91
+ outstandingRequests: this.requests.size,
92
+ outstandingBytes,
93
+ oldestRequestAgeMs,
94
+ lastCommitAt: this.lastCommitAt,
95
+ committedRecords: this.committedRecords,
96
+ maintenance: [...this.activeMaintenance.values()].sort((a, b) => a.startedAt - b.startedAt)[0] ?? this.maintenance,
97
+ checkpoint: this.checkpoint,
98
+ }
99
+ }
100
+ }
@@ -12,6 +12,7 @@ import { AI_LIST, LOG_LIST, LOG_STATS, SPAN_LIST, TRACE_LIST, TRACE_STATS, listM
12
12
  import { MOTEL_SERVICE_ID, MOTEL_VERSION, processIdentity, removeRegistryEntry, writeRegistryEntry } from "./registry.js"
13
13
  import { AsyncIngest, AsyncIngestLive } from "./services/AsyncIngest.js"
14
14
  import { TelemetryStoreReadonly } from "./services/TelemetryStore.js"
15
+ import { QueryOverloaded, QueryDeadlineExceeded, QueryUnavailable } from "./services/queryRpc.js"
15
16
  import { TelemetryQueryLive } from "./services/TelemetryQuery.js"
16
17
  import type { LogItem, TraceItem } from "./domain.js"
17
18
  import { lifecycleLabel } from "./ui/format.js"
@@ -48,14 +49,21 @@ const withRead = <A>(f: (store: TelemetryStoreReadonly["Service"]) => Effect.Eff
48
49
  // Response-building helpers are generic in R so a handler can depend
49
50
  // on AsyncIngest (worker-RPC path) or TelemetryStoreReadonly (query
50
51
  // path) without forcing every handler onto the same service surface.
52
+ const queryErrorResponse = (error: unknown) => {
53
+ const message = error instanceof Error ? error.message : String(error)
54
+ if (error instanceof QueryOverloaded) return jsonResponse({ error: message, code: "QUERY_OVERLOADED" }, 503)
55
+ if (error instanceof QueryDeadlineExceeded) return jsonResponse({ error: message, code: "QUERY_DEADLINE" }, 504)
56
+ if (error instanceof QueryUnavailable) return jsonResponse({ error: message, code: "QUERY_UNAVAILABLE" }, 503)
57
+ return jsonResponse({ error: message }, 500)
58
+ }
51
59
  const respondJson = <A, R>(effect: Effect.Effect<A, unknown, R>) =>
52
60
  Effect.match(effect, {
53
- onFailure: (error) => jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500),
61
+ onFailure: queryErrorResponse,
54
62
  onSuccess: (value) => jsonResponse(value),
55
63
  })
56
64
  const respondRaw = <R>(effect: Effect.Effect<ReturnType<typeof jsonResponse>, unknown, R>) =>
57
65
  Effect.match(effect, {
58
- onFailure: (error) => jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500),
66
+ onFailure: queryErrorResponse,
59
67
  onSuccess: (value) => value,
60
68
  })
61
69
 
@@ -66,12 +74,14 @@ const readOtlpBody = <T>(
66
74
  readonly headers: Readonly<Record<string, string | undefined>>
67
75
  },
68
76
  decodeProtobuf: (bytes: Uint8Array) => T,
69
- ): Effect.Effect<T, unknown> => {
77
+ ): Effect.Effect<{ payload: T; bytes: number }, unknown> => {
70
78
  const contentType = (request.headers["content-type"] ?? "").toLowerCase()
71
- if (contentType.includes("application/x-protobuf") || contentType.includes("application/protobuf")) {
72
- return Effect.map(request.arrayBuffer, (buffer) => decodeProtobuf(new Uint8Array(buffer)))
73
- }
74
- return Effect.map(request.json, (payload) => payload as T)
79
+ return Effect.flatMap(request.arrayBuffer, (buffer) => Effect.try(() => ({
80
+ bytes: buffer.byteLength,
81
+ payload: contentType.includes("application/x-protobuf") || contentType.includes("application/protobuf")
82
+ ? decodeProtobuf(new Uint8Array(buffer))
83
+ : JSON.parse(new TextDecoder().decode(buffer)) as T,
84
+ })))
75
85
  }
76
86
 
77
87
  // Log page loader: takes the parsed list params + any resource-specific
@@ -191,6 +201,10 @@ const TelemetryGroupLive = HttpApiBuilder.group(
191
201
  .handle("health", () =>
192
202
  HttpMiddleware.withLoggerDisabled(Effect.succeed(healthPayload())),
193
203
  )
204
+ .handleRaw("readiness", () => HttpMiddleware.withLoggerDisabled(
205
+ Effect.flatMap(AsyncIngest, (ingest) => Effect.map(ingest.readiness,
206
+ (snapshot) => jsonResponse(snapshot, snapshot.state === "ready" ? 200 : 503))),
207
+ ))
194
208
  // OTLP ingest is routed to the worker thread via AsyncIngest
195
209
  // so the main event loop stays free during heavy SQLite writes.
196
210
  // Read queries use a separate query worker so synchronous SQLite
@@ -199,8 +213,8 @@ const TelemetryGroupLive = HttpApiBuilder.group(
199
213
  HttpMiddleware.withLoggerDisabled(respondRaw(
200
214
  Effect.flatMap(
201
215
  readOtlpBody<OtlpTraceExportRequest>(request, decodeProtobufTraces),
202
- (payload) => Effect.map(
203
- Effect.flatMap(AsyncIngest, (ingest) => ingest.ingestTraces({ payload })),
216
+ (input) => Effect.map(
217
+ Effect.flatMap(AsyncIngest, (ingest) => ingest.ingestTraces(input)),
204
218
  (result) => jsonResponse(result),
205
219
  ),
206
220
  ),
@@ -210,8 +224,8 @@ const TelemetryGroupLive = HttpApiBuilder.group(
210
224
  HttpMiddleware.withLoggerDisabled(respondRaw(
211
225
  Effect.flatMap(
212
226
  readOtlpBody<OtlpLogExportRequest>(request, decodeProtobufLogs),
213
- (payload) => Effect.map(
214
- Effect.flatMap(AsyncIngest, (ingest) => ingest.ingestLogs({ payload })),
227
+ (input) => Effect.map(
228
+ Effect.flatMap(AsyncIngest, (ingest) => ingest.ingestLogs(input)),
215
229
  (result) => jsonResponse(result),
216
230
  ),
217
231
  ),
@@ -551,15 +565,16 @@ export const ServerLive = HttpRouter.serve(
551
565
  // POSTs again on the next flush. This also shaves ~1 KB of header
552
566
  // attributes off every ingest request that would have been written
553
567
  // to the spans table as noise.
554
- Layer.provide(HttpMiddleware.layerTracerDisabledForUrls(["/api/health", "/v1/traces", "/v1/logs"])),
568
+ Layer.provide(HttpMiddleware.layerTracerDisabledForUrls(["/api/health", "/api/readiness", "/v1/traces", "/v1/logs"])),
555
569
  // The telemetry worker owns ingest, migrations, and bounded maintenance.
556
- // The HTTP thread only opens an existing database read-only (or bootstraps
557
- // a brand-new empty one), keeping health independent of writer work.
570
+ // Readonly subprocesses keep native SQLite calls off the HTTP event loop and
571
+ // can be terminated on deadline without retiring the ingestion worker.
558
572
  Layer.provideMerge(AsyncIngestLive),
559
573
  Layer.provideMerge(TelemetryQueryLive),
560
574
  Layer.provideMerge(BunHttpServer.layer({
561
575
  port: config.otel.port,
562
576
  hostname: config.otel.host,
577
+ idleTimeout: Math.ceil(config.otel.queryDeadlineMs / 1000) + 5,
563
578
  reusePort: true,
564
579
  routes: {
565
580
  "/api/health": () => Response.json(healthPayload()),
package/src/mcp.ts CHANGED
@@ -433,7 +433,8 @@ const ServerLayer = McpServer.toolkit(MotelToolkit).pipe(
433
433
  }),
434
434
  ),
435
435
  Layer.provide(BunStdio.layer),
436
- Layer.provide(Logger.layer([Logger.consolePretty({ stderr: true })])),
436
+ // stdout carries the MCP stdio protocol, so logs must go to stderr.
437
+ Layer.provide(Layer.merge(Logger.layer([Logger.consolePretty()]), Layer.succeed(Logger.LogToStderr, true))),
437
438
  )
438
439
 
439
440
  Layer.launch(ServerLayer).pipe(BunRuntime.runMain)
package/src/motel.ts CHANGED
@@ -40,7 +40,7 @@ const restart = Command.make("restart", {}, () =>
40
40
 
41
41
  const serviceManager = createLaunchAgentManager()
42
42
  const serviceInstall = Command.make("install", {
43
- replace: Flag.boolean("replace").pipe(Flag.withDefault(false)),
43
+ replace: Flag.Boolean("replace").pipe(Flag.withDefault(false)),
44
44
  }, ({ replace }) =>
45
45
  serviceManager.install(replace).pipe(Effect.andThen(json)),
46
46
  ).pipe(Command.withDescription("Install the per-user Motel LaunchAgent"))
@@ -0,0 +1,34 @@
1
+ import { expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { mkdtempSync, rmSync } from "node:fs"
4
+ import { tmpdir } from "node:os"
5
+ import { join } from "node:path"
6
+ import { createDaemonManager } from "./daemon.ts"
7
+
8
+ test("HTTP overload and deadline responses are typed and leave ingestion usable", async () => {
9
+ const root = mkdtempSync(join(tmpdir(), "motel-query-http-"))
10
+ const port = 38000 + Math.floor(Math.random() * 1000)
11
+ const manager = createDaemonManager({ runtimeDir: root, databasePath: join(root, "db.sqlite"), port })
12
+ const previousCapacity = process.env.MOTEL_OTEL_QUERY_CAPACITY
13
+ const previousDeadline = process.env.MOTEL_OTEL_QUERY_DEADLINE_MS
14
+ try {
15
+ process.env.MOTEL_OTEL_QUERY_CAPACITY = "1"
16
+ process.env.MOTEL_OTEL_QUERY_DEADLINE_MS = "25"
17
+ await Effect.runPromise(manager.ensure)
18
+ const responses = await Promise.all([1, 2].map(async () => {
19
+ const response = await fetch(`http://127.0.0.1:${port}/api/services`)
20
+ return { status: response.status, body: await response.json() as { code: string } }
21
+ }))
22
+ expect(responses.map((response) => response.status).sort()).toEqual([503, 504])
23
+ expect(responses.map((response) => response.body.code).sort()).toEqual(["QUERY_DEADLINE", "QUERY_OVERLOADED"])
24
+ expect((await fetch(`http://127.0.0.1:${port}/v1/logs`, { method: "POST", body: "{}", headers: { "content-type": "application/json" } })).status).toBe(200)
25
+ expect((await fetch(`http://127.0.0.1:${port}/api/readiness`)).status).toBe(200)
26
+ } finally {
27
+ if (previousCapacity === undefined) delete process.env.MOTEL_OTEL_QUERY_CAPACITY
28
+ else process.env.MOTEL_OTEL_QUERY_CAPACITY = previousCapacity
29
+ if (previousDeadline === undefined) delete process.env.MOTEL_OTEL_QUERY_DEADLINE_MS
30
+ else process.env.MOTEL_OTEL_QUERY_DEADLINE_MS = previousDeadline
31
+ await Effect.runPromise(manager.stop)
32
+ rmSync(root, { recursive: true, force: true })
33
+ }
34
+ }, 15000)
package/src/runtime.ts CHANGED
@@ -5,7 +5,9 @@ import { SimpleLogRecordProcessor } from "@opentelemetry/sdk-logs"
5
5
  import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"
6
6
  import { Layer, ManagedRuntime } from "effect"
7
7
  import { config } from "./config.js"
8
- import { TelemetryStoreLive, TelemetryStoreReadonlyLive } from "./services/TelemetryStore.js"
8
+ import { TelemetryStoreLive } from "./services/TelemetryStore.js"
9
+
10
+ import { TelemetryQueryLive } from "./services/TelemetryQuery.js"
9
11
 
10
12
  const telemetryLayer = NodeSdk.layer(() => ({
11
13
  spanProcessor: new SimpleSpanProcessor(
@@ -31,7 +33,7 @@ const telemetryLayer = NodeSdk.layer(() => ({
31
33
  // TUI-side runtime is readonly — a daemon/worker writer owns the DB
32
34
  // lock while ingests are in flight, and trying to grab the write lock
33
35
  // for schema init on startup causes "database is locked" on bun dev.
34
- const QueryRuntimeLive = config.otel.enabled ? Layer.mergeAll(TelemetryStoreReadonlyLive, telemetryLayer) : TelemetryStoreReadonlyLive
36
+ const QueryRuntimeLive = config.otel.enabled ? Layer.mergeAll(TelemetryQueryLive, telemetryLayer) : TelemetryQueryLive
35
37
 
36
38
  export const queryRuntime = ManagedRuntime.make(QueryRuntimeLive)
37
39
  // `storeRuntime` is the full writer runtime, exposed for the telemetry
@@ -20,11 +20,11 @@
20
20
  */
21
21
 
22
22
  import * as BunWorker from "@effect/platform-bun/BunWorker"
23
- import { Context, Effect, Layer, Scope } from "effect"
23
+ import { Context, Effect, Latch, Layer, Scope, Schema } from "effect"
24
24
  import * as RpcClient from "effect/unstable/rpc/RpcClient"
25
25
  import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"
26
- import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"
27
26
  import type { WorkerError } from "effect/unstable/workers/WorkerError"
27
+ import { IngestProgress, WriterEvent, type IngestReadiness } from "../ingestReadiness.ts"
28
28
  import { IngestRpcs } from "./ingestRpc.ts"
29
29
 
30
30
  // RpcClient.make always surfaces RpcClientError in addition to the
@@ -34,23 +34,43 @@ import { IngestRpcs } from "./ingestRpc.ts"
34
34
  // unrelated structural mismatches.
35
35
  export class AsyncIngest extends Context.Service<
36
36
  AsyncIngest,
37
- RpcClient.FromGroup<typeof IngestRpcs, RpcClientError | WorkerError>
37
+ RpcClient.FromGroup<typeof IngestRpcs, RpcClientError | WorkerError> & { readonly readiness: Effect.Effect<IngestReadiness> }
38
38
  >()("@motel/AsyncIngest") {}
39
39
 
40
- // Protocol: RpcClient.layerProtocolWorker manages a worker pool and
41
- // speaks msgpack over structured-clone messages. `size: 1` matches
42
- // SQLite's single-writer constraint.
43
- const WorkerProtocol = RpcClient.layerProtocolWorker({ size: 1 }).pipe(
44
- Layer.provide(RpcSerialization.layerMsgPack),
45
- Layer.provide(
46
- BunWorker.layer(() => new Worker(new URL("./telemetryWorker.ts", import.meta.url))),
47
- ),
48
- )
49
-
50
40
  export const AsyncIngestLive = Layer.effect(
51
41
  AsyncIngest,
52
42
  Effect.gen(function*() {
53
43
  const scope = yield* Scope.Scope
44
+ const progress = new IngestProgress()
45
+ const channelName = `motel-ingest-${crypto.randomUUID()}`
46
+ const channel = yield* Effect.acquireRelease(
47
+ Effect.sync(() => new BroadcastChannel(channelName)),
48
+ (channel) => Effect.sync(() => channel.close()),
49
+ )
50
+ channel.onmessage = ({ data }) => {
51
+ const event = Schema.decodeUnknownSync(WriterEvent)(data)
52
+ progress.receive(event)
53
+ }
54
+ // Opens once the current worker has answered the platform handshake (or
55
+ // died). Effect's worker close message is only received after that
56
+ // handshake; one posted earlier is dropped and the platform finalizer
57
+ // then waits five seconds before terminating the worker.
58
+ let handshake = Latch.makeUnsafe(true)
59
+ const WorkerProtocol = RpcClient.layerProtocolWorker({ size: 1 }).pipe(
60
+ Layer.provide(BunWorker.layer(() => {
61
+ const worker = new Worker(new URL("./telemetryWorker.ts", import.meta.url), {
62
+ env: { ...process.env, MOTEL_INGEST_DIAGNOSTICS_CHANNEL: channelName },
63
+ })
64
+ const spawned = Latch.makeUnsafe(false)
65
+ handshake = spawned
66
+ const settle = () => spawned.openUnsafe()
67
+ worker.addEventListener("message", settle, { once: true })
68
+ worker.addEventListener("error", () => { settle(); progress.fail() })
69
+ worker.addEventListener("close", () => { settle(); progress.fail() })
70
+ return worker
71
+ })),
72
+ )
73
+
54
74
  // Keep daemon startup cheap: creating the RPC client here would eagerly
55
75
  // spawn the worker and make /api/health wait on the worker's SQLite
56
76
  // bootstrap. Cache a lazy initializer instead so the worker only starts
@@ -59,10 +79,14 @@ export const AsyncIngestLive = Layer.effect(
59
79
  // failures must not replace it: concurrent stale failures can otherwise
60
80
  // invalidate a newer cached generation and orphan its SQLite writer and
61
81
  // maintenance fibers. A poisoned worker stays failed until the managed
62
- // daemon is restarted, which the external ingest readiness probe handles.
82
+ // daemon is restarted, and diagnostics expose that failure for explicit operator recovery.
63
83
  const getClient = yield* Effect.cached(Effect.gen(function*() {
64
84
  const clientScope = yield* Scope.fork(scope, "sequential")
65
85
  const protocolContext = yield* Layer.buildWithScope(WorkerProtocol, clientScope)
86
+ // Added after the protocol's finalizers, so it runs before them: a
87
+ // shutdown during worker startup waits for the handshake and then
88
+ // closes the writer gracefully instead of timing out and terminating it.
89
+ yield* Scope.addFinalizer(clientScope, Effect.suspend(() => handshake.await).pipe(Effect.timeoutOption("5 seconds")))
66
90
  return yield* RpcClient.make(IngestRpcs).pipe(
67
91
  Effect.provide(protocolContext),
68
92
  Effect.provideService(Scope.Scope, clientScope),
@@ -71,10 +95,17 @@ export const AsyncIngestLive = Layer.effect(
71
95
  // Start the sole writer/maintenance worker immediately, but do not make
72
96
  // HTTP health wait for SQLite bootstrap. Managed readiness still verifies
73
97
  // the worker through explicit ingest probes.
74
- yield* Effect.forkScoped(getClient.pipe(Effect.ignore))
98
+ yield* Effect.forkScoped(getClient.pipe(Effect.tapCause(() => Effect.sync(() => progress.fail())), Effect.ignore))
99
+ const track = <A, E, R>(bytes: number, effect: Effect.Effect<A, E, R>) =>
100
+ Effect.acquireUseRelease(
101
+ Effect.sync(() => progress.begin(bytes)),
102
+ () => effect,
103
+ (finish) => Effect.sync(finish),
104
+ )
75
105
  return {
76
- ingestTraces: (input, options) => Effect.flatMap(getClient, (client) => client.ingestTraces(input, options)),
77
- ingestLogs: (input, options) => Effect.flatMap(getClient, (client) => client.ingestLogs(input, options)),
106
+ readiness: Effect.sync(() => progress.snapshot()),
107
+ ingestTraces: (input, options) => track(input.bytes ?? 0, Effect.flatMap(getClient, (client) => client.ingestTraces(input, options))),
108
+ ingestLogs: (input, options) => track(input.bytes ?? 0, Effect.flatMap(getClient, (client) => client.ingestLogs(input, options))),
78
109
  }
79
110
  }),
80
111
  )
@@ -1,39 +1,26 @@
1
- import * as BunWorker from "@effect/platform-bun/BunWorker"
2
- import { Effect, Layer, Scope } from "effect"
3
- import * as RpcClient from "effect/unstable/rpc/RpcClient"
4
- import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"
5
- import type { WorkerError } from "effect/unstable/workers/WorkerError"
6
- import type { RpcClientError } from "effect/unstable/rpc/RpcClientError"
1
+ import { Effect, Layer } from "effect"
2
+ import { config } from "../config.js"
7
3
  import { TelemetryStoreReadonly, type TelemetryStoreReader } from "./TelemetryStore.js"
8
- import { QueryRpcs } from "./queryRpc.js"
9
-
10
- type QueryMethod = keyof TelemetryStoreReader
11
- type QueryClient = RpcClient.FromGroup<typeof QueryRpcs, RpcClientError | WorkerError>
12
-
13
- const WorkerProtocol = RpcClient.layerProtocolWorker({ size: 1 }).pipe(
14
- Layer.provide(RpcSerialization.layerMsgPack),
15
- Layer.provide(BunWorker.layer(() => new Worker(new URL("./telemetryQueryWorker.ts", import.meta.url)))),
16
- )
17
-
18
- const query = <A>(getClient: Effect.Effect<QueryClient, unknown>, method: QueryMethod, args: readonly unknown[] = []) =>
19
- Effect.flatMap(getClient, (client) => client.query({ method, args })).pipe(
20
- Effect.map((result) => result as A),
21
- Effect.mapError((error) => error instanceof Error ? error : new Error(String(error))),
22
- )
4
+ import { QueryScheduler } from "./queryScheduler.js"
5
+ import { QueryError, QueryOverloaded, QueryDeadlineExceeded, QueryUnavailable } from "./queryRpc.js"
23
6
 
24
7
  export const TelemetryQueryLive = Layer.effect(
25
8
  TelemetryStoreReadonly,
26
9
  Effect.gen(function*() {
27
- const scope = yield* Scope.Scope
28
- const getClient = yield* Effect.cached(Effect.gen(function*() {
29
- const clientScope = yield* Scope.fork(scope, "sequential")
30
- const protocolContext = yield* Layer.buildWithScope(WorkerProtocol, clientScope)
31
- return yield* RpcClient.make(QueryRpcs).pipe(
32
- Effect.provide(protocolContext),
33
- Effect.provideService(Scope.Scope, clientScope),
34
- )
35
- }))
36
- const run = <A>(method: QueryMethod, args: readonly unknown[] = []) => query<A>(getClient, method, args)
10
+ const scheduler = yield* Effect.acquireRelease(
11
+ Effect.sync(() => new QueryScheduler({
12
+ workerUrl: new URL("./telemetryQueryProcess.ts", import.meta.url),
13
+ capacity: config.otel.queryCapacity,
14
+ environment: { ...process.env, MOTEL_OTEL_DB_PATH: config.otel.databasePath },
15
+ deadlineMs: config.otel.queryDeadlineMs,
16
+ })),
17
+ (scheduler) => Effect.promise(() => scheduler.close()),
18
+ )
19
+ const run = <A>(method: keyof TelemetryStoreReader, args: readonly unknown[] = []) => Effect.tryPromise({
20
+ try: (signal) => scheduler.query(method, args, signal) as Promise<A>,
21
+ catch: (error) => error instanceof QueryError || error instanceof QueryOverloaded || error instanceof QueryDeadlineExceeded || error instanceof QueryUnavailable
22
+ ? error : new QueryUnavailable({ message: String(error) }),
23
+ })
37
24
  return TelemetryStoreReadonly.of({
38
25
  listServices: run("listServices"),
39
26
  listRecentTraces: (serviceName, options) => run("listRecentTraces", [serviceName, options]),