@aryasaatvik/motel 0.3.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -2
- package/README.md +51 -0
- package/package.json +1 -1
- package/src/config.ts +7 -1
- 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 +166 -264
- 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/storybook/fixtures/index.ts +2 -2
- package/src/telemetry.test.ts +108 -0
- package/src/ui/app/useTraceScreenData.ts +11 -17
- package/src/ui/atoms.ts +2 -2
- package/src/ui/cachedLoader.test.ts +20 -0
- package/src/ui/cachedLoader.ts +1 -2
- package/src/ui/loaders.ts +17 -6
- package/src/ui/serviceSelection.test.ts +50 -0
- 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
package/AGENTS.md
CHANGED
|
@@ -97,7 +97,7 @@ The repo is wired up with `@effect/language-service` as a `tsconfig.json` `plugi
|
|
|
97
97
|
- `src/server.ts` runs the local server without the TUI.
|
|
98
98
|
- `src/instructions.ts` contains the copied setup instructions for other Effect apps.
|
|
99
99
|
- `src/services/TelemetryStore.ts` persists traces and logs in SQLite and exposes indexed queries through writer and read-only service identifiers.
|
|
100
|
-
- `src/services/TelemetryQuery.ts`
|
|
100
|
+
- `src/services/TelemetryQuery.ts` schedules read-only store calls through a bounded queue and a disposable subprocess containing `src/services/telemetryQueryWorker.ts`. Deadlines and cancellation terminate that process to release synchronous SQLite readers; the HTTP server and TUI share this architecture.
|
|
101
101
|
- `src/config.ts` is the source of truth for ports and env-driven OTEL settings.
|
|
102
102
|
- `web/` is a Vite + React SPA for the browser-based UI (Tailwind CSS, `@effect/atom-react`, `AtomHttpApi`).
|
|
103
103
|
- `web/src/api.ts` creates the typed `AtomHttpApi.Service` client from `src/httpApi.ts`.
|
|
@@ -131,7 +131,7 @@ The repo is wired up with `@effect/language-service` as a `tsconfig.json` `plugi
|
|
|
131
131
|
|
|
132
132
|
## Env Vars
|
|
133
133
|
- `MOTEL_OTEL_ENABLED`: defaults to `false` (set to `true` to emit self-traces for debugging motel itself)
|
|
134
|
-
- `MOTEL_OTEL_SERVICE_NAME`: defaults to `motel-otel-tui
|
|
134
|
+
- `MOTEL_OTEL_SERVICE_NAME`: defaults to `motel-otel-tui`; a nonblank explicit value also selects the initial TUI service, overriding the remembered selection (unset or blank values keep the remembered selection)
|
|
135
135
|
- `MOTEL_OTEL_BASE_URL`: defaults to `http://127.0.0.1:27686`
|
|
136
136
|
- `MOTEL_OTEL_HOST`: defaults to `127.0.0.1`
|
|
137
137
|
- `MOTEL_OTEL_PORT`: defaults to `27686`
|
|
@@ -147,6 +147,9 @@ The repo is wired up with `@effect/language-service` as a `tsconfig.json` `plugi
|
|
|
147
147
|
- `MOTEL_OTEL_MAX_DB_SIZE_MB`: defaults to `1024` (size-based retention cap)
|
|
148
148
|
- `MOTEL_OTEL_RETENTION_TRACE_BATCH`: defaults to `100` completed traces per cleanup pass
|
|
149
149
|
- `MOTEL_OTEL_RETENTION_LOG_BATCH`: defaults to `5000` logs per cleanup pass
|
|
150
|
+
- `MOTEL_OTEL_QUERY_CAPACITY`: defaults to `8` outstanding queries per runtime (maximum `64`)
|
|
151
|
+
- `MOTEL_OTEL_QUERY_DEADLINE_MS`: defaults to `5000` including queue and startup time (maximum `30000`)
|
|
152
|
+
- `MOTEL_OTEL_RETENTION_ROW_BATCH`: defaults to `1000` physical rows per cleanup pass
|
|
150
153
|
- `MOTEL_OTEL_RETENTION_INTERVAL_SECONDS`: defaults to `10`
|
|
151
154
|
|
|
152
155
|
## TUI Keys
|
package/README.md
CHANGED
|
@@ -135,3 +135,54 @@ telemetry. Correlated logs may include secrets, tokens, or PII if your
|
|
|
135
135
|
app logs them; AI call traces may include full prompt content and
|
|
136
136
|
response text. Treat the local SQLite store as sensitive development
|
|
137
137
|
data when pointing motel at real workloads.
|
|
138
|
+
|
|
139
|
+
### Ingestion readiness
|
|
140
|
+
|
|
141
|
+
`GET /api/health` reports process identity and liveness. `GET /api/readiness` reports cached
|
|
142
|
+
writer diagnostics without querying SQLite or joining the ingestion queue. It returns HTTP
|
|
143
|
+
200 for `ready`, and 503 for `starting`, `overloaded`, or `failed`. `motel status` includes this
|
|
144
|
+
snapshot when the daemon supports it; older servers remain compatible.
|
|
145
|
+
|
|
146
|
+
`overloaded` means an awaiting request is at least five seconds old or at least 64 requests
|
|
147
|
+
are outstanding. This is a pressure signal, not an instruction to restart a shared daemon.
|
|
148
|
+
Idle time does not imply failure. `failed` records worker exit/error and requires operator
|
|
149
|
+
inspection. Outstanding counts/bytes describe awaiting callers, not durable queued data;
|
|
150
|
+
client cancellation cannot undo a SQLite commit. Empty probes do not advance `lastCommitAt`.
|
|
151
|
+
Maintenance reports its latest operation, duration, and outcome without telemetry payloads.
|
|
152
|
+
|
|
153
|
+
Run `bun scripts/bench-shared-daemon.ts` for an isolated reduced-cap mixed workload, or add
|
|
154
|
+
`--1gib` for the representative capacity profile. Both create and remove their own database,
|
|
155
|
+
include a held reader, and report request latency percentiles, failures, and database/WAL size.
|
|
156
|
+
They never benchmark the shared machine-global database.
|
|
157
|
+
|
|
158
|
+
Concurrent starts publish a complete shared lock atomically. Ensure operations preserve a live
|
|
159
|
+
daemon when ingestion is slow; explicit operator recovery remains available through `motel restart`.
|
|
160
|
+
An unreadable lock or interrupted stale-lock recovery fails closed and reports its path for inspection.
|
|
161
|
+
When maintenance operations overlap, readiness shows the oldest active operation before a completed one.
|
|
162
|
+
|
|
163
|
+
Retention marks completed traces and expired logs before removing dependent rows. Public query
|
|
164
|
+
results hide the whole marked trace, including correlated logs, while bounded cleanup proceeds.
|
|
165
|
+
Markers persist across restarts. Arrivals for a marked trace are discarded as part of its eviction
|
|
166
|
+
and do not count as inserted records; once cleanup finishes, later exports may recreate that trace.
|
|
167
|
+
Active traces are not selected. SQLite tables are internal storage; direct SQL consumers should
|
|
168
|
+
use the `retained_spans`, `retained_logs`, and `retained_trace_summaries` views for this visibility rule.
|
|
169
|
+
|
|
170
|
+
`MOTEL_OTEL_RETENTION_ROW_BATCH` defaults to 1000 logical rows per cleanup batch, split across
|
|
171
|
+
traces and logs. It bounds row cardinality, not the size of one telemetry field. Cleanup yields
|
|
172
|
+
100 ms between batches while making progress, then returns to the configured idle interval.
|
|
173
|
+
Routine checkpoints use PASSIVE and report busy/frame counts in readiness; physical reclamation
|
|
174
|
+
is deferred for held readers. The configured database target and WAL size limit are not hard caps.
|
|
175
|
+
Existing orphan attributes/FTS entries are repaired with bounded keyset scans; normal ingests
|
|
176
|
+
maintain reverse FTS mappings for indexed cleanup.
|
|
177
|
+
|
|
178
|
+
Queries use one readonly subprocess at a time, with FIFO admission capped at eight outstanding
|
|
179
|
+
requests by default (`MOTEL_OTEL_QUERY_CAPACITY`, maximum 64). The five-second deadline includes
|
|
180
|
+
queue and startup time (`MOTEL_OTEL_QUERY_DEADLINE_MS`, maximum 30000). Overload returns HTTP 503
|
|
181
|
+
with `code: "QUERY_OVERLOADED"`; expiry returns 504 with `code: "QUERY_DEADLINE"`; process failure
|
|
182
|
+
returns 503 with `code: "QUERY_UNAVAILABLE"`. Successful response shapes are unchanged.
|
|
183
|
+
|
|
184
|
+
Cancelling or expiring an executing query terminates its readonly process and waits for exit
|
|
185
|
+
before dispatching the next query. SQLite runs in a thread inside that process so an IPC-owner
|
|
186
|
+
disconnect can also terminate it if the daemon or TUI dies. Ingestion owns a separate worker.
|
|
187
|
+
The HTTP idle timeout exceeds the query deadline by five seconds. TUI refreshes share in-flight
|
|
188
|
+
loads, obsolete selections discard results, and facet values prefetch sequentially for two keys.
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -22,10 +22,13 @@ const parsedBaseUrl = new URL(baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`)
|
|
|
22
22
|
export const resolveOtelUrl = (path: string) => new URL(path.startsWith("/") ? path.slice(1) : path, parsedBaseUrl).toString()
|
|
23
23
|
const serverPort = parsePositiveInt(process.env.MOTEL_OTEL_PORT, Number.parseInt(parsedBaseUrl.port || "80", 10))
|
|
24
24
|
|
|
25
|
+
// Preserve whether a service was requested so the TUI can override remembered state.
|
|
26
|
+
export const explicitServiceName = process.env.MOTEL_OTEL_SERVICE_NAME?.trim() || null
|
|
27
|
+
|
|
25
28
|
export const config = {
|
|
26
29
|
otel: {
|
|
27
30
|
enabled: parseBoolean(process.env.MOTEL_OTEL_ENABLED, false),
|
|
28
|
-
serviceName:
|
|
31
|
+
serviceName: explicitServiceName ?? "motel-otel-tui",
|
|
29
32
|
baseUrl,
|
|
30
33
|
host: process.env.MOTEL_OTEL_HOST?.trim() || parsedBaseUrl.hostname,
|
|
31
34
|
port: serverPort,
|
|
@@ -36,9 +39,12 @@ export const config = {
|
|
|
36
39
|
traceLookbackMinutes: parsePositiveInt(process.env.MOTEL_OTEL_TRACE_LOOKBACK_MINUTES, 1440),
|
|
37
40
|
traceFetchLimit: parsePositiveInt(process.env.MOTEL_OTEL_TRACE_LIMIT, 100),
|
|
38
41
|
logFetchLimit: parsePositiveInt(process.env.MOTEL_OTEL_LOG_LIMIT, 80),
|
|
42
|
+
queryCapacity: Math.min(parsePositiveInt(process.env.MOTEL_OTEL_QUERY_CAPACITY, 8), 64),
|
|
43
|
+
queryDeadlineMs: Math.min(parsePositiveInt(process.env.MOTEL_OTEL_QUERY_DEADLINE_MS, 5_000), 30_000),
|
|
39
44
|
retentionHours: parsePositiveInt(process.env.MOTEL_OTEL_RETENTION_HOURS, 168),
|
|
40
45
|
maxDbSizeMb: parsePositiveInt(process.env.MOTEL_OTEL_MAX_DB_SIZE_MB, 1024),
|
|
41
46
|
retentionTraceBatch: parsePositiveInt(process.env.MOTEL_OTEL_RETENTION_TRACE_BATCH, 100),
|
|
47
|
+
retentionRowBatch: parsePositiveInt(process.env.MOTEL_OTEL_RETENTION_ROW_BATCH, 1_000),
|
|
42
48
|
retentionLogBatch: parsePositiveInt(process.env.MOTEL_OTEL_RETENTION_LOG_BATCH, 5_000),
|
|
43
49
|
retentionIntervalSeconds: parsePositiveInt(process.env.MOTEL_OTEL_RETENTION_INTERVAL_SECONDS, 10),
|
|
44
50
|
},
|
package/src/daemon.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs"
|
|
2
2
|
import { promises as fsp } from "node:fs"
|
|
3
3
|
import * as path from "node:path"
|
|
4
|
-
import { Effect } from "effect"
|
|
4
|
+
import { Effect, Schema } from "effect"
|
|
5
|
+
import { IngestReadiness } from "./ingestReadiness.ts"
|
|
5
6
|
import { isAlive, isManagedDaemonProcess, listAliveEntries, motelStateDir, MOTEL_SERVICE_ID, processIdentity, removeRegistryEntry, type RegistryEntry } from "./registry.js"
|
|
6
7
|
|
|
7
8
|
const DEFAULT_REPO_ROOT = path.resolve(import.meta.dir, "..")
|
|
@@ -58,6 +59,7 @@ type DaemonConfig = {
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
export type DaemonStatus = {
|
|
62
|
+
readonly readiness?: IngestReadiness
|
|
61
63
|
readonly running: boolean
|
|
62
64
|
readonly managed: boolean
|
|
63
65
|
readonly service: string | null
|
|
@@ -170,13 +172,13 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
170
172
|
}
|
|
171
173
|
}
|
|
172
174
|
|
|
173
|
-
const fetchIngestProbe = async () => {
|
|
175
|
+
const fetchIngestProbe = async (timeoutMs = INGEST_PROBE_TIMEOUT_MS) => {
|
|
174
176
|
try {
|
|
175
177
|
const postEmpty = (path: string) => fetch(`${config.baseUrl}${path}`, {
|
|
176
178
|
method: "POST",
|
|
177
179
|
headers: { "content-type": "application/json" },
|
|
178
180
|
body: "{}",
|
|
179
|
-
signal: AbortSignal.timeout(
|
|
181
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
180
182
|
})
|
|
181
183
|
const [traces, logs] = await Promise.all([postEmpty("/v1/traces"), postEmpty("/v1/logs")])
|
|
182
184
|
return traces.ok && logs.ok
|
|
@@ -198,47 +200,55 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
198
200
|
const readLock = async (): Promise<LockShape | null> => {
|
|
199
201
|
try {
|
|
200
202
|
const raw = await fsp.readFile(config.lockPath, "utf8")
|
|
201
|
-
|
|
203
|
+
const lock = JSON.parse(raw) as Partial<LockShape>
|
|
204
|
+
if (!Number.isInteger(lock.pid) || (lock.pid ?? 0) <= 0 || typeof lock.createdAt !== "string" ||
|
|
205
|
+
(lock.processIdentity !== undefined && typeof lock.processIdentity !== "string")) return null
|
|
206
|
+
return lock as LockShape
|
|
202
207
|
} catch {
|
|
203
208
|
return null
|
|
204
209
|
}
|
|
205
210
|
}
|
|
206
211
|
|
|
207
212
|
const removeStaleLock = async () => {
|
|
208
|
-
|
|
209
|
-
|
|
213
|
+
// Serialize stale-owner cleanup so two contenders cannot unlink a fresh
|
|
214
|
+
// replacement after both observed the same dead owner. Unknown lock contents
|
|
215
|
+
// fail closed: they may belong to an older writer still publishing its lock.
|
|
216
|
+
const recoveryPath = `${config.lockPath}.recovery`
|
|
217
|
+
try { await fsp.mkdir(recoveryPath) }
|
|
218
|
+
catch (error) {
|
|
219
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false
|
|
220
|
+
throw error
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
const current = await readLock()
|
|
224
|
+
if (!current) return false
|
|
225
|
+
if (current.processIdentity ? processIdentity(current.pid) === current.processIdentity : isAlive(current.pid)) return false
|
|
210
226
|
await fsp.rm(config.lockPath, { force: true })
|
|
211
227
|
return true
|
|
212
|
-
}
|
|
213
|
-
if (current.processIdentity ? processIdentity(current.pid) === current.processIdentity : isAlive(current.pid)) return false
|
|
214
|
-
await fsp.rm(config.lockPath, { force: true })
|
|
215
|
-
return true
|
|
228
|
+
} finally { await fsp.rmdir(recoveryPath) }
|
|
216
229
|
}
|
|
217
230
|
|
|
218
231
|
const acquireStartupLock = async () => {
|
|
219
232
|
const deadline = Date.now() + LOCK_TIMEOUT_MS
|
|
220
233
|
await fsp.mkdir(config.runtimeDir, { recursive: true })
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
234
|
+
const candidate = `${config.lockPath}.${process.pid}.${crypto.randomUUID()}`
|
|
235
|
+
const contents = JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString(), processIdentity: processIdentity(process.pid) ?? undefined } satisfies LockShape)
|
|
236
|
+
await fsp.writeFile(candidate, contents, { flag: "wx" })
|
|
237
|
+
try {
|
|
238
|
+
while (Date.now() < deadline) {
|
|
239
|
+
try {
|
|
240
|
+
// link is exclusive and publishes a complete file atomically. Unlike
|
|
241
|
+
// open("wx") followed by writeFile, there is no observable empty lock.
|
|
242
|
+
await fsp.link(candidate, config.lockPath)
|
|
243
|
+
return { release: () => fsp.rm(config.lockPath, { force: true }) }
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error
|
|
246
|
+
if (await removeStaleLock()) continue
|
|
247
|
+
await sleep(POLL_INTERVAL_MS)
|
|
232
248
|
}
|
|
233
|
-
} catch (error) {
|
|
234
|
-
const errno = error as NodeJS.ErrnoException
|
|
235
|
-
if (errno.code !== "EEXIST") throw error
|
|
236
|
-
if (await removeStaleLock()) continue
|
|
237
|
-
await sleep(POLL_INTERVAL_MS)
|
|
238
249
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
throw new Error(`Timed out waiting for daemon startup lock at ${config.lockPath}`)
|
|
250
|
+
throw new Error(`Timed out waiting for daemon startup lock at ${config.lockPath}. Inspect its owner and ${config.lockPath}.recovery before recovery.`)
|
|
251
|
+
} finally { await fsp.rm(candidate, { force: true }) }
|
|
242
252
|
}
|
|
243
253
|
|
|
244
254
|
const openLogFile = async () => {
|
|
@@ -321,8 +331,18 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
321
331
|
}
|
|
322
332
|
|
|
323
333
|
const mismatch = describeManagedMismatch(health)
|
|
334
|
+
let readiness: IngestReadiness | undefined
|
|
335
|
+
if (mismatch === null) {
|
|
336
|
+
try {
|
|
337
|
+
const response = await fetch(`${config.baseUrl}/api/readiness`, { signal: AbortSignal.timeout(timeoutMs) })
|
|
338
|
+
if (response.status === 200 || response.status === 503) {
|
|
339
|
+
readiness = Schema.decodeUnknownSync(IngestReadiness)(await response.json())
|
|
340
|
+
}
|
|
341
|
+
} catch { /* Older daemons can still answer the identity handshake. */ }
|
|
342
|
+
}
|
|
324
343
|
const managed = mismatch === null && registry?.pid === health.pid && registry.instanceId === health.instanceId && isManagedDaemonProcess(registry)
|
|
325
344
|
return {
|
|
345
|
+
readiness,
|
|
326
346
|
running: mismatch === null,
|
|
327
347
|
managed,
|
|
328
348
|
service: health.service,
|
|
@@ -340,6 +360,21 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
340
360
|
}
|
|
341
361
|
}
|
|
342
362
|
|
|
363
|
+
const awaitExistingIngest = async (existing: DaemonStatus): Promise<DaemonStatus> => {
|
|
364
|
+
if (existing.pid === process.pid) return existing
|
|
365
|
+
const deadline = Date.now() + startTimeoutMs
|
|
366
|
+
while (Date.now() < deadline) {
|
|
367
|
+
if (await fetchIngestProbe(Math.max(1, Math.min(INGEST_PROBE_TIMEOUT_MS, deadline - Date.now())))) {
|
|
368
|
+
const current = await getStatus()
|
|
369
|
+
if (current.running && current.managed) return current
|
|
370
|
+
break
|
|
371
|
+
}
|
|
372
|
+
if (existing.readiness?.state === "failed") break
|
|
373
|
+
await sleep(Math.min(POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())))
|
|
374
|
+
}
|
|
375
|
+
throw new Error("Existing managed Motel daemon is not ingest-ready. Its process was preserved; inspect `motel status` and recover explicitly.")
|
|
376
|
+
}
|
|
377
|
+
|
|
343
378
|
const ensure = async (): Promise<DaemonStatus> => {
|
|
344
379
|
// Use the patient timeout for the initial probe — this is the
|
|
345
380
|
// critical "is there already a daemon here?" check. A false
|
|
@@ -351,10 +386,11 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
351
386
|
// /api/health can stay healthy after the lazy ingest worker/RPC path
|
|
352
387
|
// has been poisoned by an interrupted request. Empty OTLP posts are
|
|
353
388
|
// side-effect free and exercise the same path real exporters need.
|
|
354
|
-
|
|
355
|
-
|
|
389
|
+
return awaitExistingIngest(existing)
|
|
390
|
+
}
|
|
391
|
+
if (!existing.running && existingEntry && isManagedDaemonProcess(existingEntry)) {
|
|
392
|
+
throw new Error("Registered Motel process is alive but health is unavailable. Its process was preserved; inspect `motel status` before recovery.")
|
|
356
393
|
}
|
|
357
|
-
if (!existing.running && existingEntry && isManagedDaemonProcess(existingEntry)) await stopPid(existingEntry)
|
|
358
394
|
if (existing.service !== null && existing.reason) {
|
|
359
395
|
throw new Error(existing.reason)
|
|
360
396
|
}
|
|
@@ -369,9 +405,7 @@ export const createDaemonManager = (options: DaemonOptions = {}): DaemonManager
|
|
|
369
405
|
// while the runtime warms up.
|
|
370
406
|
const rechecked = await getStatus(HEALTH_PATIENT_TIMEOUT_MS)
|
|
371
407
|
if (rechecked.managed && rechecked.running) {
|
|
372
|
-
|
|
373
|
-
const recheckedEntry = readRegistryEntry()
|
|
374
|
-
if (recheckedEntry) await stopPid(recheckedEntry)
|
|
408
|
+
return awaitExistingIngest(rechecked)
|
|
375
409
|
}
|
|
376
410
|
if (rechecked.service !== null && rechecked.reason) {
|
|
377
411
|
throw new Error(rechecked.reason)
|
package/src/httpApi.ts
CHANGED
|
@@ -6,7 +6,14 @@ import {
|
|
|
6
6
|
TraceItem, TraceSummaryItem, SpanItem, LogItem,
|
|
7
7
|
} from "./domain.js"
|
|
8
8
|
|
|
9
|
+
import { IngestReadiness } from "./ingestReadiness.js"
|
|
10
|
+
|
|
9
11
|
const ErrorResponse = Schema.Struct({ error: Schema.String })
|
|
12
|
+
const QueryFailures = [
|
|
13
|
+
Schema.Struct({ error: Schema.String, code: Schema.Literals(["QUERY_OVERLOADED", "QUERY_UNAVAILABLE"]) }).pipe(HttpApiSchema.status(503)),
|
|
14
|
+
Schema.Struct({ error: Schema.String, code: Schema.Literal("QUERY_DEADLINE") }).pipe(HttpApiSchema.status(504)),
|
|
15
|
+
] as const
|
|
16
|
+
|
|
10
17
|
const Meta = Schema.Struct({
|
|
11
18
|
limit: Schema.Number,
|
|
12
19
|
lookback: Schema.String,
|
|
@@ -80,6 +87,9 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
|
|
|
80
87
|
.annotate(OpenApi.Summary, "Health check and identity handshake")
|
|
81
88
|
.annotate(OpenApi.Description, "Returns liveness plus identity fields (pid, url, workdir, startedAt, version). Doubles as the MCP discovery handshake: clients compare the returned pid against a registry entry to detect stale registrations that now point at an impostor process on the same port."),
|
|
82
89
|
|
|
90
|
+
HttpApiEndpoint.get("readiness", "/api/readiness", { success: IngestReadiness, error: IngestReadiness.pipe(HttpApiSchema.status(503)) })
|
|
91
|
+
.annotate(OpenApi.Summary, "Cached writer readiness and progress; does not enqueue database work"),
|
|
92
|
+
|
|
83
93
|
HttpApiEndpoint.post("ingestTraces", "/v1/traces", {
|
|
84
94
|
payload: Schema.Unknown,
|
|
85
95
|
success: IngestTraceResponse,
|
|
@@ -94,7 +104,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
|
|
|
94
104
|
.annotate(OpenApi.Summary, "Ingest OTLP logs")
|
|
95
105
|
.annotate(OpenApi.Description, "Accepts OTLP HTTP log export requests and stores them in the local SQLite telemetry store."),
|
|
96
106
|
|
|
97
|
-
HttpApiEndpoint.get("services", "/api/services", { success: ServiceList })
|
|
107
|
+
HttpApiEndpoint.get("services", "/api/services", { success: ServiceList, error: QueryFailures })
|
|
98
108
|
.annotate(OpenApi.Summary, "List active services")
|
|
99
109
|
.annotate(OpenApi.Description, "Returns service names that have emitted spans or logs within the default lookback window. Use this to discover what services are reporting, then query their traces or logs."),
|
|
100
110
|
|
|
@@ -106,6 +116,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
|
|
|
106
116
|
cursor: CursorParam,
|
|
107
117
|
},
|
|
108
118
|
success: TraceSummaryList,
|
|
119
|
+
error: QueryFailures,
|
|
109
120
|
})
|
|
110
121
|
.annotate(OpenApi.Summary, "List recent traces")
|
|
111
122
|
.annotate(OpenApi.Description, "Returns compact trace summaries ordered by start time descending. Use /api/traces/{traceId} for the full span tree. Supports cursor pagination and applies default/max limit and lookback bounds."),
|
|
@@ -130,6 +141,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
|
|
|
130
141
|
cursor: CursorParam,
|
|
131
142
|
},
|
|
132
143
|
success: TraceSummaryList,
|
|
144
|
+
error: QueryFailures,
|
|
133
145
|
})
|
|
134
146
|
.annotate(OpenApi.Summary, "Search traces with filters")
|
|
135
147
|
.annotate(OpenApi.Description, "Search compact trace summaries with filters. Use /api/traces/{traceId} for full details. Supports cursor pagination, attr.<key> filters in the query string, and aiText for full-text search across LLM prompt/response content."),
|
|
@@ -146,7 +158,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
|
|
|
146
158
|
limit: LimitParam,
|
|
147
159
|
},
|
|
148
160
|
success: StatList,
|
|
149
|
-
error: ErrorResponse,
|
|
161
|
+
error: [ErrorResponse, ...QueryFailures],
|
|
150
162
|
})
|
|
151
163
|
.annotate(OpenApi.Summary, "Aggregate trace statistics")
|
|
152
164
|
.annotate(OpenApi.Description, "Returns grouped trace aggregates such as count, average duration, p95 duration, or error rate. Supports the same core filters as trace search plus groupBy dimensions like service, operation, status, and attr.<key>."),
|
|
@@ -156,7 +168,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
|
|
|
156
168
|
traceId: Schema.String.pipe(Schema.annotateKey({ description: "Full 32-character hex trace ID" })),
|
|
157
169
|
},
|
|
158
170
|
success: TraceResponse,
|
|
159
|
-
error: ErrorResponse,
|
|
171
|
+
error: [ErrorResponse, ...QueryFailures],
|
|
160
172
|
})
|
|
161
173
|
.annotate(OpenApi.Summary, "Get a single trace")
|
|
162
174
|
.annotate(OpenApi.Description, "Returns the full trace with all spans ordered by parent-child hierarchy. Returns 404 if the trace ID is not found or has expired."),
|
|
@@ -166,7 +178,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
|
|
|
166
178
|
traceId: Schema.String.pipe(Schema.annotateKey({ description: "Full 32-character hex trace ID" })),
|
|
167
179
|
},
|
|
168
180
|
success: HtmlText,
|
|
169
|
-
error: ErrorResponse,
|
|
181
|
+
error: [ErrorResponse, ...QueryFailures],
|
|
170
182
|
})
|
|
171
183
|
.annotate(OpenApi.Summary, "Render a browser trace page")
|
|
172
184
|
.annotate(OpenApi.Description, "Renders a simple HTML waterfall/log view for one trace, suitable for opening from the TUI or browser."),
|
|
@@ -181,6 +193,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
|
|
|
181
193
|
cursor: CursorParam,
|
|
182
194
|
},
|
|
183
195
|
success: LogList,
|
|
196
|
+
error: QueryFailures,
|
|
184
197
|
})
|
|
185
198
|
.annotate(OpenApi.Summary, "Get logs for a trace")
|
|
186
199
|
.annotate(OpenApi.Description, "Returns log records correlated with the given trace, across all spans. Ordered by timestamp descending. Supports cursor pagination and bounded lookback/limit defaults."),
|
|
@@ -190,6 +203,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
|
|
|
190
203
|
traceId: Schema.String.pipe(Schema.annotateKey({ description: "Full 32-character hex trace ID" })),
|
|
191
204
|
},
|
|
192
205
|
success: SpanList,
|
|
206
|
+
error: QueryFailures,
|
|
193
207
|
})
|
|
194
208
|
.annotate(OpenApi.Summary, "List spans for a trace")
|
|
195
209
|
.annotate(OpenApi.Description, "Returns the flat list of spans for one trace, preserving trace context on each row. Useful for span-level filtering and sorting without traversing the full tree shape."),
|
|
@@ -199,7 +213,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
|
|
|
199
213
|
spanId: Schema.String.pipe(Schema.annotateKey({ description: "Full 16-character hex span ID" })),
|
|
200
214
|
},
|
|
201
215
|
success: SpanResponse,
|
|
202
|
-
error: ErrorResponse,
|
|
216
|
+
error: [ErrorResponse, ...QueryFailures],
|
|
203
217
|
})
|
|
204
218
|
.annotate(OpenApi.Summary, "Get a single span")
|
|
205
219
|
.annotate(OpenApi.Description, "Returns a span by its ID, including the parent trace ID and root operation name for context. Returns 404 if the span is not found."),
|
|
@@ -214,6 +228,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
|
|
|
214
228
|
cursor: CursorParam,
|
|
215
229
|
},
|
|
216
230
|
success: LogList,
|
|
231
|
+
error: QueryFailures,
|
|
217
232
|
})
|
|
218
233
|
.annotate(OpenApi.Summary, "Get logs for a span")
|
|
219
234
|
.annotate(OpenApi.Description, "Returns log records correlated with the given span. Ordered by timestamp descending. Supports cursor pagination and bounded lookback/limit defaults."),
|
|
@@ -231,6 +246,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
|
|
|
231
246
|
limit: LimitParam,
|
|
232
247
|
},
|
|
233
248
|
success: PaginatedSpanList,
|
|
249
|
+
error: QueryFailures,
|
|
234
250
|
})
|
|
235
251
|
.annotate(OpenApi.Summary, "Search spans directly")
|
|
236
252
|
.annotate(OpenApi.Description, "Search spans directly instead of root traces. Supports service, traceId, operation, parentOperation, status, lookback, limit, attr.<key>=<value> (exact match), and attrContains.<key>=<substring> (case-insensitive substring search inside attribute values) in the query string."),
|
|
@@ -255,6 +271,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
|
|
|
255
271
|
cursor: CursorParam,
|
|
256
272
|
},
|
|
257
273
|
success: LogList,
|
|
274
|
+
error: QueryFailures,
|
|
258
275
|
})
|
|
259
276
|
.annotate(OpenApi.Summary, "Search logs")
|
|
260
277
|
.annotate(OpenApi.Description, "Search log records by service, severity, trace/span correlation, or body text (case-insensitive). Supports attr.<key>=<value> (exact match) and attrContains.<key>=<substring> (case-insensitive substring) in the query string. Cursor pagination and bounded lookback/limit defaults. Ordered by timestamp descending."),
|
|
@@ -273,6 +290,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
|
|
|
273
290
|
cursor: CursorParam,
|
|
274
291
|
},
|
|
275
292
|
success: LogList,
|
|
293
|
+
error: QueryFailures,
|
|
276
294
|
})
|
|
277
295
|
.annotate(OpenApi.Summary, "Alias for log search")
|
|
278
296
|
.annotate(OpenApi.Description, "Same behavior as GET /api/logs. Exists as an explicit search endpoint for agents and scripts that distinguish list vs search routes."),
|
|
@@ -289,7 +307,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
|
|
|
289
307
|
limit: LimitParam,
|
|
290
308
|
},
|
|
291
309
|
success: StatList,
|
|
292
|
-
error: ErrorResponse,
|
|
310
|
+
error: [ErrorResponse, ...QueryFailures],
|
|
293
311
|
})
|
|
294
312
|
.annotate(OpenApi.Summary, "Aggregate log statistics")
|
|
295
313
|
.annotate(OpenApi.Description, "Returns grouped log counts by fields like severity, service, scope, or attr.<key>. Useful for quickly understanding log distribution before drilling into raw entries."),
|
|
@@ -324,7 +342,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
|
|
|
324
342
|
limit: LimitParam,
|
|
325
343
|
},
|
|
326
344
|
success: FacetList,
|
|
327
|
-
error: ErrorResponse,
|
|
345
|
+
error: [ErrorResponse, ...QueryFailures],
|
|
328
346
|
})
|
|
329
347
|
.annotate(OpenApi.Summary, "Get facet value counts")
|
|
330
348
|
.annotate(OpenApi.Description, "Returns distinct values and their counts for a given field, useful for discovering what data exists before querying. Examples: ?type=logs&field=severity returns log level distribution; ?type=traces&field=attribute_keys&service=opencode lists top span attribute keys; ?type=traces&field=attribute_values&key=ai.model.id lists values seen for that key."),
|
|
@@ -346,6 +364,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
|
|
|
346
364
|
limit: LimitParam,
|
|
347
365
|
},
|
|
348
366
|
success: AiCallList,
|
|
367
|
+
error: QueryFailures,
|
|
349
368
|
})
|
|
350
369
|
.annotate(OpenApi.Summary, "Search AI calls")
|
|
351
370
|
.annotate(OpenApi.Description, "Search AI SDK calls (streamText, generateText, etc.) with normalized fields. Returns compact summaries with previews — use /api/ai/calls/{spanId} for full prompt/response payloads. Supports filtering by service, session, model, provider, function, operation, status, duration, and free-text search across prompt/response content."),
|
|
@@ -355,7 +374,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
|
|
|
355
374
|
spanId: Schema.String.pipe(Schema.annotateKey({ description: "The span ID of the AI call" })),
|
|
356
375
|
},
|
|
357
376
|
success: AiCallDetailResponse,
|
|
358
|
-
error: ErrorResponse,
|
|
377
|
+
error: [ErrorResponse, ...QueryFailures],
|
|
359
378
|
})
|
|
360
379
|
.annotate(OpenApi.Summary, "Get AI call detail")
|
|
361
380
|
.annotate(OpenApi.Description, "Returns the full detail of a single AI call including complete prompt messages, response text, tool calls with args, token usage, timing, provider metadata, and correlated logs."),
|
|
@@ -381,7 +400,7 @@ export const MotelHttpApi = HttpApi.make("MotelTelemetry")
|
|
|
381
400
|
limit: LimitParam,
|
|
382
401
|
},
|
|
383
402
|
success: StatList,
|
|
384
|
-
error: ErrorResponse,
|
|
403
|
+
error: [ErrorResponse, ...QueryFailures],
|
|
385
404
|
})
|
|
386
405
|
.annotate(OpenApi.Summary, "Aggregate AI call statistics")
|
|
387
406
|
.annotate(OpenApi.Description, "Returns grouped statistics for AI calls. Supports grouping by provider, model, functionId, sessionId, or status with aggregations: count, avg_duration, p95_duration, total_input_tokens, total_output_tokens."),
|
|
@@ -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)
|