zzet--gortex
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 行
10 KiB
Go
280 行
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))
|
|
}
|
|
}
|