Skip to content

fix(#6045): one Go soft memory limit per installation, split 30/70 serve/engine - #6046

Merged
cajasmota merged 1 commit into
mainfrom
worktree-agent-aa4247d31c41353c2
Jul 30, 2026
Merged

fix(#6045): one Go soft memory limit per installation, split 30/70 serve/engine#6046
cajasmota merged 1 commit into
mainfrom
worktree-agent-aa4247d31c41353c2

Conversation

@cajasmota

Copy link
Copy Markdown
Owner

daemon: applied Go soft memory limit was logged twice on every daemon start — once by grafel serve, once by grafel engine --foreground. Each process set GOMEMLIMIT to the full resolved budget independently, so the real ceiling for the installation was 2x what grafel status advertised.

Reported by @auxmedrano with an unusually clean repro — idle daemon, empty index, in_flight=0, 0.0% CPU — and confirmed independently here:

03:31:12.146  applied Go soft memory limit  limit_mb=2560  source="fraction-of-RAM (capped)"
03:31:12.346  applied Go soft memory limit  limit_mb=2560  source="fraction-of-RAM (capped)"

Two lines, ~200 ms apart, one per process. On a 16 GB machine the reporter measured 11.4 GB of 12.2 GB swap in use.

This also undermines epic #5954's own measurements: that work reasoned about bounding memory against an advertised 2560 MB budget which was in fact 5120 MB.

Why the budget is derived, not passed down

The obvious channel — synthesising the child's environment in defaultEngineChildCommand (internal/daemon/supervise.go:81) — is explicitly forbidden there, by a comment recording a past incident: force-appending GRAFEL_DAEMON_ROOT once flipped the child to ~/.grafel/state while serve used ~/.grafel/store, silently dropping serve-written reindex requests.

So both processes instead derive their share from the plane they are running:

  • run(planeMonolith)memPlaneMonolith (whole budget)
  • run(planeServeOnly)memPlaneServe
  • RunEnginememPlaneEngine

Both already see the same environment, the same settings.json and the same host RAM, so each resolves the identical total independently and slices it. Nothing new in the child environment, nothing to keep in sync, and a hand-started grafel engine stays inside the budget too.

The split is 30/70, not 50/50

MemLimitServeShare = 0.30, a named constant with the reasoning recorded beside it.

An even split would be actively harmful. The engine is the write plane and a large reindex peaks at ~1–1.5 GB heap per job — the same measurement that justifies memLimitCeilingMB. A 50/50 split of the 2560 MB default gives the engine 1280 MB, below its normal working set, so the runtime would GC continuously against a limit it cannot honour.

serve's largest structure is the graph_cache mmap, which is file-backed and therefore not Go heap — GOMEMLIMIT does not account for it at all. serve's real heap is MCP and dashboard marshalling.

So: 768 MB serve + 1792 MB engine, summing exactly to 2560 (the engine absorbs rounding). Monolith mode keeps the whole budget.

The RSS-budget admission control is not the same defect

It looks like it, since scheduler: RSS-budget admission control enabled budget_mb=2048 also appears per-process in the reporter's log. But it is armed inside startEnginePlane, and split-mode serve runs planeServeOnly, which skips the engine plane entirely (internal/daemon/server.go:660). Exactly one process arms it.

Pinned in both directions by TestRSSBudgetAdmissionControl_IsEnginePlaneOnly: monolith must log the marker (proving the fixture can exhibit it), split-mode serve must not.

What the log and status now print

applied Go soft memory limit (#3648, split #6045) limit_mb=1792 total_mb=2560 plane=engine source="fraction-of-RAM (capped)"
applied Go soft memory limit (#3648, split #6045) limit_mb=3000 total_mb=10000 plane=serve  source=GRAFEL_DAEMON_MEMLIMIT_MB

From a binary built from this tree:

[ ok ] go soft mem limit: 2560MB (768MB serve + 1792MB engine) (fraction-of-RAM (capped))

The bug was visible in the log, so the fix had to change what the log says.

Verification

8 mutations, all killed: split reverted to per-process-full; status printing one share; monolith halved; the log line reverted to its old fields; RunEngine mapped to the monolith plane; run() mapped to monolith; monolith mapped to the serve plane; serve also arming the engine plane. Independently re-verified — collapsing the split fails TestSplitMemLimitMB_EngineGetsMajority.

Two findings from the author worth recording, both instances of patterns this project keeps hitting:

  • TestRSSBudgetAdmissionControl_IsEnginePlaneOnly was vacuous on first run: the fixture needed MaxRSSBudgetMB: 2048 and a non-nil SchedulerIndex before it could exhibit the marker at all. Caught and fixed before commit.
  • Two process-level tests passed in isolation but failed in full-suite context — sibling tests start in-process daemons that leave the global soft limit set, so the assertion was reading another test's value. Fixed by resetting to math.MaxInt64 in pinProcessMemLimit.

go build ./... && go vet ./... && gofmt -l . clean; internal/daemon/..., internal/cli and cmd/grafel green with raw exit codes via rtk proxy.

Known and disclosed

  • An explicit GOMEMLIMIT env var is still per-process. The Go runtime applies it before main() in both planes and it cannot be retroactively split. That is the operator's explicit choice; the log now names the plane so the doubling is at least visible.
  • Extract subprocesses are a third consumer. The daemon pair's GOMEMLIMIT and the scheduler's RSS budget govern different things — total installation memory is serve + engine + admitted subprocess RSS. Out of scope here, but it belongs in any epic(index): reduce indexing time + RSS + worktree-derived incremental indexing #5954 measurement.
  • The 0.30 constant is an inference, not a measurement. The engine's 1–1.5 GB peak is documented and gives a hard lower bound, but serve's heap ceiling under heavy concurrent MCP fan-out is not measured anywhere. If serve turns out to GC-thrash, this is the single knob and the tests pin the ratio explicitly, so moving it is one line plus updated expectations.
  • Deliberately not addressed: idle footprint. The reporter's third expectation is largely a macOS accounting artifact — Go returns pages with MADV_FREE_REUSABLE and they remain counted in phys_footprint. Measured here: an engine reading 2396 MB fell to 1282 MB under real memory pressure with no work done, and to 264 MB on its own after 12 h idle. The 2x ceiling and the swap pressure are real; the footprint figure is partly not.

Independently adversarially reviewed.

Closes #6045

…rve/engine

`daemon: applied Go soft memory limit (#3648)` was logged twice on every
daemon start — once by `grafel serve`, once by `grafel engine --foreground`.
Each process called debug.SetMemoryLimit with the FULL resolved limit, so on
the default ceiling the real ceiling for the installation was 5120MB while
`grafel status` advertised 2560MB. Every measurement taken against that
budget (epic #5954 in particular) was against a number that was silently 2x.

The resolved limit is now the budget for the WHOLE installation and each
process applies only its plane's share.

## Channel: derived per-plane, not passed down

The engine child is spawned by defaultEngineChildCommand (supervise.go) with
`cmd.Env = os.Environ()` and a load-bearing comment forbidding env synthesis:
force-appending GRAFEL_DAEMON_ROOT there once flipped the child to a different
store layout and silently dropped serve-written reindex requests. Rather than
add a second thing to that env, both processes derive their share from the
plane they are ACTUALLY running:

  run(planeMonolith)   -> memPlaneMonolith  (whole budget)
  run(planeServeOnly)  -> memPlaneServe     (serve share)
  RunEngine            -> memPlaneEngine    (engine share)

Both processes already see the same env, the same settings.json and the same
host RAM, so both resolve the identical TOTAL independently and slice it. No
channel to keep in sync, nothing new in the child env, and a standalone
`grafel engine` started by hand still stays inside the installation budget.

## Ratio: 30/70, not 50/50 (MemLimitServeShare)

The engine is the write plane — scheduler, watcher, extraction, fbwriter — and
is where the allocation happens; a legitimate large reindex peaks at ~1-1.5GB
Go heap per job (the measurement memLimitCeilingMB is already justified by).
An even split of the 2560MB default would hand the engine 1280MB, BELOW its
normal working set, making the runtime GC continuously against a limit it
cannot honour.

serve's large data structure is the graph_cache mmap, which is file-backed and
not Go heap, so GOMEMLIMIT does not account for it at all. What serve actually
allocates is MCP request/response marshalling and dashboard JSON: small,
bursty, short-lived.

0.30 gives serve 768MB and the engine 1792MB on the default ceiling — clears
the measured per-job peak with headroom, and the pair's total is exactly the
advertised figure (the engine absorbs the rounding, so serve+engine == total
for every input).

Monolith (GRAFEL_SPLIT_MODE=0) is one process and keeps the WHOLE budget; the
escape hatch is unchanged and is not halved.

## Reporting

`grafel status` / `grafel doctor` now print the effective total plus both
shares:

  go soft mem limit: 2560MB (768MB serve + 1792MB engine) (fraction-of-RAM (capped); ...)

and the log names the plane and the total:

  applied Go soft memory limit (#3648, split #6045) limit_mb=1792 total_mb=2560 plane=engine

## RSS-budget admission control

Checked — it does NOT have the same defect, despite looking like it does. The
budget is armed in startEnginePlane, and split-mode serve runs with
planeServeOnly, which skips the engine plane entirely, so exactly one process
ever logs `scheduler: RSS-budget admission control enabled`. A regression test
pins that (monolith must arm it, split-mode serve must not) so a future change
that starts the engine plane inside serve cannot reintroduce the doubling
there.

Out of scope, per the issue: idle footprint / retention. On macOS Go returns
pages with MADV_FREE_REUSABLE and they stay counted in phys_footprint; a 2396MB
engine here fell to 1282MB under real memory pressure with no work done. That
is measurement artifact, not retention.

Known and deliberately unchanged: when an explicit GOMEMLIMIT env var is set,
the Go runtime applied it before main() in BOTH planes and we cannot
retroactively split it. That is the operator's explicit choice; the log now
names the plane so the doubling is at least visible.

Tests (strict TDD, all mutation-verified):
  - share arithmetic: sum-to-total, engine majority, disabled stays disabled
  - applyMemoryLimit per plane, asserting the runtime limit AND the log fields
  - RunServe split-mode: real in-process serve applies 3000 of 10000, not 10000
  - RunServe monolith: real in-process daemon keeps the whole 10000
  - RunEngine: real engine subprocess applies 7000 of 10000, not 10000
  - status/doctor line: total plus both shares, and they must sum to the total
  - RSS budget armed by exactly one plane

Mutants confirmed caught: split reverted to per-process-full; status line
reporting one share; monolith halved (both via shareOf and via the plane
mapping); log line reverted to its old fields; each call site reverted to the
monolith plane; serve arming the engine plane.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0111KEDUVWEWm9G5RRnJqXft
@cajasmota
cajasmota merged commit e92e7db into main Jul 30, 2026
1 of 2 checks passed
@cajasmota
cajasmota deleted the worktree-agent-aa4247d31c41353c2 branch August 1, 2026 06:08
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.

Go soft memory limit applied per-process (serve + engine), so effective ceiling is 2x mem_limit — 5 GB retained on an empty index

1 participant