@aryasaatvik/motel 0.3.5 → 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.
@@ -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()),
@@ -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,12 @@
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, 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
26
  import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"
27
27
  import type { WorkerError } from "effect/unstable/workers/WorkerError"
28
+ import { IngestProgress, WriterEvent, type IngestReadiness } from "../ingestReadiness.ts"
28
29
  import { IngestRpcs } from "./ingestRpc.ts"
29
30
 
30
31
  // RpcClient.make always surfaces RpcClientError in addition to the
@@ -34,23 +35,35 @@ import { IngestRpcs } from "./ingestRpc.ts"
34
35
  // unrelated structural mismatches.
35
36
  export class AsyncIngest extends Context.Service<
36
37
  AsyncIngest,
37
- RpcClient.FromGroup<typeof IngestRpcs, RpcClientError | WorkerError>
38
+ RpcClient.FromGroup<typeof IngestRpcs, RpcClientError | WorkerError> & { readonly readiness: Effect.Effect<IngestReadiness> }
38
39
  >()("@motel/AsyncIngest") {}
39
40
 
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
41
  export const AsyncIngestLive = Layer.effect(
51
42
  AsyncIngest,
52
43
  Effect.gen(function*() {
53
44
  const scope = yield* Scope.Scope
45
+ const progress = new IngestProgress()
46
+ const channelName = `motel-ingest-${crypto.randomUUID()}`
47
+ const channel = yield* Effect.acquireRelease(
48
+ Effect.sync(() => new BroadcastChannel(channelName)),
49
+ (channel) => Effect.sync(() => channel.close()),
50
+ )
51
+ channel.onmessage = ({ data }) => {
52
+ const event = Schema.decodeUnknownSync(WriterEvent)(data)
53
+ progress.receive(event)
54
+ }
55
+ const WorkerProtocol = RpcClient.layerProtocolWorker({ size: 1 }).pipe(
56
+ Layer.provide(RpcSerialization.layerMsgPack),
57
+ Layer.provide(BunWorker.layer(() => {
58
+ const worker = new Worker(new URL("./telemetryWorker.ts", import.meta.url), {
59
+ env: { ...process.env, MOTEL_INGEST_DIAGNOSTICS_CHANNEL: channelName },
60
+ })
61
+ worker.addEventListener("error", () => progress.fail())
62
+ worker.addEventListener("close", () => progress.fail())
63
+ return worker
64
+ })),
65
+ )
66
+
54
67
  // Keep daemon startup cheap: creating the RPC client here would eagerly
55
68
  // spawn the worker and make /api/health wait on the worker's SQLite
56
69
  // bootstrap. Cache a lazy initializer instead so the worker only starts
@@ -59,7 +72,7 @@ export const AsyncIngestLive = Layer.effect(
59
72
  // failures must not replace it: concurrent stale failures can otherwise
60
73
  // invalidate a newer cached generation and orphan its SQLite writer and
61
74
  // maintenance fibers. A poisoned worker stays failed until the managed
62
- // daemon is restarted, which the external ingest readiness probe handles.
75
+ // daemon is restarted, and diagnostics expose that failure for explicit operator recovery.
63
76
  const getClient = yield* Effect.cached(Effect.gen(function*() {
64
77
  const clientScope = yield* Scope.fork(scope, "sequential")
65
78
  const protocolContext = yield* Layer.buildWithScope(WorkerProtocol, clientScope)
@@ -71,10 +84,17 @@ export const AsyncIngestLive = Layer.effect(
71
84
  // Start the sole writer/maintenance worker immediately, but do not make
72
85
  // HTTP health wait for SQLite bootstrap. Managed readiness still verifies
73
86
  // the worker through explicit ingest probes.
74
- yield* Effect.forkScoped(getClient.pipe(Effect.ignore))
87
+ yield* Effect.forkScoped(getClient.pipe(Effect.tapCause(() => Effect.sync(() => progress.fail())), Effect.ignore))
88
+ const track = <A, E, R>(bytes: number, effect: Effect.Effect<A, E, R>) =>
89
+ Effect.acquireUseRelease(
90
+ Effect.sync(() => progress.begin(bytes)),
91
+ () => effect,
92
+ (finish) => Effect.sync(finish),
93
+ )
75
94
  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)),
95
+ readiness: Effect.sync(() => progress.snapshot()),
96
+ ingestTraces: (input, options) => track(input.bytes ?? 0, Effect.flatMap(getClient, (client) => client.ingestTraces(input, options))),
97
+ ingestLogs: (input, options) => track(input.bytes ?? 0, Effect.flatMap(getClient, (client) => client.ingestLogs(input, options))),
78
98
  }
79
99
  }),
80
100
  )
@@ -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]),