a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
280 lines
10 KiB
Go
280 lines
10 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"math"
|
||
"os"
|
||
"runtime"
|
||
"runtime/debug"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"go.uber.org/zap"
|
||
|
||
"github.com/zzet/gortex/internal/platform"
|
||
)
|
||
|
||
// Daemon memory envelope: a standing soft memory limit installed at boot,
|
||
// and a forced heap-to-OS release fired at allocation-burst boundaries.
|
||
//
|
||
// The daemon is a long-lived background service that shares a developer's
|
||
// machine. With the runtime default (no memory limit, GOGC=100) the Go GC
|
||
// lets the heap high-water climb toward machine RAM during a burst and then
|
||
// keeps that footprint resident — the observed failure was a multi-GB peak
|
||
// from a warmup / whole-graph-analysis burst pinning the process footprint
|
||
// for hours at idle. A soft memory limit makes the collector pace against a
|
||
// ceiling and resist that balloon growth; the release helper returns a
|
||
// burst's high-water to the OS so the idle footprint tracks the working set
|
||
// rather than the peak. Both are policy, not hard guarantees, and both carry
|
||
// env kill-switches so an operator can bypass them entirely.
|
||
|
||
const (
|
||
// standingMemLimitDivisor takes a quarter of host RAM as the default
|
||
// budget: a background service should leave the bulk of RAM to the
|
||
// editor, compiler, and the app under test.
|
||
standingMemLimitDivisor = 4
|
||
// standingMemLimitFloor is the lowest limit the default policy will
|
||
// set. Below ~1 GiB a daemon with a resident graph would sit in
|
||
// near-constant GC, so the floor trades a little footprint for
|
||
// steady-state responsiveness.
|
||
standingMemLimitFloor = int64(1) << 30 // 1 GiB
|
||
// standingMemLimitCeil caps the default so a large workstation's
|
||
// quarter-RAM figure doesn't hand the daemon a tens-of-GiB budget it
|
||
// never needs — the point of the limit is to resist balloon growth,
|
||
// not to permit it. An operator who genuinely wants more sets an
|
||
// explicit value (honored verbatim, below).
|
||
standingMemLimitCeil = int64(8) << 30 // 8 GiB
|
||
)
|
||
|
||
// memLimitResolution is the outcome of the standing-limit policy: the byte
|
||
// value to install (0 = install nothing), where it came from (for the log
|
||
// line), and an optional warning when a provided value was malformed and
|
||
// ignored.
|
||
type memLimitResolution struct {
|
||
limit int64
|
||
source string // "goenv" | "env" | "config" | "default" | "off" | "unavailable"
|
||
warn string
|
||
}
|
||
|
||
// resolveStandingMemoryLimit is the pure standing-limit policy. Resolution
|
||
// order, highest priority first:
|
||
//
|
||
// 1. GOMEMLIMIT set — the Go runtime already honors it, so we never fight
|
||
// an explicit operator setting: install nothing, source "goenv".
|
||
// 2. GORTEX_DAEMON_MEMLIMIT env var.
|
||
// 3. the daemon.memory_limit config value.
|
||
// 4. the RAM-derived default policy.
|
||
//
|
||
// An explicit env/config value is honored verbatim (the operator knows
|
||
// their machine); only the default is clamped. "off"/"0" at the env or
|
||
// config layer disables the standing limit outright. A malformed env/config
|
||
// value is ignored and the decision falls through to the default with a
|
||
// warning. Pure — every input is a parameter — so the whole precedence
|
||
// table is exhaustively testable without touching the process or the host.
|
||
func resolveStandingMemoryLimit(hostRAM uint64, goenv, env, cfg string) memLimitResolution {
|
||
if strings.TrimSpace(goenv) != "" {
|
||
return memLimitResolution{source: "goenv"}
|
||
}
|
||
for _, layer := range []struct{ name, val string }{
|
||
{"env", env},
|
||
{"config", cfg},
|
||
} {
|
||
v := strings.TrimSpace(layer.val)
|
||
if v == "" {
|
||
continue
|
||
}
|
||
n, err := parseByteSize(v)
|
||
if err != nil {
|
||
// A typo'd operator value should not abort boot; apply the safe
|
||
// default instead and surface why. Deliberately does not fall
|
||
// through to the next layer — a malformed value is a signal, not
|
||
// an invitation to keep guessing.
|
||
d := defaultMemLimitResolution(hostRAM)
|
||
d.warn = fmt.Sprintf("invalid %s memory limit %q: %v", layer.name, layer.val, err)
|
||
return d
|
||
}
|
||
if n <= 0 {
|
||
return memLimitResolution{source: "off"}
|
||
}
|
||
return memLimitResolution{limit: n, source: layer.name}
|
||
}
|
||
return defaultMemLimitResolution(hostRAM)
|
||
}
|
||
|
||
// defaultMemLimitResolution wraps the RAM-derived default. Host RAM of 0
|
||
// means "unknown" (no portable reader on this platform, or the syscall
|
||
// failed): with no machine to reason about, installing an arbitrary limit
|
||
// could throttle a large server or over-commit a tiny one, so the safe
|
||
// answer is to install nothing.
|
||
func defaultMemLimitResolution(hostRAM uint64) memLimitResolution {
|
||
n := defaultStandingMemoryLimit(hostRAM)
|
||
if n <= 0 {
|
||
return memLimitResolution{source: "unavailable"}
|
||
}
|
||
return memLimitResolution{limit: n, source: "default"}
|
||
}
|
||
|
||
// defaultStandingMemoryLimit derives the default soft limit from host RAM:
|
||
// a quarter of it, clamped to [floor, ceil]. Returns 0 when host RAM is
|
||
// unknown. Pure so the clamps are table-testable without a host.
|
||
func defaultStandingMemoryLimit(hostRAM uint64) int64 {
|
||
if hostRAM == 0 {
|
||
return 0
|
||
}
|
||
limit := int64(hostRAM / standingMemLimitDivisor)
|
||
if limit < standingMemLimitFloor {
|
||
limit = standingMemLimitFloor
|
||
}
|
||
if limit > standingMemLimitCeil {
|
||
limit = standingMemLimitCeil
|
||
}
|
||
return limit
|
||
}
|
||
|
||
// parseByteSize parses a human byte size into an exact byte count. It
|
||
// accepts a bare integer (bytes) or an integer with a binary unit suffix —
|
||
// K/KB/KiB, M/MB/MiB, G/GB/GiB, T/TB/TiB — all interpreted as powers of
|
||
// 1024, case-insensitively, since this sizes a memory budget. "off", "0",
|
||
// and the empty string parse to 0 (the caller reads 0 as "disabled"). A
|
||
// malformed number, an unrecognised unit, or a value that would overflow
|
||
// int64 returns an error so the caller can fall back to the default policy.
|
||
func parseByteSize(s string) (int64, error) {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" {
|
||
return 0, nil
|
||
}
|
||
low := strings.ToLower(s)
|
||
if low == "off" || low == "0" {
|
||
return 0, nil
|
||
}
|
||
i := 0
|
||
for i < len(low) && low[i] >= '0' && low[i] <= '9' {
|
||
i++
|
||
}
|
||
numPart := low[:i]
|
||
unit := strings.TrimSpace(low[i:])
|
||
if numPart == "" {
|
||
return 0, fmt.Errorf("invalid byte size %q", s)
|
||
}
|
||
n, err := strconv.ParseInt(numPart, 10, 64)
|
||
if err != nil || n < 0 {
|
||
return 0, fmt.Errorf("invalid byte size %q", s)
|
||
}
|
||
var mult int64
|
||
switch unit {
|
||
case "", "b":
|
||
mult = 1
|
||
case "k", "kb", "kib":
|
||
mult = 1 << 10
|
||
case "m", "mb", "mib":
|
||
mult = 1 << 20
|
||
case "g", "gb", "gib":
|
||
mult = 1 << 30
|
||
case "t", "tb", "tib":
|
||
mult = 1 << 40
|
||
default:
|
||
return 0, fmt.Errorf("invalid byte-size unit %q in %q", unit, s)
|
||
}
|
||
if mult > 1 && n > math.MaxInt64/mult {
|
||
return 0, fmt.Errorf("byte size out of range %q", s)
|
||
}
|
||
return n * mult, nil
|
||
}
|
||
|
||
// applyStandingMemoryLimit resolves and installs the daemon's standing soft
|
||
// memory limit. Call once at boot, after logging and config are up and
|
||
// before warmup starts allocating.
|
||
//
|
||
// Composition with the cold-index window (internal/indexer/gc_tune.go): a
|
||
// cold index briefly raises the limit to a larger budget (RAM/2) and, on
|
||
// exit, restores the value it captured via debug.SetMemoryLimit(-1) — which
|
||
// is exactly the standing limit installed here. Installing this before any
|
||
// index runs is what makes that restore land on our value rather than on
|
||
// "no limit". The two are therefore composable: the daemon holds a modest
|
||
// standing ceiling, cold indexes get their wider one-shot budget, and the
|
||
// standing ceiling comes back afterward untouched.
|
||
func applyStandingMemoryLimit(logger *zap.Logger, cfgVal string) {
|
||
d := resolveStandingMemoryLimit(
|
||
platform.HostPhysicalMemoryBytes(),
|
||
os.Getenv("GOMEMLIMIT"),
|
||
os.Getenv("GORTEX_DAEMON_MEMLIMIT"),
|
||
cfgVal,
|
||
)
|
||
if d.warn != "" && logger != nil {
|
||
logger.Warn("daemon: standing memory limit — falling back to default",
|
||
zap.String("reason", d.warn))
|
||
}
|
||
switch d.source {
|
||
case "goenv":
|
||
if logger != nil {
|
||
logger.Info("daemon: standing memory limit deferred to GOMEMLIMIT")
|
||
}
|
||
return
|
||
case "off":
|
||
if logger != nil {
|
||
logger.Info("daemon: standing memory limit disabled by configuration")
|
||
}
|
||
return
|
||
case "unavailable":
|
||
if logger != nil {
|
||
logger.Debug("daemon: standing memory limit skipped — host RAM unknown")
|
||
}
|
||
return
|
||
}
|
||
debug.SetMemoryLimit(d.limit)
|
||
if logger != nil {
|
||
logger.Info("daemon: standing memory limit applied",
|
||
zap.Int64("bytes", d.limit),
|
||
zap.String("source", d.source))
|
||
}
|
||
}
|
||
|
||
// memReleaseEnabled reports whether post-burst heap release is active. On by
|
||
// default; GORTEX_DAEMON_MEMRELEASE=0 (or "false") disables it.
|
||
func memReleaseEnabled() bool {
|
||
v := os.Getenv("GORTEX_DAEMON_MEMRELEASE")
|
||
return v != "0" && !strings.EqualFold(v, "false")
|
||
}
|
||
|
||
// releaseMemoryToOS forces a GC + scavenge (runtime/debug.FreeOSMemory) so a
|
||
// just-completed allocation burst's high-water heap is returned to the OS
|
||
// promptly instead of pinning the process footprint at its peak.
|
||
//
|
||
// It is called only at burst boundaries (warmup end, the reconcile janitor
|
||
// after a tick that did work), never on a timer: FreeOSMemory runs a full,
|
||
// largely stop-the-world GC cycle costing ~0.1–2 s on a multi-GB heap, so
|
||
// paying it once per burst is fine while paying it periodically would
|
||
// reintroduce exactly the steady-state GC cost the standing limit is tuned
|
||
// to avoid. HeapReleased is monotonic across the forced scavenge, so the
|
||
// logged delta is the bytes this call handed back.
|
||
//
|
||
// GORTEX_DAEMON_MEMRELEASE=0 (or "false") turns it into a no-op.
|
||
func releaseMemoryToOS(logger *zap.Logger, reason string) {
|
||
if !memReleaseEnabled() {
|
||
return
|
||
}
|
||
var before, after runtime.MemStats
|
||
runtime.ReadMemStats(&before)
|
||
start := time.Now()
|
||
debug.FreeOSMemory()
|
||
elapsed := time.Since(start)
|
||
runtime.ReadMemStats(&after)
|
||
// Concurrent allocation between the two reads can re-acquire released
|
||
// pages faster than this call released them, making the raw delta
|
||
// negative; report that as zero net release rather than a nonsense
|
||
// negative byte count.
|
||
freed := int64(after.HeapReleased) - int64(before.HeapReleased)
|
||
if freed < 0 {
|
||
freed = 0
|
||
}
|
||
if logger != nil {
|
||
logger.Info("daemon: released heap to OS",
|
||
zap.String("reason", reason),
|
||
zap.Duration("elapsed", elapsed),
|
||
zap.Int64("freed_bytes", freed),
|
||
zap.Uint64("heap_sys_bytes", after.HeapSys),
|
||
zap.Uint64("heap_released_bytes", after.HeapReleased))
|
||
}
|
||
}
|