Skip to content

Memory Management

Grafeo manages memory through a unified buffer manager, arena allocators for query execution and transparent spill-to-disk for large operations.

Buffer Manager

The buffer manager controls a single memory budget shared across all operations. By default it claims 75% of the memory the process may use: the smaller of the machine's memory and any limit set on the process from outside (a cgroup level on Linux, at the process's own cgroup or any parent; a job object on Windows). When neither can be read the figure is 1 GiB, a 768 MiB budget, and the open logs a warning. In the browser (WebAssembly) the figure is a fixed 1 GiB by design, a 768 MiB budget, with no warning: a tab's real ceiling depends on the browser and the device. Config::with_memory_fraction takes another share of the same figure, detected at each open and never stored in the file. Config::with_memory_limit sets bytes: a database file stores the limit given when it is created, or later through set_stored_config; a limit given at a later open applies to that open only.

What the limit counts

The memory limit bounds what the database's own structures hold: the store's tables, columns and versions, its indexes, the RDF store, the change data capture log, open transactions' change sets and the queries' operator buffers. Each counts the capacity it allocates, as it grows. The process uses more than that: the allocator's own bookkeeping and the free space it keeps, whole pages where the structures use part of one, and memory the database does not account for (thread stacks, the plan and query caches, the language runtime of a binding). Measured on Linux against the process's private memory (Private_Clean + Private_Dirty in /proc/self/smaps_rollup), the counted bytes were 72% of the growth for 100,000 nodes with eight integer properties, and 76% for 10,000 nodes with a 384-dimension vector and its index. So a process holding a database at a limit L grows to about 1.4 times L (1 / 0.72; 1.3 times in the vector case), roughly 40% more than the limit: leave that margin when choosing it. To keep the process under 4 GiB, for example, set a limit of about 2.5 GiB, which keeps some room beside that margin.

At the limit, a statement that needs more memory than the database can free (by evicting or spilling what it can) fails with GRAFEO-S001 and changes nothing; reads keep working. A database that already holds at or above the limit when it opens (a file written under a higher limit, or a limit lowered since) still opens: open_warnings() reports MEMORY_LIMIT_PASSED, and writes that need memory fail until the limit is raised.

Memory is tracked across four regions that share the unified budget:

Region Contents
GraphStorage Nodes, edges, properties, adjacency lists
IndexBuffers B-tree, hash, trie indexes
ExecutionBuffers DataChunks, hash tables, sort buffers
SpillStaging Staging area for operators under memory pressure

Pressure Levels

The buffer manager responds to memory pressure in stages:

Level Threshold Action
Normal < 70% No action
Moderate 70-85% Proactive eviction of cold data
High 85-<100% Aggressive eviction, trigger spilling
Critical 100% Refuse what does not fit after eviction (GRAFEO-S001)

Eviction follows LRU (Least Recently Used) with priority hints: cached results are evicted first, then intermediate results, then active operator state.

Arena Allocators

Query execution allocates many short-lived objects (intermediate tuples, hash table entries, sort buffers) that all share the same lifetime: the query. Arenas let these allocate with a simple pointer bump and deallocate in one bulk reset at query end, avoiding per-object free overhead and heap fragmentation.

Arena:
├── Chunk 1 (1MB): [allocated][allocated][free...]
├── Chunk 2 (1MB): [allocated][free.............]
└── Chunk 3 (1MB): [free......................]

Allocation: Bump pointer in current chunk
Deallocation: Reset entire arena at once
use grafeo_common::memory::Arena;
use grafeo_common::types::EpochId;

let arena = Arena::new(EpochId::INITIAL).unwrap();

// Allocate within arena
let data = arena.alloc_value(node).unwrap();
let more = arena.alloc_slice(&values).unwrap();

Spill to Disk

Large operations can spill to disk when memory is exhausted. Spilling is triggered automatically when the buffer manager reaches High or Critical pressure levels.

Operator Spill Strategy
Hash Join Partition both sides by hash, spill partitions that don't fit, process spilled partitions later
Aggregate Partition by group key
Sort External merge sort: sort chunks in memory, write sorted runs to disk, merge runs

Under memory pressure, embedding columns spill by row group, and each graph's newest row group keeps its column in memory, since new rows go there. After an explicit spill (TierOverride::ForceDisk through Config::with_section_tier, or a spill asked for by name) the newest group is on disk too, and a write into it reads its column back into memory first, so at the memory limit it can be refused until a later release, which writes such rows without reading the spilled part back (#713).

Configuration

use grafeo_engine::Config;

let config = Config::default()
    .with_memory_limit(4 * 1024 * 1024 * 1024)  // 4 GB explicit limit
    .with_spill_path("/tmp/grafeo-spill");       // Directory for spill files

let db = GrafeoDB::with_config(config);