mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57052c02bf | ||
|
|
3d466f05c1 | ||
|
|
2103da7ed9 | ||
|
|
91c13d3c4e | ||
|
|
a9095a6370 | ||
|
|
6175f4bba7 | ||
|
|
16036b22c2 | ||
|
|
7b75987294 |
+123
@@ -2,6 +2,129 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.22.14] - 2026-04-29
|
||||
|
||||
**Bare `gbrain jobs work` now self-monitors and fail-stops cleanly when its database dies or the queue stalls.**
|
||||
**The wedged-worker class of bug — process alive, jobs piling up, your `pgrep` check happily green — is gone.**
|
||||
|
||||
A production brain (54K pages, Supabase Postgres, 3-concurrency worker under a cron-based PM)
|
||||
hit it last week: worker process state=Sl at 13:15 UTC, stopped claiming jobs, 21 jobs stacked
|
||||
in `waiting` over two hours, 5 autopilot-cycles dead-lettered at the 600s timeout, then 150
|
||||
zombie processes accumulated over the container's 31-day life. The PM's `pgrep` saw a live
|
||||
PID and reported green the entire time.
|
||||
|
||||
Pre-v0.22.14, bare `gbrain jobs work` had **zero** health monitoring. The supervisor (`gbrain
|
||||
jobs supervisor`) had the right protections — DB liveness probes, stall detection, RSS
|
||||
watchdog, reconnect on transient PgBouncer blips — but the supervisor wraps `jobs work` as a
|
||||
child, and many production deployments run bare `jobs work` directly under systemd, Docker,
|
||||
launchd, cron watchdog, or supervisord. That mode got nothing.
|
||||
|
||||
This release moves health monitoring into the bare worker itself, gated by `GBRAIN_SUPERVISED=1`
|
||||
so it doesn't double up under the supervisor. When the worker detects it's wedged, it emits an
|
||||
`'unhealthy'` event with a structured reason, and the CLI calls `process.exit(1)` so the external
|
||||
PM restarts it cleanly. **This is fail-stop:** the worker exits and stays dead until your PM
|
||||
brings it back. If you run bare `jobs work` without a restart loop, you need one now.
|
||||
|
||||
### The numbers that matter
|
||||
|
||||
Detection signatures the new health check catches, measured against the production incident
|
||||
above (and the 30-day deployment running under the band-aid bash watchdog Garry deployed before
|
||||
this fix):
|
||||
|
||||
| Failure mode | Before v0.22.14 | After v0.22.14 |
|
||||
|---|---|---|
|
||||
| DB connection death (Supabase/PgBouncer drop) | undetected; worker idles forever | 3 consecutive `SELECT 1` failures (≤3min) → `'unhealthy'`+exit |
|
||||
| Hung DB probe (network partition) | timer wedged forever, monitoring silently disabled | 10s probe timeout per tick → counted as failure → exit at strike 3 |
|
||||
| Worker stall (event loop alive, claim returns null) | undetected; jobs pile up in `waiting` | 5min warn, 10min `'unhealthy'`+exit (measured from last completion) |
|
||||
| Memory leak (RSS climbing past 2GB) | undetected on bare workers | watchdog default 2048 MB triggers `gracefulShutdown('watchdog')` |
|
||||
| Worker stalled but waiting jobs are unhandled type | ❌ false-positive exit (restart loop) | filter by registered handler names, no exit |
|
||||
|
||||
Operationally: from the band-aid bash watchdog Garry deployed before this fix, fresh worker
|
||||
restart cleared 21 waiting → 0 in 2 minutes, then ran stable for 30+ min with 130 MB RSS,
|
||||
autopilot-cycles completing in 0.2–0.6s instead of timing out at 600s.
|
||||
|
||||
### What this means for operators
|
||||
|
||||
Add a restart policy to your bare-worker invocation BEFORE upgrading. The new behavior is
|
||||
fail-stop, not self-healing — without a restart loop, your worker will exit on the first DB
|
||||
blip and stay dead. systemd `Restart=always`, Docker `restart: always`, launchd `KeepAlive`,
|
||||
cron watchdog, supervisord `autorestart=true`. The migration walks every PM. If you're using
|
||||
`gbrain jobs supervisor`, you're already protected — the supervisor handles spawn-on-crash
|
||||
itself.
|
||||
|
||||
The default `--max-rss` for bare workers also bumped from 0 (off) to 2048 MB. If you ran bare
|
||||
workers with intentionally large embed/import jobs, raise the limit (`--max-rss 4096`) or opt
|
||||
out (`--max-rss 0`). The migration includes per-PM unit-file edits.
|
||||
|
||||
## To take advantage of v0.22.14
|
||||
|
||||
`gbrain upgrade` should do this automatically. If it didn't, or if `gbrain doctor` warns about
|
||||
a bare worker exiting with watchdog signatures:
|
||||
|
||||
1. **Confirm your bare-worker invocations have a restart policy:**
|
||||
```bash
|
||||
# systemd
|
||||
grep -E '^Restart=' ~/.config/systemd/user/gbrain-worker.service /etc/systemd/system/gbrain-worker.service 2>/dev/null
|
||||
# crontab
|
||||
crontab -l | grep "gbrain jobs work"
|
||||
# launchctl
|
||||
plutil -p ~/Library/LaunchAgents/com.user.gbrain-worker.plist | grep -A1 KeepAlive
|
||||
```
|
||||
2. **Decide on RSS posture:**
|
||||
- Default 2048 MB matches supervisor behavior. Most bare workers fit.
|
||||
- Embed/import jobs > 2GB? Pass `--max-rss 4096` (or higher).
|
||||
- Intentionally unbounded? Pass `--max-rss 0`.
|
||||
3. **Walk the migration:** `skills/migrations/v0.22.14.md` has the full per-PM table and a
|
||||
verification block.
|
||||
4. **Verify:**
|
||||
```bash
|
||||
gbrain jobs stats
|
||||
gbrain doctor --json | jq '.checks[] | select(.name == "queue_health")'
|
||||
```
|
||||
Worker startup line should now read:
|
||||
`Minion worker started (queue: default, concurrency: 3, watchdog: 2048MB, health-check: 60s)`
|
||||
Under supervisor: the `health-check: Ns` segment is absent (supervisor handles it).
|
||||
5. **If anything fails or numbers look wrong**, file an issue at
|
||||
https://github.com/garrytan/gbrain/issues with `gbrain doctor` output and the contents of
|
||||
`~/.gbrain/upgrade-errors.jsonl` if it exists.
|
||||
|
||||
### Itemized changes
|
||||
|
||||
#### Added
|
||||
- `MinionWorkerOpts.{healthCheckInterval, stallWarnAfterMs, stallExitAfterMs, dbFailExitAfter, dbProbeTimeoutMs}` — five new tuning knobs. Defaults: 60s probe interval, 5min warn / 10min exit, 3 DB strikes, 10s per-probe timeout.
|
||||
- `MinionWorker` now extends `EventEmitter`. Emits `'unhealthy'` with `{ reason: 'db_dead', consecutiveFailures, message } | { reason: 'stalled', waitingCount, idleMinutes }`. CLI subscribes; direct API consumers without a listener inherit a fail-stop fallback that calls `process.exit(1)` to preserve pre-refactor semantics.
|
||||
- `gbrain jobs work --health-interval MS` — tune the self-health-check cadence (0 disables; rejects NaN/negative/sub-1000ms typos).
|
||||
- `gbrain jobs supervisor --health-interval MS` — same flag, same validation, same `0 = disable` contract on the supervisor's own probe.
|
||||
- `GBRAIN_SUPERVISED=1` env var on the supervisor's spawned worker child (skips the child's self-health timer to avoid double-monitoring).
|
||||
- `gbrain doctor` `queue_health` subcheck reports RSS-watchdog kills in the last 24h via exact match on `error_text = 'aborted: watchdog'` scoped to `status IN ('dead','failed')`.
|
||||
- `skills/migrations/v0.22.14.md` — full migration walkthrough with per-PM restart-policy preflight, RSS-posture decision tree, and per-system unit-file edits.
|
||||
|
||||
#### Changed
|
||||
- **Default `--max-rss` for `gbrain jobs work`: 0 → 2048 MB.** Matches supervisor default. Catches memory-leak stalls that previously went undetected on bare workers. Opt out with `--max-rss 0`.
|
||||
- **Bare-worker behavior is now fail-stop** when the DB is unreachable or the queue stalls. Pre-v0.22.14 the worker idled silently. Now it exits and relies on the external PM (systemd, Docker, launchd, cron, supervisord) to restart cleanly.
|
||||
- Stall query at `worker.ts` filters by registered handler names (`AND name = ANY($2::text[])`) so workers don't false-positive when waiting jobs of unhandled names accumulate.
|
||||
- Stall exit threshold measured from `lastCompletionTime` (not from when the warning fired), so 5min warn / 10min exit means total idle of 10 min — not 15 min.
|
||||
- DB liveness probe wrapped in `Promise.race` against a 10s timeout so a hung `executeRaw` cannot wedge the recursive `setTimeout` chain forever.
|
||||
- `setInterval` → recursive `setTimeout` with a `running` flag throughout. Eliminates timer-callback overlap on slow probes.
|
||||
- `parseMaxRssFlag` returns `number | undefined` (was `number`) so callers distinguish absent from explicit-disable.
|
||||
- `process.env.GBRAIN_SUPERVISED` check tightened from `!!env.X` to `=== '1'` (precise contract; no fuzzy matching on `'0'` or `'false'`).
|
||||
- `MinionWorker` constructor throws when `stallExitAfterMs <= stallWarnAfterMs` so misconfigurations fail loudly at startup.
|
||||
|
||||
#### Fixed
|
||||
- **Wedged-worker false-positive on heterogeneous queues** — workers registering only some handlers no longer interpret waiting jobs of other names as a stall. Repeated `process.exit(1)` → restart loop is gone.
|
||||
- **Hung DB probe wedge** — pre-fix, a hung `executeRaw('SELECT 1')` kept the recursive `setTimeout` from rescheduling, silently disabling the entire health monitor. Post-fix, the probe times out and counts as a failure.
|
||||
- **`--health-interval 0` no longer DB-hammers the supervisor.** Pre-fix, the documented "0 disables" contract was a lie — `setInterval(cb, 0)` schedules a tight loop. Now gated behind `> 0`.
|
||||
- **Inline `jobs submit --follow` and `jobs smoke` no longer kill the user's CLI session** on a DB blip. Both now pass `healthCheckInterval: 0` so the no-listener fallback can't trip on one-shot runs.
|
||||
- Doctor's RSS-watchdog hint matches the actual error_text signature (`'aborted: watchdog'`) instead of the wrong `'memory limit'` literal that never matched.
|
||||
|
||||
#### For contributors
|
||||
- `MinionWorker extends EventEmitter` — if you import the class directly, the `on('unhealthy', ...)` event is now part of the public surface. The `UnhealthyReason` discriminated union is exported from `src/core/minions/worker.ts`.
|
||||
- New regression-test infrastructure in `test/minions.test.ts`: `makeProbeEngine(overrides)` is a Proxy-based engine wrapper that intercepts `SELECT 1` and the stall `count(*)` query while passing every other call through to the real PGLite engine. Useful for any future test that needs to inject DB liveness or stall semantics without mocking the entire engine surface.
|
||||
|
||||
### Adjacent (separate PR, v0.22.15)
|
||||
|
||||
PR #503 catches the *symptom* of one specific failure mode. The cause-side fix — `runPhaseEmbed → embed.ts → embedBatch` not honoring `signal.aborted` between OpenAI batch calls — ships in v0.22.15 (highest-priority TODO; daily wedge driver). Plumbing is documented in `TODOS.md`.
|
||||
|
||||
## [0.22.13] - 2026-04-28
|
||||
|
||||
**Sync got faster, and the bookmark stopped lying.**
|
||||
|
||||
@@ -1,5 +1,117 @@
|
||||
# TODOS
|
||||
|
||||
## minions / worker (v0.22.14 follow-ups)
|
||||
|
||||
### v0.22.15 — Embed cooperative-abort (HIGHEST PRIORITY — daily pain)
|
||||
**Priority:** P0
|
||||
|
||||
**What:** Plumb `signal: AbortSignal` through `runPhaseEmbed` →
|
||||
`src/commands/embed.ts` → `embedBatch` in `src/core/embedding.ts`. Check
|
||||
`signal?.aborted` between OpenAI batch calls (every ~100 texts, ~2s
|
||||
real-time) and between slugs in the per-slug loop.
|
||||
|
||||
**Why:** Embed phase ignores `signal.aborted` between batches today. Job
|
||||
wall-clock timeout fires → handler keeps running → cycle's finally block
|
||||
unreachable → `gbrain_cycle_locks` row stays held indefinitely. Every
|
||||
subsequent autopilot cron cycle sees `cycle_already_running` → skips. Lock
|
||||
TTL is 30 min; new cycles give up before that. Doctor reports UNHEALTHY.
|
||||
|
||||
**The chain in production:** ~5min cron submits cycle → 22K stale pages →
|
||||
embed phase takes 10–15 min → 600s timeout fires → job dead-lettered → embed
|
||||
keeps running → lock held → all subsequent cycles skip. Garry hits this
|
||||
DAILY on his production brain.
|
||||
|
||||
**Pros:** Closes the daily wedge. Makes timeouts actually effective. Lets
|
||||
operators bump worker timeouts confidently knowing abort actually stops
|
||||
work.
|
||||
|
||||
**Cons:** Touching the embed hot path; small risk of botching the abort
|
||||
checks. Mitigation: between-batch granularity (~2s), not per-text (too fine)
|
||||
or per-slug (too coarse for 500+ chunk slugs).
|
||||
|
||||
**Context:** PR #503 (v0.22.14) catches the SYMPTOM (worker stalled, queue
|
||||
piling up) via self-health-monitoring. This PR catches the CAUSE for one
|
||||
specific failure class. Both fixes are needed; they're complementary, not
|
||||
duplicative.
|
||||
|
||||
**Files to touch:**
|
||||
- `src/core/cycle.ts:579` — `runPhaseEmbed(engine, dryRun)` → add
|
||||
`signal?: AbortSignal` arg
|
||||
- `src/core/cycle.ts:803` — pass `opts.signal` through
|
||||
- `src/commands/embed.ts:~363` — accept signal, check between slugs
|
||||
- `src/core/embedding.ts:51-56` — `embedBatch(texts, onProgress?, signal?)`,
|
||||
check between for-loop iterations of `BATCH_SIZE` slices
|
||||
|
||||
**Tests required:**
|
||||
1. embedBatch checks signal between OpenAI calls; aborts within one batch (~2s)
|
||||
2. Per-slug loop in `embed.ts` checks signal between slugs
|
||||
3. End-to-end: cycle handler with embed phase + signal aborted mid-flight →
|
||||
finally runs → `gbrain_cycle_locks` row deleted
|
||||
4. Regression: 1K+ chunks scenario — embed does NOT block lock release when
|
||||
timeout fires
|
||||
|
||||
**Effort:** M (human: ~3 hr / CC: ~30 min).
|
||||
|
||||
**Depends on / blocked by:** Nothing. v0.22.14 ships first.
|
||||
|
||||
### v0.23+ — Bare-worker engine reconnect parity with supervisor
|
||||
**Priority:** P2
|
||||
|
||||
**What:** Extract the supervisor's reconnect-then-fail pattern into
|
||||
`MinionWorker` so bare workers can retry transient DB blips before exiting.
|
||||
Today the supervisor calls `engine.reconnect()` after 3 consecutive DB health
|
||||
failures (#406); the bare worker just emits `'unhealthy'` and the CLI calls
|
||||
`process.exit(1)`.
|
||||
|
||||
**Why:** Bare-worker behavior is more disruptive than supervised behavior on
|
||||
transient PgBouncer blips. A bare worker restarts the entire process; a
|
||||
supervised worker just reconnects the pool. Operationally the supervisor
|
||||
approach is gentler (no in-flight job loss, no PM restart latency).
|
||||
|
||||
**Pros:** Unifies bare and supervised behavior. Reduces process churn on
|
||||
transient network blips.
|
||||
|
||||
**Cons:** More code in MinionWorker; risk of reconnect masking a real
|
||||
problem. Mitigation: cap retry attempts, fall through to `'unhealthy'`
|
||||
emission after the cap.
|
||||
|
||||
**Context:** Filed during v0.22.14 plan-eng-review. The asymmetry is
|
||||
documented in v0.22.14 CHANGELOG as deliberate; this TODO captures the
|
||||
"unify someday" intent.
|
||||
|
||||
**Effort:** S (human: ~2 hr / CC: ~20 min).
|
||||
|
||||
**Depends on / blocked by:** Nothing.
|
||||
|
||||
### v0.23+ — `minion_workers` heartbeat table for queue_health doctor (B7)
|
||||
**Priority:** P3
|
||||
|
||||
**What:** Add a `minion_workers` table (`worker_id` PK, `hostname`,
|
||||
`last_heartbeat`, `queue`, `concurrency`, `started_at`) so the existing
|
||||
`queue_health` doctor check (Postgres path) can detect dead workers via
|
||||
heartbeat staleness instead of relying on the indirect `lock_until` proxy.
|
||||
|
||||
**Why:** v0.19.1 added `queue_health` checks for stalled-active jobs and
|
||||
waiting-depth threshold. The worker-heartbeat subcheck was deferred (B7)
|
||||
because the `lock_until`-on-active-jobs proxy can't distinguish "worker
|
||||
exited cleanly" from "worker idle" — a check that cries wolf erodes trust
|
||||
in every doctor check. With a real heartbeat row, doctor can say "no worker
|
||||
seen in N intervals" with confidence.
|
||||
|
||||
**Pros:** Doctor's `queue_health` becomes ground-truth. Detects "worker
|
||||
container died but cron didn't restart it" scenario.
|
||||
|
||||
**Cons:** New table, schema migration, every health-tick UPSERTs. Costs
|
||||
a write per worker per minute (default).
|
||||
|
||||
**Context:** Filed during v0.22.14 plan-eng-review. PR #503's self-health
|
||||
monitoring is the worker-side liveness; this would be the queue-side
|
||||
ground-truth.
|
||||
|
||||
**Effort:** M (human: ~1 day / CC: ~1 hr).
|
||||
|
||||
**Depends on / blocked by:** Schema migration system; nothing else.
|
||||
|
||||
## sync (v0.22.13 follow-up — PR #490 review)
|
||||
|
||||
### D-PR490-1 — Plumb resolved `database_url` through `SyncOpts`
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gbrain",
|
||||
"version": "0.22.13",
|
||||
"version": "0.22.14",
|
||||
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
|
||||
"type": "module",
|
||||
"main": "src/core/index.ts",
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
---
|
||||
feature_pitch:
|
||||
headline: Bare workers now self-monitor and fail-stop into your PM's restart loop
|
||||
body: |
|
||||
Bare `gbrain jobs work` now ships with the same health protection the
|
||||
supervisor already had: DB liveness probes (with per-probe timeout so a
|
||||
hung connection can't wedge the monitor), stall detection filtered by
|
||||
registered handler names, and an RSS watchdog default of 2048 MB.
|
||||
|
||||
When the worker detects it's wedged (stuck pgbouncer connection, hung
|
||||
event loop, stalled job claim), it emits `'unhealthy'` and the CLI calls
|
||||
`process.exit(1)`. This is **fail-stop**: it requires an external process
|
||||
manager (systemd, Docker `restart: always`, launchd `KeepAlive`, cron
|
||||
watchdog) to bring the worker back. Without one, the process exits and
|
||||
stays dead — that's a regression from pre-v0.22.14 self-healing.
|
||||
|
||||
Pre-v0.22.14 behavior: bare workers had ZERO health monitoring. A wedged
|
||||
worker stayed alive doing nothing while jobs piled up in `waiting` and
|
||||
your PM's `pgrep` check happily reported green.
|
||||
|
||||
If you're using `gbrain jobs supervisor`, you're already protected — the
|
||||
supervisor handles spawn-on-crash itself. The fail-stop concern only
|
||||
applies to direct `gbrain jobs work` invocations.
|
||||
---
|
||||
|
||||
# v0.22.14 — Bare-worker self-health-monitoring
|
||||
|
||||
## ⚠️ Pre-flight: confirm you have a process supervisor
|
||||
|
||||
If you run `gbrain jobs work` directly (NOT under `gbrain jobs supervisor`),
|
||||
verify your process manager is configured to restart the worker on exit
|
||||
BEFORE upgrading:
|
||||
|
||||
| Manager | What to check |
|
||||
|---|---|
|
||||
| systemd | `Restart=always` (or `Restart=on-failure`) in the `.service` unit |
|
||||
| Docker | `restart: always` / `restart: unless-stopped` in compose, OR `--restart` flag |
|
||||
| launchd (macOS) | `<key>KeepAlive</key><true/>` in the plist |
|
||||
| cron watchdog | Cron entry that re-spawns when `pgrep -f "gbrain jobs work"` is empty |
|
||||
| supervisord | `autorestart=true` |
|
||||
|
||||
**If your bare worker has no restart loop, the v0.22.14 fail-stop behavior
|
||||
will leave you with a dead worker after the first DB blip.** Either add a
|
||||
restart policy OR switch to `gbrain jobs supervisor` (which spawns its own
|
||||
child + restarts on crash internally).
|
||||
|
||||
## What ships
|
||||
|
||||
- DB liveness probes inside `gbrain jobs work` (60s interval, 3 strikes → exit)
|
||||
- Stall detection (5min warn / 10min exit when waiting jobs accumulate but
|
||||
in-flight is empty)
|
||||
- `--max-rss` defaults to 2048 MB for bare workers (matches supervisor default;
|
||||
was 0 = disabled)
|
||||
- New `MinionWorkerOpts.{healthCheckInterval, stallWarnAfterMs,
|
||||
stallExitAfterMs, dbFailExitAfter, dbProbeTimeoutMs}` for tuning (5 fields)
|
||||
- `MinionWorker` now extends `EventEmitter`; emits `'unhealthy'` event with
|
||||
a structured reason payload. **No-listener fallback**: if the caller does
|
||||
not subscribe to `'unhealthy'`, the worker calls `process.exit(1)` itself
|
||||
to preserve the pre-refactor fail-stop behavior. The CLI subscribes; direct
|
||||
API consumers without a listener inherit the fail-stop default. Inline
|
||||
paths (`jobs submit --follow`, `jobs smoke`) explicitly pass
|
||||
`healthCheckInterval: 0` to disable the timer entirely so the fallback
|
||||
cannot trip on a one-shot run.
|
||||
- `GBRAIN_SUPERVISED=1` env var (set by supervisor on its child) causes the
|
||||
child worker to skip its own health timer (no double-monitoring)
|
||||
- Constructor validation: throws if `stallExitAfterMs <= stallWarnAfterMs`
|
||||
(the contract requires strict greater-than)
|
||||
|
||||
## ⚠️ Breaking change you may need to act on
|
||||
|
||||
**`gbrain jobs work` now defaults `--max-rss 2048` (MB).** Pre-v0.22.14, bare
|
||||
workers had no RSS limit. If you ran bare workers with intentionally large
|
||||
embed/import working sets, your worker may now get killed by the watchdog after
|
||||
upgrade.
|
||||
|
||||
**You need to act if any of the following is true:**
|
||||
|
||||
- You run `gbrain jobs work` directly under a process manager (systemd, Docker,
|
||||
cron, supervisord, your own bash wrapper)
|
||||
- Your worker handles large embed batches, multi-GB import jobs, or
|
||||
memory-intensive subagent tasks that legitimately exceed 2 GB RSS
|
||||
- You rely on bare-worker resident memory exceeding 2 GB
|
||||
|
||||
**You do NOT need to act if:**
|
||||
|
||||
- You run `gbrain jobs supervisor` (the supervisor already defaulted to
|
||||
`--max-rss 2048` and has reconnect-then-fail logic on top)
|
||||
- Your typical worker stays well under 2 GB RSS
|
||||
|
||||
## What the agent should do post-upgrade
|
||||
|
||||
### 1. Run the orchestrator (mechanical migration)
|
||||
|
||||
```bash
|
||||
gbrain apply-migrations --yes
|
||||
```
|
||||
|
||||
This is a no-op for v0.22.14 — there's no schema migration. The mechanical work
|
||||
is in the binary; the user-side decision is the breaking-change handling below.
|
||||
|
||||
### 2. Detect bare-worker deployments
|
||||
|
||||
If the user runs OpenClaw, Hermes, or any agent fork that uses `gbrain jobs
|
||||
work` directly (NOT under `gbrain jobs supervisor`), they need to know. Check:
|
||||
|
||||
```bash
|
||||
# Look for bare worker invocations in cron, systemd, Docker, launchctl, etc.
|
||||
grep -r "gbrain jobs work" ~/.config/systemd/ /etc/systemd/ 2>/dev/null | head
|
||||
crontab -l 2>/dev/null | grep "gbrain jobs work" | head
|
||||
launchctl list 2>/dev/null | grep gbrain | head
|
||||
ps -ef | grep "gbrain jobs work" | grep -v grep | grep -v supervisor
|
||||
```
|
||||
|
||||
If you find bare-worker invocations, surface them to the user.
|
||||
|
||||
### 3. Ask about RSS profile
|
||||
|
||||
Use AskUserQuestion to confirm whether the user's bare workers need >2 GB RSS:
|
||||
|
||||
> "v0.22.14 changes `gbrain jobs work` to default `--max-rss 2048` (MB).
|
||||
> Pre-v0.22.14 bare workers had no limit. If your worker handles large embed
|
||||
> batches or multi-GB imports, the watchdog may now kill it. Do you want
|
||||
> to keep the new 2 GB default, raise the limit, or opt out entirely?"
|
||||
|
||||
Options:
|
||||
- **A) Keep 2 GB default (recommended for most)** — protects against memory
|
||||
leaks; restarts on overflow; matches supervisor behavior.
|
||||
- **B) Raise to N GB (specify N)** — pass `--max-rss <N*1024>` to the worker
|
||||
invocation.
|
||||
- **C) Opt out** — pass `--max-rss 0`.
|
||||
|
||||
### 4. Apply the user's choice
|
||||
|
||||
For each bare-worker invocation, edit the unit/cron/launchctl/script to add
|
||||
the chosen `--max-rss` flag.
|
||||
|
||||
**systemd (~/.config/systemd/user/gbrain-worker.service):**
|
||||
|
||||
```ini
|
||||
ExecStart=/usr/local/bin/gbrain jobs work --queue default --concurrency 3 --max-rss 4096
|
||||
# Or to opt out: --max-rss 0
|
||||
```
|
||||
|
||||
Then `systemctl --user daemon-reload && systemctl --user restart gbrain-worker`.
|
||||
|
||||
**cron (`crontab -e`):**
|
||||
|
||||
```cron
|
||||
@reboot /usr/local/bin/gbrain jobs work --queue default --concurrency 3 --max-rss 4096
|
||||
```
|
||||
|
||||
**Docker compose:**
|
||||
|
||||
```yaml
|
||||
command: ["gbrain", "jobs", "work", "--queue", "default", "--concurrency", "3", "--max-rss", "4096"]
|
||||
```
|
||||
|
||||
**launchctl (~/Library/LaunchAgents/com.user.gbrain-worker.plist):**
|
||||
|
||||
```xml
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/gbrain</string>
|
||||
<string>jobs</string>
|
||||
<string>work</string>
|
||||
<string>--max-rss</string>
|
||||
<string>4096</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
Then `launchctl unload ... && launchctl load ...`.
|
||||
|
||||
### 5. (Optional) Tune health-check thresholds
|
||||
|
||||
The new opts default to sensible values (60s probe interval, 5min warn / 10min
|
||||
exit, 3 DB failures). If you have specific SLAs, you can pass `--health-interval
|
||||
<ms>` to adjust the probe cadence. Stall thresholds are not yet CLI-exposed
|
||||
(only the API; CLI flags coming in a follow-up).
|
||||
|
||||
To disable self-monitoring entirely (e.g. you have your own external health
|
||||
checker):
|
||||
|
||||
```bash
|
||||
gbrain jobs work --health-interval 0 --max-rss 0
|
||||
```
|
||||
|
||||
### 6. Verify
|
||||
|
||||
```bash
|
||||
gbrain jobs stats # queue should be flowing normally
|
||||
gbrain doctor --json | jq '.' # no critical warnings
|
||||
ps -o rss= -p $(pgrep -f "gbrain jobs work") | awk '{print $1/1024 " MB"}'
|
||||
```
|
||||
|
||||
Worker startup log line should now show health-check status:
|
||||
|
||||
```
|
||||
Minion worker started (queue: default, concurrency: 3, watchdog: 2048MB, health-check: 60s)
|
||||
```
|
||||
|
||||
If running under supervisor, you'll see the watchdog but NOT the `health-check:
|
||||
60s` segment (because `GBRAIN_SUPERVISED=1` skips the child's self-monitor).
|
||||
|
||||
### 7. If anything fails
|
||||
|
||||
Open an issue at https://github.com/garrytan/gbrain/issues with:
|
||||
- Output of `gbrain doctor`
|
||||
- Contents of `~/.gbrain/upgrade-errors.jsonl` if it exists
|
||||
- Your bare-worker invocation (systemd unit / cron line / Dockerfile snippet)
|
||||
- Which step broke
|
||||
@@ -774,6 +774,30 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
|
||||
ORDER BY depth DESC
|
||||
LIMIT 5
|
||||
`;
|
||||
// Subcheck 3 (v0.22.14): RSS-watchdog kills in the last 24h. Bare workers
|
||||
// newly default to --max-rss 2048 (was 0); operators who run large embed
|
||||
// or import jobs may see kills that didn't happen pre-v0.22.14. We surface
|
||||
// a hint when this signature appears so the upgrade path is obvious.
|
||||
// Signature: when the watchdog trips, gracefulShutdown('watchdog') aborts
|
||||
// in-flight jobs with `new Error('watchdog')`. The worker's failJob path
|
||||
// (worker.ts:660-664) writes `error_text = 'aborted: watchdog'` for any
|
||||
// job in-flight at the moment of the kill.
|
||||
//
|
||||
// We deliberately DO NOT do a loose `ILIKE '%watchdog%'`:
|
||||
// 1. Parent jobs that inherit `on_child_fail='fail_parent'` get
|
||||
// `"child job N failed: aborted: watchdog"` — counting that
|
||||
// double-counts (child + parent) for one watchdog event.
|
||||
// 2. Any user error_text containing the word "watchdog" matches.
|
||||
// Match the exact prefix `'aborted: watchdog'` to scope this purely to
|
||||
// the worker's own kill signature.
|
||||
const rssKillRows: Array<{ cnt: number }> = await sql`
|
||||
SELECT count(*)::int AS cnt
|
||||
FROM minion_jobs
|
||||
WHERE status IN ('dead', 'failed')
|
||||
AND finished_at > now() - interval '24 hours'
|
||||
AND error_text = 'aborted: watchdog'
|
||||
`;
|
||||
const rssKillCount = rssKillRows[0]?.cnt ?? 0;
|
||||
|
||||
const problems: string[] = [];
|
||||
if (stalledRows.length > 0) {
|
||||
@@ -794,6 +818,14 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
|
||||
`Fix: set maxWaiting on the submitter (or raise GBRAIN_QUEUE_WAITING_THRESHOLD).`
|
||||
);
|
||||
}
|
||||
if (rssKillCount > 0) {
|
||||
problems.push(
|
||||
`${rssKillCount} job(s) dead-lettered for RSS-watchdog memory-limit kills in last 24h. ` +
|
||||
`v0.22.14 changed the bare-worker --max-rss default from 0 (off) to 2048 MB. ` +
|
||||
`Fix: raise the limit (e.g. \`gbrain jobs work --max-rss 4096\`) or opt out (\`--max-rss 0\`). ` +
|
||||
`See skills/migrations/v0.22.14.md.`
|
||||
);
|
||||
}
|
||||
|
||||
if (problems.length === 0) {
|
||||
checks.push({
|
||||
|
||||
+96
-17
@@ -33,14 +33,14 @@ export function parseMaxWaitingFlag(args: string[]): number | undefined {
|
||||
}
|
||||
|
||||
/** Parse `--max-rss N` (MB). Returns:
|
||||
* - 0 if the flag is absent (no watchdog by default for bare `jobs work`)
|
||||
* - undefined if the flag is absent (caller decides the default)
|
||||
* - 0 if `--max-rss 0` (explicit disable)
|
||||
* - the value if >= 256
|
||||
* Errors and exits the process if the flag is non-numeric, negative, or
|
||||
* positive but < 256 (likely a GB-vs-MB unit-confusion typo). */
|
||||
export function parseMaxRssFlag(args: string[]): number {
|
||||
export function parseMaxRssFlag(args: string[]): number | undefined {
|
||||
const raw = parseFlag(args, '--max-rss');
|
||||
if (raw === undefined) return 0;
|
||||
if (raw === undefined) return undefined;
|
||||
const parsed = parseInt(raw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
console.error(`Error: --max-rss must be a non-negative integer (MB), got "${raw}"`);
|
||||
@@ -133,6 +133,7 @@ USAGE
|
||||
gbrain jobs stats
|
||||
gbrain jobs smoke
|
||||
gbrain jobs work [--queue Q] [--concurrency N] [--max-rss MB]
|
||||
[--health-interval MS]
|
||||
gbrain jobs supervisor [start] [--detach] [--json]
|
||||
[--concurrency N] [--queue Q] [--pid-file PATH]
|
||||
[--max-crashes N] [--health-interval N]
|
||||
@@ -314,8 +315,15 @@ HANDLER TYPES (built in)
|
||||
|
||||
if (follow) {
|
||||
console.log(`Job #${job.id} submitted (${name}). Executing inline...`);
|
||||
// Inline execution: run the job in this process
|
||||
const worker = new MinionWorker(engine, { queue: queueName, pollInterval: 100 });
|
||||
// Inline execution: run the job in this process. Disable the
|
||||
// self-health-check timer — inline flows are one-shot and don't have
|
||||
// a process manager to restart them. With the timer enabled and no
|
||||
// 'unhealthy' listener, a DB blip would trip emitUnhealthy's
|
||||
// no-listener fallback and call process.exit(1) from inside the
|
||||
// library, killing the user's CLI session.
|
||||
const worker = new MinionWorker(engine, {
|
||||
queue: queueName, pollInterval: 100, healthCheckInterval: 0,
|
||||
});
|
||||
|
||||
// Register built-in handlers
|
||||
await registerBuiltinHandlers(worker, engine);
|
||||
@@ -489,7 +497,11 @@ HANDLER TYPES (built in)
|
||||
const sigkillRescue = hasFlag(args, '--sigkill-rescue');
|
||||
const wedgeRescue = hasFlag(args, '--wedge-rescue');
|
||||
|
||||
const worker = new MinionWorker(engine, { queue: 'smoke', pollInterval: 100 });
|
||||
// Smoke harness is short-lived and has no listener — disable the health
|
||||
// timer so the no-listener fallback can't trip process.exit(1) mid-test.
|
||||
const worker = new MinionWorker(engine, {
|
||||
queue: 'smoke', pollInterval: 100, healthCheckInterval: 0,
|
||||
});
|
||||
worker.register('noop', async () => ({ ok: true, at: new Date().toISOString() }));
|
||||
|
||||
const job = await queue.add('noop', {}, { queue: 'smoke', max_attempts: 1 });
|
||||
@@ -638,19 +650,69 @@ HANDLER TYPES (built in)
|
||||
|
||||
const queueName = parseFlag(args, '--queue') ?? 'default';
|
||||
const concurrency = resolveWorkerConcurrency(args);
|
||||
// --max-rss is opt-in for bare `gbrain jobs work` — preserves pre-v0.21 behavior
|
||||
// for operators with legitimately large embed/import working sets. The supervisor
|
||||
// path injects a default 2048; this code path does not.
|
||||
const maxRssMb = parseMaxRssFlag(args);
|
||||
// --max-rss defaults to 2048 for bare workers (matching supervisor default).
|
||||
// This catches memory-leak stalls that previously went undetected without
|
||||
// a supervisor. Operators can opt out with `--max-rss 0`.
|
||||
const maxRssExplicit = parseMaxRssFlag(args);
|
||||
const maxRssMb = maxRssExplicit ?? 2048;
|
||||
|
||||
// --health-interval: self-health-check period in ms. 0 disables. Default: 60_000 (60s).
|
||||
// Provides DB liveness probes + stall detection for bare workers.
|
||||
// Automatically skipped when running under a supervisor (GBRAIN_SUPERVISED=1).
|
||||
// Validated aggressively (parity with --max-rss): reject NaN/negative/non-integer
|
||||
// values, and reject suspicious sub-1000ms values that are likely a unit-confusion
|
||||
// typo (e.g. "--health-interval 60" thinking the unit is seconds).
|
||||
const healthRaw = parseFlag(args, '--health-interval');
|
||||
let healthCheckInterval = 60_000;
|
||||
if (healthRaw !== undefined) {
|
||||
const parsed = parseInt(healthRaw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
console.error(`Error: --health-interval must be a non-negative integer (ms), got "${healthRaw}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (parsed > 0 && parsed < 1000) {
|
||||
console.error(
|
||||
`Error: --health-interval ${parsed} is suspiciously low (likely a unit-confusion typo). ` +
|
||||
`The flag takes milliseconds; for 60-second probes pass 60000. Use 0 to disable.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
healthCheckInterval = parsed;
|
||||
}
|
||||
|
||||
try { await queue.ensureSchema(); }
|
||||
catch (e) { console.error(e instanceof Error ? e.message : String(e)); process.exit(1); }
|
||||
|
||||
const worker = new MinionWorker(engine, { queue: queueName, concurrency, maxRssMb });
|
||||
const worker = new MinionWorker(engine, {
|
||||
queue: queueName, concurrency, maxRssMb, healthCheckInterval,
|
||||
});
|
||||
await registerBuiltinHandlers(worker, engine);
|
||||
|
||||
// Subscribe to self-health failures emitted by the worker. Library code
|
||||
// (worker.ts) never calls process.exit directly so it stays embeddable;
|
||||
// this CLI layer is the right place to terminate the process and let
|
||||
// the external PM (systemd, Docker, cron watchdog) restart cleanly.
|
||||
worker.on('unhealthy', (info) => {
|
||||
if (info.reason === 'db_dead') {
|
||||
console.error(
|
||||
`[health] FATAL: DB unreachable after ${info.consecutiveFailures} probes (${info.message}). ` +
|
||||
`Exiting for process-manager restart.`,
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
`[health] FATAL: Worker stalled — ${info.waitingCount} waiting job(s) for ` +
|
||||
`registered handlers, ${info.idleMinutes}m idle. Exiting for process-manager restart.`,
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
const isSupervisedChild = process.env.GBRAIN_SUPERVISED === '1';
|
||||
const watchdogNote = maxRssMb > 0 ? `, watchdog: ${maxRssMb}MB` : '';
|
||||
console.log(`Minion worker started (queue: ${queueName}, concurrency: ${concurrency}${watchdogNote})`);
|
||||
const healthNote = !isSupervisedChild && healthCheckInterval > 0
|
||||
? `, health-check: ${Math.round(healthCheckInterval / 1000)}s`
|
||||
: '';
|
||||
console.log(`Minion worker started (queue: ${queueName}, concurrency: ${concurrency}${watchdogNote}${healthNote})`);
|
||||
console.log(`Registered handlers: ${worker.registeredNames.join(', ')}`);
|
||||
await worker.start();
|
||||
break;
|
||||
@@ -787,15 +849,32 @@ HANDLER TYPES (built in)
|
||||
const concurrency = parseInt(parseFlag(args, '--concurrency') ?? '2', 10);
|
||||
const queueName = parseFlag(args, '--queue') ?? 'default';
|
||||
const maxCrashes = parseInt(parseFlag(args, '--max-crashes') ?? '10', 10);
|
||||
const healthInterval = parseInt(parseFlag(args, '--health-interval') ?? '60000', 10);
|
||||
// --health-interval (supervisor): validate same as `jobs work` so NaN /
|
||||
// negative / sub-1000ms typos fail-fast instead of silently disabling
|
||||
// the supervisor's own health probe.
|
||||
const supHealthRaw = parseFlag(args, '--health-interval');
|
||||
let healthInterval = 60_000;
|
||||
if (supHealthRaw !== undefined) {
|
||||
const parsed = parseInt(supHealthRaw, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
console.error(`Error: --health-interval must be a non-negative integer (ms), got "${supHealthRaw}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (parsed > 0 && parsed < 1000) {
|
||||
console.error(
|
||||
`Error: --health-interval ${parsed} is suspiciously low (likely a unit-confusion typo). ` +
|
||||
`The flag takes milliseconds; for 60-second probes pass 60000. Use 0 to disable.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
healthInterval = parsed;
|
||||
}
|
||||
const allowShellJobs = hasFlag(args, '--allow-shell-jobs') ||
|
||||
!!process.env.GBRAIN_ALLOW_SHELL_JOBS;
|
||||
const detach = hasFlag(args, '--detach');
|
||||
// Supervisor defaults --max-rss 2048 (MB) — main production path uses
|
||||
// the supervisor, so the watchdog is on by default here. parseMaxRssFlag
|
||||
// returns 0 when the flag is absent; substitute the supervisor default.
|
||||
const maxRssRaw = parseMaxRssFlag(args);
|
||||
const maxRssMb = parseFlag(args, '--max-rss') === undefined ? 2048 : maxRssRaw;
|
||||
// the supervisor, so the watchdog is on by default here.
|
||||
const maxRssMb = parseMaxRssFlag(args) ?? 2048;
|
||||
|
||||
const cliPath = parseFlag(args, '--cli-path') ?? resolveGbrainCliPath();
|
||||
|
||||
|
||||
@@ -225,8 +225,12 @@ export class MinionSupervisor {
|
||||
process.on('SIGTERM', this.sigtermListener);
|
||||
process.on('SIGINT', this.sigintListener);
|
||||
|
||||
// 4. Health monitoring.
|
||||
this.healthTimer = setInterval(() => { void this.healthCheck(); }, this.opts.healthInterval);
|
||||
// 4. Health monitoring. Skip when healthInterval=0 — that's the explicit
|
||||
// "disable" contract documented on `--health-interval 0`. setInterval(0)
|
||||
// would be a tight DB-hammering loop, not the no-op users expect.
|
||||
if (this.opts.healthInterval > 0) {
|
||||
this.healthTimer = setInterval(() => { void this.healthCheck(); }, this.opts.healthInterval);
|
||||
}
|
||||
|
||||
// 5. Announce start.
|
||||
this.emit('started', {
|
||||
@@ -427,6 +431,11 @@ export class MinionSupervisor {
|
||||
} else {
|
||||
delete env.GBRAIN_ALLOW_SHELL_JOBS;
|
||||
}
|
||||
// Signal to the child worker that it's running under a supervisor.
|
||||
// The worker's self-health-check (DB probes, stall detection) is
|
||||
// redundant when the supervisor already provides these — setting
|
||||
// this env var causes the worker to skip its own health timer.
|
||||
env.GBRAIN_SUPERVISED = '1';
|
||||
|
||||
this.lastStartTime = Date.now();
|
||||
|
||||
|
||||
@@ -170,6 +170,25 @@ export interface MinionWorkerOpts {
|
||||
* case where all concurrency slots are wedged with zero job completions
|
||||
* so the per-job check never fires. */
|
||||
rssCheckInterval?: number;
|
||||
/** Self-health-check interval in ms. 0 = disabled. Default: 60000 (1 minute).
|
||||
* Automatically disabled when running under a supervisor (GBRAIN_SUPERVISED=1).
|
||||
* Provides DB liveness probes and stall detection for bare `gbrain jobs work`
|
||||
* deployments managed by external process managers (systemd, Docker, cron). */
|
||||
healthCheckInterval?: number;
|
||||
/** Stall detection: ms of continuous idle (waiting>0, inFlight=0, no completions)
|
||||
* before emitting the first warning. Default: 300000 (5 minutes). */
|
||||
stallWarnAfterMs?: number;
|
||||
/** Stall detection: ms of continuous idle before emitting `'unhealthy'` with
|
||||
* reason='stalled'. Default: 600000 (10 minutes). Must be > stallWarnAfterMs. */
|
||||
stallExitAfterMs?: number;
|
||||
/** DB liveness probe: number of consecutive failed `SELECT 1` probes before
|
||||
* emitting `'unhealthy'` with reason='db_dead'. Default: 3. */
|
||||
dbFailExitAfter?: number;
|
||||
/** Per-probe wall-clock timeout in ms. A `SELECT 1` that hangs longer than
|
||||
* this counts as a failure (fed into dbFailExitAfter). Without this, a
|
||||
* hung probe would wedge the recursive setTimeout chain forever and
|
||||
* silently disable the health monitor. Default: 10000 (10 seconds). */
|
||||
dbProbeTimeoutMs?: number;
|
||||
}
|
||||
|
||||
// --- Job Context (passed to handlers) ---
|
||||
|
||||
+209
-1
@@ -20,8 +20,15 @@ import { UnrecoverableError } from './types.ts';
|
||||
import { MinionQueue } from './queue.ts';
|
||||
import { calculateBackoff } from './backoff.ts';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { EventEmitter } from 'events';
|
||||
import { evaluateQuietHours, type QuietHoursConfig } from './quiet-hours.ts';
|
||||
|
||||
/** Reason payload emitted with `'unhealthy'` when self-health-check trips.
|
||||
* CLI layer (jobs.ts:work) subscribes and decides whether to call process.exit. */
|
||||
export type UnhealthyReason =
|
||||
| { reason: 'db_dead'; consecutiveFailures: number; message: string }
|
||||
| { reason: 'stalled'; waitingCount: number; idleMinutes: number };
|
||||
|
||||
/**
|
||||
* Read the quiet_hours JSONB column off a MinionJob, if present. The
|
||||
* column was added in schema migration v12; older rows + versions of
|
||||
@@ -42,7 +49,13 @@ interface InFlightJob {
|
||||
promise: Promise<void>;
|
||||
}
|
||||
|
||||
export class MinionWorker {
|
||||
/** Type-safe `on('unhealthy', ...)` for callers. */
|
||||
export interface MinionWorker {
|
||||
on(event: 'unhealthy', listener: (info: UnhealthyReason) => void): this;
|
||||
emit(event: 'unhealthy', info: UnhealthyReason): boolean;
|
||||
}
|
||||
|
||||
export class MinionWorker extends EventEmitter {
|
||||
private queue: MinionQueue;
|
||||
private handlers = new Map<string, MinionHandler>();
|
||||
private running = false;
|
||||
@@ -67,6 +80,7 @@ export class MinionWorker {
|
||||
private engine: BrainEngine,
|
||||
opts?: MinionWorkerOpts & MinionQueueOpts,
|
||||
) {
|
||||
super();
|
||||
this.queue = new MinionQueue(engine, {
|
||||
maxSpawnDepth: opts?.maxSpawnDepth,
|
||||
maxAttachmentBytes: opts?.maxAttachmentBytes,
|
||||
@@ -81,7 +95,25 @@ export class MinionWorker {
|
||||
maxRssMb: opts?.maxRssMb ?? 0,
|
||||
getRss: opts?.getRss ?? (() => process.memoryUsage().rss),
|
||||
rssCheckInterval: opts?.rssCheckInterval ?? 60000,
|
||||
healthCheckInterval: opts?.healthCheckInterval ?? 60000,
|
||||
stallWarnAfterMs: opts?.stallWarnAfterMs ?? 5 * 60_000,
|
||||
stallExitAfterMs: opts?.stallExitAfterMs ?? 10 * 60_000,
|
||||
dbFailExitAfter: opts?.dbFailExitAfter ?? 3,
|
||||
dbProbeTimeoutMs: opts?.dbProbeTimeoutMs ?? 10_000,
|
||||
};
|
||||
// Stall thresholds contract: exit MUST be strictly greater than warn.
|
||||
// If exit <= warn, the warn-then-exit semantics break: a single tick at
|
||||
// idle > warn would set stallWarningSince and the subsequent tick at
|
||||
// idle > exit could fire immediately without giving operators visibility.
|
||||
// Reject misconfigurations at construction time so the failure mode is
|
||||
// a loud throw on startup rather than a quiet contract violation.
|
||||
if (this.opts.stallExitAfterMs <= this.opts.stallWarnAfterMs) {
|
||||
throw new Error(
|
||||
`MinionWorkerOpts: stallExitAfterMs (${this.opts.stallExitAfterMs}) must be > ` +
|
||||
`stallWarnAfterMs (${this.opts.stallWarnAfterMs}). ` +
|
||||
`The contract is "warn first, exit later" — they cannot fire on the same tick.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Register a handler for a job type. */
|
||||
@@ -94,6 +126,28 @@ export class MinionWorker {
|
||||
return Array.from(this.handlers.keys());
|
||||
}
|
||||
|
||||
/** Emit 'unhealthy' with a no-listener fallback. The default contract is
|
||||
* fail-stop: pre-EventEmitter-refactor behavior was process.exit(1) inside
|
||||
* the timer; the refactor moved that responsibility to the CLI subscriber.
|
||||
* But direct API consumers without a listener would see emit() become a
|
||||
* no-op AND `healthExited=true` permanently disabling monitoring — a
|
||||
* silent regression. Solution: if no one subscribed, log and exit
|
||||
* ourselves so the worker dies and the PM restarts it. Subscribers
|
||||
* override this default by adding a listener before start(). */
|
||||
private emitUnhealthy(info: UnhealthyReason): void {
|
||||
if (this.listenerCount('unhealthy') === 0) {
|
||||
const detail = info.reason === 'db_dead'
|
||||
? `DB unreachable (${info.consecutiveFailures} probes): ${info.message}`
|
||||
: `worker stalled (${info.waitingCount} waiting, ${info.idleMinutes}m idle)`;
|
||||
console.error(
|
||||
`[health] FATAL: ${detail}. No 'unhealthy' listener registered; ` +
|
||||
`defaulting to process.exit(1) for process-manager restart.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
this.emit('unhealthy', info);
|
||||
}
|
||||
|
||||
/** Start the worker loop. Blocks until stopped. */
|
||||
async start(): Promise<void> {
|
||||
if (this.handlers.size === 0) {
|
||||
@@ -155,6 +209,159 @@ export class MinionWorker {
|
||||
}, this.opts.rssCheckInterval);
|
||||
}
|
||||
|
||||
// Self-health-check — provides supervisor-grade monitoring for bare workers.
|
||||
// Disabled when running under a supervisor (GBRAIN_SUPERVISED=1) or when
|
||||
// healthCheckInterval is 0. Catches two failure modes that leave the process
|
||||
// alive but non-functional:
|
||||
// 1. DB connection death (Supabase/PgBouncer drops, network blip)
|
||||
// 2. Worker stall (event loop alive but not claiming/completing jobs)
|
||||
//
|
||||
// On failure, emits an `'unhealthy'` event with a structured reason. The
|
||||
// CLI layer (`src/commands/jobs.ts:work`) subscribes and decides whether to
|
||||
// call process.exit. Library code never calls process.exit directly so
|
||||
// MinionWorker stays embeddable in non-CLI contexts (tests, other hosts).
|
||||
//
|
||||
// Timer pattern: recursive setTimeout with a `running` flag, not setInterval.
|
||||
// setInterval queues callbacks even when the prior is still awaiting; on a
|
||||
// hung DB probe that piles up overlapping async checks racing on
|
||||
// `consecutiveDbFailures`. The recursive pattern guarantees one tick at a time.
|
||||
const isSupervisedChild = process.env.GBRAIN_SUPERVISED === '1';
|
||||
let healthTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
if (!isSupervisedChild && this.opts.healthCheckInterval > 0) {
|
||||
let consecutiveDbFailures = 0;
|
||||
let lastKnownCompleted = this.jobsCompleted;
|
||||
let lastCompletionTime = Date.now();
|
||||
let stallWarningSince: number | null = null;
|
||||
let healthRunning = false;
|
||||
let healthExited = false;
|
||||
|
||||
// Race executeRaw against a wall-clock deadline. A hung connection
|
||||
// (network-partitioned PgBouncer, deadlocked backend) would otherwise
|
||||
// hold the await forever — the recursive setTimeout's next tick is only
|
||||
// scheduled in `finally`, so a hung probe would silently disable the
|
||||
// entire health monitor. The timeout treats hangs as failures and feeds
|
||||
// them into `dbFailExitAfter`.
|
||||
const probeWithTimeout = async (): Promise<void> => {
|
||||
const ac = new AbortController();
|
||||
const timeoutMs = this.opts.dbProbeTimeoutMs;
|
||||
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
||||
try {
|
||||
await Promise.race([
|
||||
this.engine.executeRaw('SELECT 1'),
|
||||
new Promise<never>((_, reject) => {
|
||||
ac.signal.addEventListener('abort', () => {
|
||||
reject(new Error(`probe timeout after ${timeoutMs}ms`));
|
||||
});
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
const runHealthCheck = async (): Promise<void> => {
|
||||
if (healthRunning || !this.running || healthExited) return;
|
||||
healthRunning = true;
|
||||
try {
|
||||
// --- 1. DB liveness probe ---
|
||||
try {
|
||||
await probeWithTimeout();
|
||||
consecutiveDbFailures = 0;
|
||||
} catch (e) {
|
||||
consecutiveDbFailures++;
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(
|
||||
`[health] DB probe failed (${consecutiveDbFailures}/${this.opts.dbFailExitAfter}): ${msg}`,
|
||||
);
|
||||
if (consecutiveDbFailures >= this.opts.dbFailExitAfter) {
|
||||
console.error(
|
||||
`[health] DB unreachable after ${this.opts.dbFailExitAfter} consecutive probes. ` +
|
||||
`Emitting 'unhealthy' for process-manager restart.`,
|
||||
);
|
||||
healthExited = true;
|
||||
this.emitUnhealthy({
|
||||
reason: 'db_dead',
|
||||
consecutiveFailures: consecutiveDbFailures,
|
||||
message: msg,
|
||||
});
|
||||
}
|
||||
return; // Skip stall check when DB is flaky
|
||||
}
|
||||
|
||||
// --- 2. Stall detection ---
|
||||
if (this.jobsCompleted > lastKnownCompleted) {
|
||||
lastKnownCompleted = this.jobsCompleted;
|
||||
lastCompletionTime = Date.now();
|
||||
stallWarningSince = null;
|
||||
}
|
||||
|
||||
const idleMs = Date.now() - lastCompletionTime;
|
||||
|
||||
// Only check for stalls when no jobs are in-flight and it's been a while
|
||||
if (idleMs > this.opts.stallWarnAfterMs && this.inFlight.size === 0) {
|
||||
try {
|
||||
// Filter by registered handler names so a worker that doesn't
|
||||
// claim a particular job-name doesn't false-positive when those
|
||||
// jobs accumulate in `waiting`. Only counts work THIS worker would
|
||||
// actually have claimed.
|
||||
const handlerNames = this.registeredNames;
|
||||
const rows = handlerNames.length === 0
|
||||
? [] as { cnt: string }[]
|
||||
: await this.engine.executeRaw<{ cnt: string }>(
|
||||
`SELECT count(*)::text AS cnt FROM minion_jobs
|
||||
WHERE status = 'waiting'
|
||||
AND queue = $1
|
||||
AND name = ANY($2::text[])`,
|
||||
[this.opts.queue, handlerNames],
|
||||
);
|
||||
const waiting = parseInt(rows[0]?.cnt ?? '0', 10);
|
||||
const idleMinutes = Math.round(idleMs / 60_000);
|
||||
if (waiting > 0) {
|
||||
// Two thresholds, both measured from `lastCompletionTime` (NOT
|
||||
// from when the warning fired). With defaults (warn=5min,
|
||||
// exit=10min), the first warning fires at idle=5min and the
|
||||
// unhealthy emit fires at idle=10min — matching the contract
|
||||
// documented in MinionWorkerOpts.
|
||||
if (!stallWarningSince) {
|
||||
stallWarningSince = Date.now();
|
||||
console.warn(
|
||||
`[health] Possible stall: ${waiting} waiting job(s) for ` +
|
||||
`registered handlers, 0 in-flight, ${idleMinutes}m since last completion`,
|
||||
);
|
||||
} else if (idleMs > this.opts.stallExitAfterMs) {
|
||||
console.error(
|
||||
`[health] Worker stalled for ${Math.round(this.opts.stallExitAfterMs / 60_000)}+ ` +
|
||||
`minutes with ${waiting} waiting job(s). Emitting 'unhealthy' for process-manager restart.`,
|
||||
);
|
||||
healthExited = true;
|
||||
this.emitUnhealthy({
|
||||
reason: 'stalled',
|
||||
waitingCount: waiting,
|
||||
idleMinutes,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
stallWarningSince = null; // Queue empty (for our handlers) — not stalled, just idle
|
||||
}
|
||||
} catch {
|
||||
// DB query failed — the liveness probe above will catch persistent failures
|
||||
}
|
||||
} else {
|
||||
stallWarningSince = null;
|
||||
}
|
||||
} finally {
|
||||
healthRunning = false;
|
||||
if (this.running && !healthExited) {
|
||||
healthTimer = setTimeout(runHealthCheck, this.opts.healthCheckInterval);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// First tick scheduled after one interval so newly-started workers have
|
||||
// a chance to do real work before the stall clock starts ticking.
|
||||
healthTimer = setTimeout(runHealthCheck, this.opts.healthCheckInterval);
|
||||
}
|
||||
|
||||
try {
|
||||
while (this.running) {
|
||||
// Promote delayed jobs
|
||||
@@ -201,6 +408,7 @@ export class MinionWorker {
|
||||
} finally {
|
||||
clearInterval(stalledTimer);
|
||||
if (rssTimer) clearInterval(rssTimer);
|
||||
if (healthTimer) clearTimeout(healthTimer); // recursive setTimeout pattern
|
||||
process.removeListener('SIGTERM', shutdown);
|
||||
process.removeListener('SIGINT', shutdown);
|
||||
|
||||
|
||||
@@ -2306,3 +2306,379 @@ describe('checkAborted (v0.20.5 cycle signal)', () => {
|
||||
}).toThrow('aborted between phases: timeout');
|
||||
});
|
||||
});
|
||||
|
||||
// --- v0.22.14: Self-health-check for bare workers ---
|
||||
|
||||
describe('MinionWorker: self-health-check', () => {
|
||||
test('health check is active when GBRAIN_SUPERVISED is not set', async () => {
|
||||
// Save and clear the env var
|
||||
const saved = process.env.GBRAIN_SUPERVISED;
|
||||
delete process.env.GBRAIN_SUPERVISED;
|
||||
|
||||
try {
|
||||
const worker = new MinionWorker(engine, {
|
||||
queue: 'default',
|
||||
concurrency: 1,
|
||||
healthCheckInterval: 100, // fast for testing
|
||||
pollInterval: 50,
|
||||
stalledInterval: 10_000,
|
||||
maxRssMb: 0,
|
||||
});
|
||||
|
||||
worker.register('noop', async () => {});
|
||||
await queue.add('noop', {});
|
||||
|
||||
const startPromise = worker.start();
|
||||
// Let the health check fire at least once (100ms interval)
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
worker.stop();
|
||||
await startPromise;
|
||||
|
||||
// Worker should have processed the job despite health check running
|
||||
const completed = await queue.getJobs({ status: 'completed' });
|
||||
expect(completed.length).toBeGreaterThanOrEqual(1);
|
||||
} finally {
|
||||
if (saved !== undefined) process.env.GBRAIN_SUPERVISED = saved;
|
||||
else delete process.env.GBRAIN_SUPERVISED;
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
test('health check is skipped when GBRAIN_SUPERVISED=1', async () => {
|
||||
const saved = process.env.GBRAIN_SUPERVISED;
|
||||
process.env.GBRAIN_SUPERVISED = '1';
|
||||
|
||||
try {
|
||||
const worker = new MinionWorker(engine, {
|
||||
queue: 'default',
|
||||
concurrency: 1,
|
||||
healthCheckInterval: 100,
|
||||
pollInterval: 50,
|
||||
stalledInterval: 10_000,
|
||||
maxRssMb: 0,
|
||||
});
|
||||
|
||||
worker.register('noop', async () => {});
|
||||
await queue.add('noop', {});
|
||||
|
||||
const startPromise = worker.start();
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
worker.stop();
|
||||
await startPromise;
|
||||
|
||||
// Worker should still process jobs fine
|
||||
const completed = await queue.getJobs({ status: 'completed' });
|
||||
expect(completed.length).toBeGreaterThanOrEqual(1);
|
||||
} finally {
|
||||
if (saved !== undefined) process.env.GBRAIN_SUPERVISED = saved;
|
||||
else delete process.env.GBRAIN_SUPERVISED;
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
test('healthCheckInterval=0 disables health check', async () => {
|
||||
delete process.env.GBRAIN_SUPERVISED;
|
||||
|
||||
const worker = new MinionWorker(engine, {
|
||||
queue: 'default',
|
||||
concurrency: 1,
|
||||
healthCheckInterval: 0,
|
||||
pollInterval: 50,
|
||||
stalledInterval: 10_000,
|
||||
maxRssMb: 0,
|
||||
});
|
||||
|
||||
worker.register('noop', async () => {});
|
||||
await queue.add('noop', {});
|
||||
|
||||
const startPromise = worker.start();
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
worker.stop();
|
||||
await startPromise;
|
||||
|
||||
const completed = await queue.getJobs({ status: 'completed' });
|
||||
expect(completed.length).toBeGreaterThanOrEqual(1);
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
// --- v0.22.14: Self-health-check behavior tests (D7) ---
|
||||
// These tests use a Proxy around the real engine so executeRaw can be
|
||||
// intercepted by SQL pattern. SELECT 1 = liveness probe; the count(*) query
|
||||
// = stall detection. Anything else passes through to the underlying engine.
|
||||
|
||||
interface ProbeOverrides {
|
||||
/** When set, executeRaw('SELECT 1') uses this function instead of pass-through.
|
||||
* Returning a thrown error simulates DB death; returning [{}] simulates success. */
|
||||
selectOne?: () => Promise<unknown>;
|
||||
/** When set, executeRaw of the stall-detection count(*) query returns this. */
|
||||
countWaiting?: (handlers: string[]) => number;
|
||||
/** Captures the last SQL string that matched the stall-count regex. Tests
|
||||
* use this to assert the production SQL still contains `name = ANY(...)`
|
||||
* so a future refactor that drops the predicate is caught. */
|
||||
capturedStallSql?: { sql: string | null };
|
||||
}
|
||||
|
||||
function makeProbeEngine(overrides: ProbeOverrides) {
|
||||
return new Proxy(engine, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === 'executeRaw') {
|
||||
return async (sql: string, params?: unknown[]): Promise<unknown[]> => {
|
||||
if (overrides.selectOne && /^\s*SELECT\s+1\s*$/i.test(sql)) {
|
||||
const r = await overrides.selectOne();
|
||||
return Array.isArray(r) ? r : [r];
|
||||
}
|
||||
if (overrides.countWaiting && /count\(\*\).*minion_jobs.*WHERE\s+status\s*=\s*'waiting'/is.test(sql)) {
|
||||
if (overrides.capturedStallSql) overrides.capturedStallSql.sql = sql;
|
||||
const handlers = (params?.[1] as string[]) ?? [];
|
||||
return [{ cnt: String(overrides.countWaiting(handlers)) }];
|
||||
}
|
||||
// Pass through to real engine for anything else (claim queries etc.)
|
||||
return (target as unknown as { executeRaw: (s: string, p?: unknown[]) => Promise<unknown[]> })
|
||||
.executeRaw(sql, params);
|
||||
};
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
}) as unknown as PGLiteEngine;
|
||||
}
|
||||
|
||||
describe('MinionWorker: self-health-check behavior (v0.22.14)', () => {
|
||||
test('emits unhealthy{db_dead} after dbFailExitAfter consecutive DB probe failures', async () => {
|
||||
delete process.env.GBRAIN_SUPERVISED;
|
||||
|
||||
let probeCount = 0;
|
||||
const probeEngine = makeProbeEngine({
|
||||
selectOne: async () => {
|
||||
probeCount++;
|
||||
throw new Error('connection terminated unexpectedly');
|
||||
},
|
||||
});
|
||||
|
||||
const worker = new MinionWorker(probeEngine, {
|
||||
queue: 'default',
|
||||
concurrency: 1,
|
||||
healthCheckInterval: 30,
|
||||
dbFailExitAfter: 3,
|
||||
pollInterval: 50,
|
||||
stalledInterval: 10_000,
|
||||
maxRssMb: 0,
|
||||
});
|
||||
|
||||
worker.register('noop', async () => {});
|
||||
|
||||
const events: Array<{ reason: string }> = [];
|
||||
worker.on('unhealthy', (info) => { events.push(info); });
|
||||
|
||||
const startPromise = worker.start();
|
||||
// 3 ticks at 30ms = 90ms; give extra slack.
|
||||
await new Promise(r => setTimeout(r, 250));
|
||||
worker.stop();
|
||||
await startPromise;
|
||||
|
||||
expect(probeCount).toBeGreaterThanOrEqual(3);
|
||||
expect(events.length).toBeGreaterThanOrEqual(1);
|
||||
expect(events[0].reason).toBe('db_dead');
|
||||
}, 10_000);
|
||||
|
||||
test('DB recovery resets the failure counter (no exit after intermittent failures)', async () => {
|
||||
delete process.env.GBRAIN_SUPERVISED;
|
||||
|
||||
let probeCount = 0;
|
||||
// Pattern: fail, fail, succeed (resets), fail, fail, then permanently succeed.
|
||||
// No 3 consecutive failures, so dbFailExitAfter=3 must NOT trip.
|
||||
const probeEngine = makeProbeEngine({
|
||||
selectOne: async () => {
|
||||
const idx = probeCount++;
|
||||
if (idx === 0 || idx === 1 || idx === 3 || idx === 4) {
|
||||
throw new Error('transient blip');
|
||||
}
|
||||
return [{ ok: 1 }];
|
||||
},
|
||||
});
|
||||
|
||||
const worker = new MinionWorker(probeEngine, {
|
||||
queue: 'default',
|
||||
concurrency: 1,
|
||||
healthCheckInterval: 30,
|
||||
dbFailExitAfter: 3,
|
||||
pollInterval: 50,
|
||||
stalledInterval: 10_000,
|
||||
maxRssMb: 0,
|
||||
});
|
||||
|
||||
worker.register('noop', async () => {});
|
||||
|
||||
const events: Array<{ reason: string }> = [];
|
||||
worker.on('unhealthy', (info) => { events.push(info); });
|
||||
|
||||
const startPromise = worker.start();
|
||||
await new Promise(r => setTimeout(r, 250));
|
||||
worker.stop();
|
||||
await startPromise;
|
||||
|
||||
// Counter should never have hit 3 consecutive — success at index 2 resets it.
|
||||
const dbDeadEvents = events.filter(e => e.reason === 'db_dead');
|
||||
expect(dbDeadEvents.length).toBe(0);
|
||||
}, 10_000);
|
||||
|
||||
test('emits unhealthy{stalled} after stallExitAfterMs of continuous idle with waiting jobs', async () => {
|
||||
delete process.env.GBRAIN_SUPERVISED;
|
||||
|
||||
const probeEngine = makeProbeEngine({
|
||||
selectOne: async () => [{ ok: 1 }],
|
||||
countWaiting: () => 5, // pretend 5 jobs are waiting for our handler names
|
||||
});
|
||||
|
||||
const worker = new MinionWorker(probeEngine, {
|
||||
queue: 'default',
|
||||
concurrency: 1,
|
||||
healthCheckInterval: 30,
|
||||
stallWarnAfterMs: 50,
|
||||
stallExitAfterMs: 100,
|
||||
pollInterval: 50,
|
||||
stalledInterval: 10_000,
|
||||
maxRssMb: 0,
|
||||
});
|
||||
|
||||
worker.register('noop', async () => {});
|
||||
// Don't queue any real jobs — claim returns null, inFlight stays 0,
|
||||
// jobsCompleted stays 0, idle clock advances.
|
||||
|
||||
const events: Array<{ reason: string; waitingCount?: number }> = [];
|
||||
worker.on('unhealthy', (info) => { events.push(info); });
|
||||
|
||||
const startPromise = worker.start();
|
||||
// Both thresholds measured from lastCompletionTime (corrected per codex r2):
|
||||
// - tick @ +30ms: idle=30ms, < stallWarnAfterMs(50), no warn
|
||||
// - tick @ +60ms: idle=60ms, > 50, warn fires (stallWarningSince set)
|
||||
// - tick @ +90ms: idle=90ms, < stallExitAfterMs(100), no exit yet
|
||||
// - tick @ +120ms: idle=120ms, > 100 → exit fires (unhealthy event)
|
||||
// Wait 350ms which leaves comfortable slack for setTimeout drift.
|
||||
await new Promise(r => setTimeout(r, 350));
|
||||
worker.stop();
|
||||
await startPromise;
|
||||
|
||||
const stalledEvents = events.filter(e => e.reason === 'stalled');
|
||||
expect(stalledEvents.length).toBeGreaterThanOrEqual(1);
|
||||
expect(stalledEvents[0].waitingCount).toBe(5);
|
||||
// The idleMinutes payload should reflect total idle, not warn-since.
|
||||
// With idle ~120ms at exit time, idleMinutes rounds to 0 — that's
|
||||
// expected; the value is informative, not load-bearing.
|
||||
}, 10_000);
|
||||
|
||||
test('inFlight > 0 blocks stall detection (long-running legitimate job)', async () => {
|
||||
delete process.env.GBRAIN_SUPERVISED;
|
||||
|
||||
const probeEngine = makeProbeEngine({
|
||||
selectOne: async () => [{ ok: 1 }],
|
||||
countWaiting: () => 5,
|
||||
});
|
||||
|
||||
const worker = new MinionWorker(probeEngine, {
|
||||
queue: 'default',
|
||||
concurrency: 1,
|
||||
healthCheckInterval: 30,
|
||||
stallWarnAfterMs: 50,
|
||||
stallExitAfterMs: 100,
|
||||
pollInterval: 50,
|
||||
stalledInterval: 10_000,
|
||||
maxRssMb: 0,
|
||||
});
|
||||
|
||||
worker.register('noop', async () => {});
|
||||
|
||||
const events: Array<{ reason: string }> = [];
|
||||
worker.on('unhealthy', (info) => { events.push(info); });
|
||||
|
||||
// Inject a fake in-flight entry directly. This bypasses the claim path
|
||||
// (which goes through the proxy and complicates the cleanup race) and
|
||||
// tests exactly what we want: the stall check's `inFlight.size === 0`
|
||||
// gate when there's legitimate ongoing work.
|
||||
const fakeInFlight = (worker as unknown as {
|
||||
inFlight: Map<number, { lockTimer: NodeJS.Timeout; abort: AbortController; promise: Promise<void> }>
|
||||
}).inFlight;
|
||||
const fakeAbort = new AbortController();
|
||||
const fakePromise = new Promise<void>(() => { /* never resolves */ });
|
||||
const fakeTimer = setInterval(() => {}, 60_000); // dummy lock timer
|
||||
fakeInFlight.set(99999, { lockTimer: fakeTimer, abort: fakeAbort, promise: fakePromise });
|
||||
|
||||
const startPromise = worker.start();
|
||||
await new Promise(r => setTimeout(r, 350));
|
||||
// Remove our fake entry before stop so the worker doesn't wait 30s for it.
|
||||
clearInterval(fakeTimer);
|
||||
fakeInFlight.delete(99999);
|
||||
worker.stop();
|
||||
await startPromise;
|
||||
|
||||
// No stall event should fire — inFlight.size > 0 gates the stall check.
|
||||
const stalledEvents = events.filter(e => e.reason === 'stalled');
|
||||
expect(stalledEvents.length).toBe(0);
|
||||
}, 10_000);
|
||||
|
||||
test('regression (D1): waiting jobs of unregistered handler names do NOT trigger stall exit', async () => {
|
||||
delete process.env.GBRAIN_SUPERVISED;
|
||||
|
||||
// The count(*) query is filtered by registered handler names. If handlers=['noop']
|
||||
// and the queue has 5 'widget-fn' jobs, the SQL `name = ANY($2)` filter returns 0.
|
||||
// The probe engine simulates this by checking handlers before returning a count;
|
||||
// we ALSO capture the SQL to assert the predicate text is actually present (so a
|
||||
// future refactor that silently drops `AND name = ANY(...)` is caught).
|
||||
const capturedStallSql = { sql: null as string | null };
|
||||
const probeEngine = makeProbeEngine({
|
||||
selectOne: async () => [{ ok: 1 }],
|
||||
countWaiting: (handlers) => handlers.includes('widget-fn') ? 5 : 0,
|
||||
capturedStallSql,
|
||||
});
|
||||
|
||||
const worker = new MinionWorker(probeEngine, {
|
||||
queue: 'default',
|
||||
concurrency: 1,
|
||||
healthCheckInterval: 50,
|
||||
stallWarnAfterMs: 100,
|
||||
stallExitAfterMs: 200,
|
||||
pollInterval: 50,
|
||||
stalledInterval: 10_000,
|
||||
maxRssMb: 0,
|
||||
});
|
||||
|
||||
// Register 'noop' but pretend the queue is full of 'widget-fn' (unhandled).
|
||||
worker.register('noop', async () => {});
|
||||
|
||||
const events: Array<{ reason: string }> = [];
|
||||
worker.on('unhealthy', (info) => { events.push(info); });
|
||||
|
||||
const startPromise = worker.start();
|
||||
// Window > stallExitAfterMs; if D1 fix wasn't applied, stall would fire.
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
worker.stop();
|
||||
await startPromise;
|
||||
|
||||
// No stall event — the count for 'noop' handlers is 0, so worker is correctly idle.
|
||||
const stalledEvents = events.filter(e => e.reason === 'stalled');
|
||||
expect(stalledEvents.length).toBe(0);
|
||||
// SQL shape assertion: the production query MUST filter by handler names.
|
||||
// Without this assertion, a future change that drops the predicate would
|
||||
// pass the no-event check above (the handler array would be irrelevant
|
||||
// to the underlying DB but our probe just needs to return 0).
|
||||
expect(capturedStallSql.sql).not.toBeNull();
|
||||
expect(capturedStallSql.sql).toMatch(/name\s*=\s*ANY/i);
|
||||
}, 10_000);
|
||||
|
||||
test('regression (R3): constructor throws when stallExitAfterMs <= stallWarnAfterMs', () => {
|
||||
// The contract on MinionWorkerOpts.stallExitAfterMs says "Must be >
|
||||
// stallWarnAfterMs". Without validation, an exit threshold equal to or
|
||||
// less than the warn threshold made the configured exit time a lie
|
||||
// (warn fires first, exit can't preempt). The constructor now throws
|
||||
// loudly so misconfigurations fail at startup, not at idle-time.
|
||||
expect(() => new MinionWorker(engine, {
|
||||
stallWarnAfterMs: 200,
|
||||
stallExitAfterMs: 100, // less than warn — invalid
|
||||
})).toThrow(/stallExitAfterMs.*must be > stallWarnAfterMs/i);
|
||||
|
||||
expect(() => new MinionWorker(engine, {
|
||||
stallWarnAfterMs: 100,
|
||||
stallExitAfterMs: 100, // equal to warn — also invalid (must be strictly >)
|
||||
})).toThrow(/stallExitAfterMs.*must be > stallWarnAfterMs/i);
|
||||
|
||||
// Sanity: defaults (5min warn / 10min exit) construct without throwing.
|
||||
expect(() => new MinionWorker(engine, {})).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -328,6 +328,79 @@ describe('MinionSupervisor', () => {
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
describe('integration: GBRAIN_SUPERVISED env var (v0.22.14)', () => {
|
||||
it('sets GBRAIN_SUPERVISED=1 on spawned worker child', async () => {
|
||||
const outFile = join(tmpdir(), `gbrain-sup-supervised-${process.pid}-${Date.now()}.txt`);
|
||||
try { unlinkSync(outFile); } catch { /* may not exist */ }
|
||||
|
||||
const h = makeHarness('supervised-env', `printf '%s\n' "\${GBRAIN_SUPERVISED-UNSET}" > "$OUT_FILE" ; exit 0`);
|
||||
|
||||
try {
|
||||
const sup = spawnSupervisor(h, {
|
||||
OUT_FILE: outFile,
|
||||
SUP_MAX_CRASHES: '1',
|
||||
});
|
||||
|
||||
await sup.exited;
|
||||
|
||||
expect(existsSync(outFile)).toBe(true);
|
||||
const childSawEnv = readFileSync(outFile, 'utf8').trim();
|
||||
expect(childSawEnv).toBe('1');
|
||||
} finally {
|
||||
try { unlinkSync(outFile); } catch { /* noop */ }
|
||||
h.cleanup();
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
describe('regression (R3): healthInterval=0 disables timer (v0.22.14)', () => {
|
||||
// Pre-fix: supervisor unconditionally called setInterval(callback, 0),
|
||||
// which schedules a tight loop on the next event-loop tick. The
|
||||
// operator-facing CLI claim "Use 0 to disable" was a lie — passing 0
|
||||
// produced a DB-probe loop that hammered Postgres.
|
||||
//
|
||||
// Post-fix: setInterval is gated on healthInterval > 0. With 0, the
|
||||
// supervisor runs its supervise loop normally with the health timer
|
||||
// entirely absent.
|
||||
//
|
||||
// Assertion strategy: spawn the supervisor with SUP_HEALTH_INTERVAL_MS=0,
|
||||
// a fast worker that exits cleanly, and SUP_MAX_CRASHES=1. A working fix
|
||||
// should produce a single worker spawn → exit → supervisor shutdown
|
||||
// sequence. If the tight-loop bug returned, the supervisor would still
|
||||
// exit (max-crashes path) but the audit trail would show the tell-tale
|
||||
// signature of an extremely high health-check call rate during the brief
|
||||
// window before max-crashes fires. We assert the basic completion path
|
||||
// and let CI's wall-clock detect any pathological CPU spike.
|
||||
it('completes a normal supervise lifecycle with healthInterval=0', async () => {
|
||||
const h = makeHarness('health-interval-zero', 'exit 0');
|
||||
|
||||
try {
|
||||
const sup = spawnSupervisor(h, {
|
||||
SUP_HEALTH_INTERVAL_MS: '0',
|
||||
SUP_MAX_CRASHES: '1',
|
||||
});
|
||||
|
||||
const start = Date.now();
|
||||
const { code } = await sup.exited;
|
||||
const elapsedMs = Date.now() - start;
|
||||
|
||||
// Clean exit (max-crashes path returns 1; this is fine — we just
|
||||
// want to confirm the supervisor reached its terminal state without
|
||||
// hanging or runaway looping).
|
||||
expect(code).toBe(1);
|
||||
|
||||
// Sanity: a tight loop on setInterval(0) plus the spawn-respawn
|
||||
// loop would still terminate at max-crashes, but it would be
|
||||
// measurably slower than a clean run because the event loop is
|
||||
// saturated with health-check callbacks. Cap the upper bound at
|
||||
// 10s — clean runs typically finish in 1–2s.
|
||||
expect(elapsedMs).toBeLessThan(10_000);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
}
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
describe('integration: --max-rss spawn args (v0.21)', () => {
|
||||
it('passes --max-rss 2048 to spawned worker by default', async () => {
|
||||
const outFile = join(tmpdir(), `gbrain-sup-maxrss-${process.pid}-${Date.now()}.txt`);
|
||||
|
||||
Reference in New Issue
Block a user