Living Document Notice
Published 2026-09-15. The evolving architecture, revisions, and connected notes for this dispatch live in the Stax Digital Garden.
Resident Memory Caps - Preventing RSS Bloat in Batch Vault Ingestion
Summary
Parsing massive note archives often causes unbounded heap expansion when streaming allocators buffer entire document trees in memory. Monolithic parsers reading nested XML trees or recursive JSON structures can exhaust available system RAM, triggering operating system out-of-memory killers that crash adjacent batch workers.
Outrigger enforces strict resident set size (RSS) ceiling limits via operating system cgroups and job objects. Setting explicit memory boundaries isolates worker allocations, forcing parsers to stream document tokens through bounded memory buffers rather than materializing entire vaults into heap memory.
Enforcing Cgroup V2 Limits on Linux Workers
On Linux runners, Outrigger places each conversion worker into a dedicated cgroup v2 slice with strict limits configured on memory.max and memory.high. The high watermark triggers background kernel page reclamation before the hard maximum is reached, allowing memory-constrained parsers to flush transient string allocations without terminating.
# Provision dedicated cgroup slice for Outrigger worker
WORKER_CGROUP="/sys/fs/cgroup/outrigger/worker-$$"
mkdir -p "${WORKER_CGROUP}"
# Set hard ceiling at 256MB and throttle warning threshold at 200MB
echo "268435456" > "${WORKER_CGROUP}/memory.max"
echo "209715200" > "${WORKER_CGROUP}/memory.high"
# Bind worker process to the cgroup
echo $$ > "${WORKER_CGROUP}/cgroup.procs"
If a parser attempts to allocate beyond 256MB due to recursive entity expansion or deeply nested DOM trees, the Linux kernel invokes the cgroup out-of-memory killer targeting solely that specific worker process. The parent supervisor catches the SIGKILL termination, logs the byte offset of the offending note, and isolates the source file into quarantine storage without affecting neighboring workers.
POSIX Resource Limits via setrlimit
In containerized environments lacking root permissions to mount cgroup slices, Outrigger falls back to POSIX setrlimit system calls to cap address space (RLIMIT_AS) and data segment size (RLIMIT_DATA).
#include <sys/resource.h>
#include <stdio.h>
#include <stdlib.h>
void enforce_worker_memory_boundary(rlim_t max_bytes) {
struct rlimit mem_limit;
mem_limit.rlim_cur = max_bytes;
mem_limit.rlim_max = max_bytes;
if (setrlimit(RLIMIT_AS, &mem_limit) != 0) {
perror("setrlimit RLIMIT_AS failed");
exit(EXIT_FAILURE);
}
}
When RLIMIT_AS is exceeded, subsequent malloc or mmap syscalls return ENOMEM. The Outrigger memory allocator intercepts ENOMEM, flushes open file buffers, and reports a structured exhaustion error code back to the orchestrator.
Memory Allocation Metrics Across Parsing Strategies
The table below compares heap allocation behavior between unconstrained whole-file parsing and Outrigger stream-bounded ingestion when processing a 12GB export archive containing 45,000 notes.
| Processing Model | Peak RSS per Worker | Page Fault Count | Ingestion Throughput | Worker OOM Terminations |
|---|---|---|---|---|
| Unconstrained DOM Parser | 1,842 MB | 472,110 | 14.2 MB/s | 18 crashes |
| Chunked Token Stream (1MB) | 48 MB | 12,430 | 38.6 MB/s | 0 crashes |
| Outrigger Cgroup Cap (256MB) | 62 MB | 15,890 | 37.1 MB/s | 0 crashes |
Fallback RLIMIT_AS (256MB) |
58 MB | 14,210 | 36.8 MB/s | 0 crashes |
Stream-bounded parsing maintains flat RSS curves regardless of archive size, keeping memory footprints predictable during sustained batch execution.
Windows Job Object Memory Configuration
For Windows runtimes, Outrigger binds child processes to an anonymous Job Object with JOBOBJECT_EXTENDED_LIMIT_INFORMATION.
use windows::Win32::System::JobObjects::{
SetInformationJobObject, JobObjectExtendedLimitInformation,
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_PROCESS_MEMORY,
};
use windows::Win32::Foundation::HANDLE;
use std::mem::size_of;
pub unsafe fn apply_windows_memory_ceiling(job: HANDLE, limit_bytes: usize) -> bool {
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_PROCESS_MEMORY;
limits.ProcessMemoryLimit = limit_bytes;
SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
&limits as *const _ as _,
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
).is_ok()
}