@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.
- package/AGENTS.md +4 -1
- package/README.md +51 -0
- package/package.json +1 -1
- package/src/config.ts +3 -0
- package/src/daemon.ts +69 -35
- package/src/httpApi.ts +28 -9
- package/src/ingestReadiness.test.ts +160 -0
- package/src/ingestReadiness.ts +100 -0
- package/src/localServer.ts +29 -14
- package/src/queryHttp.test.ts +34 -0
- package/src/runtime.ts +4 -2
- package/src/services/AsyncIngest.ts +36 -16
- package/src/services/TelemetryQuery.ts +18 -31
- package/src/services/TelemetryStore.ts +160 -259
- package/src/services/ingestRpc.ts +2 -2
- package/src/services/queryRpc.ts +12 -12
- package/src/services/queryScheduler.test.ts +102 -0
- package/src/services/queryScheduler.ts +157 -0
- package/src/services/retention.test.ts +170 -0
- package/src/services/retention.ts +158 -0
- package/src/services/telemetryQueryProcess.ts +10 -0
- package/src/services/telemetryQueryWorker.ts +25 -26
- package/src/services/telemetryWorker.ts +9 -1
- package/src/telemetry.test.ts +34 -1
- package/src/ui/app/useTraceScreenData.ts +11 -17
- package/src/ui/cachedLoader.test.ts +20 -0
- package/src/ui/cachedLoader.ts +1 -2
- package/src/ui/loaders.ts +17 -6
- package/web/dist/assets/index-CN7ClqOl.js +21 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-5mTXbtNw.js +0 -21
|
@@ -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
|
+
}
|
package/src/localServer.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
(
|
|
203
|
-
Effect.flatMap(AsyncIngest, (ingest) => ingest.ingestTraces(
|
|
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
|
-
(
|
|
214
|
-
Effect.flatMap(AsyncIngest, (ingest) => ingest.ingestLogs(
|
|
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
|
-
//
|
|
557
|
-
//
|
|
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
|
|
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(
|
|
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,
|
|
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
|
-
|
|
77
|
-
|
|
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
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
const run = <A>(method:
|
|
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]),
|