Skip to content

fix(enricher): gate the durable refresh so an idle nectar scales Deep Lake to zero#19

Open
chrisl10 wants to merge 2 commits into
legioncodeinc:mainfrom
chrisl10:fix/enricher-idle-scale-to-zero
Open

fix(enricher): gate the durable refresh so an idle nectar scales Deep Lake to zero#19
chrisl10 wants to merge 2 commits into
legioncodeinc:mainfrom
chrisl10:fix/enricher-idle-scale-to-zero

Conversation

@chrisl10

@chrisl10 chrisl10 commented Jul 3, 2026

Copy link
Copy Markdown

Problem

The steady-state enricher re-seeds its in-memory mirror from Deep Lake on every cycle: refreshWorkingSet() -> DeepLakeEnricherStore.refresh -> a SELECT ... FROM hive_graph_versions. Because that refresh runs unconditionally at the top of runEnricherCycle, before the empty-work early return, an idle repo issues one Deep Lake read every poll interval (30s by default) indefinitely, even when there is nothing to enrich.

A steady 30s read cadence keeps the Activeloop serverless pod from ever reaching the sustained-idle window it needs to scale to zero, so it stays warm and bills compute-uptime the whole time the daemon is up. This is the same idle-burn class honeycomb removed in PRD-062b (adaptive poll backoff so an idle daemon lets the connection hibernate). nectar re-introduces it, via a poll rather than a held socket (nectar's transport is statelessly per-query, which is good; the cost is the refresh frequency, not a pinned connection).

It only bites once the enricher is live (Portkey configured), but that is nectar's primary use case.

Fix: gate the refresh on a one-bit dirty signal

  • enricher/refresh-signal.ts (new): a read-and-clear flag shared by producer and consumer. Starts dirty so the first cycle refreshes once (to pick up rows persisted before this boot).
  • StoreBridge gains an onDurableWrite callback: the registration leg is the only producer of new durable rows this daemon writes, so it marks the signal dirty after each SUCCESSFUL durable write (not for a parked/failed write).
  • runEnricherCycle gains shouldRefresh: it consumes the flag to decide whether a cycle is worth a Deep Lake round trip. When absent (or returning true) the legacy always-refresh behavior is preserved, so every existing caller and test is unaffected.
  • cli.ts wires the producer (onRegistrationDurableWrite -> markDirty) and the consumer (shouldRefresh -> consume) around a single createRefreshSignal().

Result: an idle repo (no file changes) marks nothing dirty, the enricher issues zero Deep Lake reads, and the pod idles to zero. A file change flushes a durable registration write, which arms exactly one refresh on the next tick, so enrichment latency is unchanged (a change is still picked up within one poll interval). The enricher's own describe write-backs go through a separate store and never self-trigger a refresh.

I deliberately did not touch the enricher's flat poll cadence: once the refresh is gated, an idle tick is a local in-memory mirror scan with no network I/O, so adding backoff would only add latency for no cost saving.

Kill switch

Adds NECTAR_ENRICHER_ENABLED (default true). Setting it false runs registration and on-demand brood but no background enricher poll, so an idle daemon issues zero Deep Lake reads. This is an operator escape hatch for running against a shared org before this fix is deployed, and it is also the natural primitive for a "one describer device per project" fleet layout (run the enricher live on one device, off on the rest, to avoid duplicated describe work).

Testing

  • npm run typecheck clean.
  • test/refresh-signal.test.ts: signal read-and-clear + re-arm semantics.
  • test/enricher-refresh-gate.test.ts: the cycle skips the refresh when shouldRefresh is false, refreshes when true, and preserves legacy always-refresh when unwired.
  • test/config.test.ts: NECTAR_ENRICHER_ENABLED parsing (default, kill-switch values, garbage-falls-back).
  • Full npm test green (the only intermittent red is the pre-existing live Deep Lake network round-trip test, which is unrelated to this change).

Scope / out of scope

This addresses the idle scale-to-zero cost only. The separate multi-device concern (two devices enriching the same project_id on divergent working trees can duplicate describe calls and thrash the latest-version, since there is no device dimension or cross-device describe lease) is intentionally not in this PR.

Happy to adjust the refresh-gating approach if you would prefer a different shape (for example a low-frequency periodic backstop refresh for cross-device sync freshness, at some idle-cost).

Summary by CodeRabbit

  • New Features
    • Added an enricherEnabled runtime flag (via NECTAR_ENRICHER_ENABLED) to enable or disable steady-state enricher background polling.
    • Introduced a durable “refresh-needed” signal so the enricher refreshes only after relevant durable registration writes.
  • Bug Fixes
    • The enricher now reliably honors environment-configured enablement.
    • Refresh gating ensures refresh runs only when needed (including an initial startup refresh) and remains robust to callback errors.

… Lake to zero

The steady-state enricher re-seeds its in-memory mirror from Deep Lake on every
cycle (`refreshWorkingSet` -> a `SELECT ... FROM hive_graph_versions`). Because
the refresh runs unconditionally at the top of `runEnricherCycle`, before the
empty-work early return, an idle repo issues one Deep Lake read every poll
interval (30s) forever. A steady 30s read cadence keeps the Activeloop
serverless pod from ever reaching its idle window, so it never scales to zero:
the same idle compute burn honeycomb removed in PRD-062b, reintroduced here.

Fix: gate the refresh on a one-bit dirty signal.

- `enricher/refresh-signal.ts`: a read-and-clear flag shared by producer and
  consumer. Starts dirty so the first cycle refreshes once.
- `StoreBridge` (`onDurableWrite`): the registration leg is the only producer of
  new durable rows this daemon writes, so it marks the signal dirty after each
  successful durable write. Not fired for a parked/failed write.
- `runEnricherCycle` (`shouldRefresh`): consumes the flag to decide whether a
  cycle is worth a Deep Lake round trip. Absent `shouldRefresh` keeps the legacy
  always-refresh behavior, so every existing caller and test is unaffected.

Result: an idle repo (no file changes) marks nothing dirty, the enricher issues
zero Deep Lake reads, and the pod idles to zero. A file change flushes a durable
registration write, which arms exactly one refresh on the next tick. Enrichment
latency is unchanged (a change is still picked up within one poll interval); the
enricher's own describe write-backs go through a separate store and never
self-trigger a refresh.

Also adds `NECTAR_ENRICHER_ENABLED` (default true) as an operator kill switch:
`=false` runs registration and on-demand brood but no background enricher poll,
for running against a shared Deep Lake org before this fix is deployed.

Tests: refresh-signal semantics, the cycle refresh gate (skip when idle, refresh
when armed, legacy behavior when unwired), and the env kill switch parsing.
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b06b1344-2734-42e7-a6e9-0f8b638efd14

📥 Commits

Reviewing files that changed from the base of the PR and between a00b52f and 5c880e7.

📒 Files selected for processing (1)
  • src/daemon.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/daemon.ts

📝 Walkthrough

Walkthrough

This PR adds a refresh-signal gate for enricher working-set refreshes, a resolved enricherEnabled config flag, durable-write callbacks from registration storage, and CLI wiring that connects durable writes to enricher refresh cycles.

Changes

Refresh gate feature

Layer / File(s) Summary
RefreshSignal primitive
src/enricher/refresh-signal.ts, test/refresh-signal.test.ts
New RefreshSignal interface and createRefreshSignal() factory implement markDirty, consume, and dirty semantics, with tests covering default dirtiness, read-and-clear behavior, coalescing, and non-destructive reads.
enricherEnabled config flag
src/config.ts, test/config.test.ts
Adds DEFAULT_ENRICHER_ENABLED, enricherEnabled fields on config types, boolean env parsing, and resolveConfig() precedence for NECTAR_ENRICHER_ENABLED, with matching tests.
Enricher cycle refresh gating
src/enricher/cycle.ts, test/enricher-refresh-gate.test.ts
Adds optional shouldRefresh to EnricherCycleDeps and gates refreshWorkingSet on it, with tests for gated, enabled, and legacy behavior.
Durable write success callback
src/registration/store-bridge.ts, src/daemon.ts
Adds onDurableWrite to StoreBridgeOptions, invokes it after successful durable writes, and exposes onRegistrationDurableWrite on daemon assembly options.
CLI daemon wiring of refresh gate
src/cli.ts
Creates a refreshSignal, passes enricherEnabled and onRegistrationDurableWrite into daemon assembly, and wires the enricher cycle to refreshSignal.consume().

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • legioncodeinc/nectar#18: Introduces the base enricher refresh and durable-write queue plumbing that this PR extends with gating and callbacks.

Poem

I sniff the writes, then do a hop,
The dusty refreshes start and stop.
One dirty signal, then serene—
A tiny rabbit keeps things clean 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is specific and matches the main change: gating enricher refresh so idle instances can scale to zero.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/config.ts`:
- Around line 151-159: The enricher loop is still being gated from the raw
options instead of the resolved daemon config, so it can ignore
NECTAR_ENRICHER_ENABLED and drift from daemon.config.enricherEnabled. In
assembleDaemon(), update the enricher startup condition to use the resolved
config returned by resolveConfig(options), specifically config.enricherEnabled,
so the runtime behavior always matches the resolved configuration.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f5ec1706-2fdb-4e4e-863d-395fc1c90423

📥 Commits

Reviewing files that changed from the base of the PR and between d720f73 and a00b52f.

📒 Files selected for processing (9)
  • src/cli.ts
  • src/config.ts
  • src/daemon.ts
  • src/enricher/cycle.ts
  • src/enricher/refresh-signal.ts
  • src/registration/store-bridge.ts
  • test/config.test.ts
  • test/enricher-refresh-gate.test.ts
  • test/refresh-signal.test.ts

Comment thread src/config.ts
… options

Review follow-up: the enricher start condition used `options.enricherEnabled ??
true`, which sees only the raw option and ignores NECTAR_ENRICHER_ENABLED for any
caller that does not re-forward the resolved value, and can drift from
`daemon.config.enricherEnabled`.

assembleDaemon already resolves `config = resolveConfig(options)`, whose
precedence is override -> env -> default, so `config.enricherEnabled` honors both
an explicit `enricherEnabled` override (the tests that pass `enricherEnabled:
false`) and the env, with no drift. Gate on it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant