Files
packfs/tests/test_index_stress.c
T
retoorandClaude Sonnet 5 64283d587b Fix the O(n^2) bulk create/unlink: flat array -> persistent treap
BENCH.md's earlier finding was real: every structural write (create/
unlink/mkdir) copied the entire sorted UpperSnapshot entry array before
publishing the next snapshot, making bulk sequential creation O(n^2).
concept.md Section 5.3 names the exact condition for reconsidering this
("a persistent structurally-shared tree structure is not required
until this assumption is empirically violated") -- that condition was
measured, not hypothesized, so this closes it rather than leaving it
as a documented-but-open limitation.

The index is now a persistent treap (src/upper.c): a structural write
copies only the O(log n) nodes on the path to the change, sharing
every other node (its own refcount, cascading like MutCell's and
UpperSnapshot's) with whichever snapshot(s) it was built from. Chosen
over a persistent AVL/red-black/weight-balanced tree because deletion
in those can need O(log n) rebalancing rotations -- each a real
allocation in a persistent setting -- where a treap needs only O(1)
amortized rotations for insert and delete (Seidel & Aragon 1996), with
expected O(log n) height regardless of insertion order, including the
sorted-by-creation-order pattern that made the flat array quadratic in
the first place. Liljenzin's "Confluently Persistent Sets and Maps"
(arXiv:1301.3388) documents persistent treaps giving O(1) snapshots
for MVCC specifically, which is this exact use case. Full reasoning
and the ownership convention (functions consume one ref of their tree
arguments, return one owned ref) are in upper.c's comment above
struct TreapNode.

Blast radius kept deliberately small: snapshot_upsert/snapshot_remove
keep their exact original signatures, so upper_create/upper_mkdir/
upper_remove/upper_rename/upper_copy_up needed zero changes.
upper_lookup/upper_has_children keep their exact contracts. Only
upstd_readdir and overlay_readdir's manual array scans became calls to
a new upper_visit_range (O(log n + r) range query, replacing an O(n)
scan in both, a bonus fix beyond what was strictly necessary) since
there's no flat array left to scan.

Added tests/test_index_stress.c: thousands of randomized (not
sequential) creates/deletes/renames across nested directories,
cross-checked against an independent reference model after every
round, not just "did it not crash" -- exercises exactly the code path
the O(n^2) bug and this fix live in, at a scale the other tests don't
reach. Verified under -fsanitize=undefined (150+ runs across this
change's lifetime, 0 failures) and -fsanitize=address (100+ runs, 0
real findings; known sandbox ASan-startup flakes excluded, see prior
commits) per CLAUDE.md's sanitizer rule for upper.c/overlay.c changes.

Measured result (make bench, same environment as the original
finding): mem create 257x faster, unlink 330x faster, mkdir 65x
faster, concurrent mixed workload 131x faster -- and, the comparison
that matters, mem now beats raw fs at every one of these (was losing
by 6-22x before). The complexity-class change is confirmed the same
way the O(n^2) was found: mkdir at N=4,000 vs create at N=20,000 now
shows a 7.15x slowdown for a 5x increase in N, matching the O(n log n)
prediction (5.97x) rather than the old O(n^2) one (25x). Full
before/after tables in BENCH.md's new "Resolution" section, which
keeps the original run as the historical record rather than
overwriting it, per this project's own documentation standard.

Recorded the fix in CLAUDE.md's "Known performance characteristics"
(marked RESOLVED, not silently removed) and its architecture map.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UqJpkdJ6Njnt1pw3CbghzB
2026-09-14 08:09:49 +00:00

180 lines
6.2 KiB
C

/*
* test_index_stress.c — correctness of the persistent treap index
* (Section 9.1's sort order; see CLAUDE.md's "Known performance
* characteristics" and BENCH.md for why it replaced a flat array) under
* heavy, randomized structural churn, cross-checked against an
* independent reference model rather than just "did it not crash."
*
* This exercises exactly the code path BENCH.md's O(n^2) finding and its
* fix live in: upper.c's snapshot_upsert/snapshot_remove and the treap
* (split3/merge, node refcounting) beneath them, at a scale (thousands
* of entries, non-sequential insertion/deletion order, nested
* directories, renames) the other test files don't reach.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "packfs.h"
#include "test_harness.h"
#define N 8000
static int g_alive[N];
static void name_for(int id, char *buf, size_t cap) {
/* deliberately nested, to exercise readdir's prefix-range logic at
* more than one level, not just a flat root directory */
snprintf(buf, cap, "/d%03d/f%06d.dat", id % 50, id);
}
static void shuffle(int *order, int n) {
for (int i = n - 1; i > 0; i--) {
int j = rand() % (i + 1);
int t = order[i]; order[i] = order[j]; order[j] = t;
}
}
static void create_one(Vfs *v, int id) {
char path[64], parent[16];
name_for(id, path, sizeof(path));
snprintf(parent, sizeof(parent), "/d%03d", id % 50);
int err = 0;
if (vfs_stat(v, parent, &(VfsStat){0}) != VFS_OK) vfs_mkdir(v, parent);
VfsFile *f = vfs_open(v, path, VFS_O_WRONLY | VFS_O_CREAT | VFS_O_TRUNC, &err);
CHECK(f != NULL);
if (!f) return;
char payload[32];
int n = snprintf(payload, sizeof(payload), "id=%d", id);
CHECK_EQ_INT(vfs_write(f, payload, (pfs_usize)n), n);
vfs_close(f);
g_alive[id] = 1;
}
static void delete_one(Vfs *v, int id) {
char path[64];
name_for(id, path, sizeof(path));
CHECK_EQ_INT(vfs_unlink(v, path), VFS_OK);
g_alive[id] = 0;
}
/* Cross-checks the live tree against g_alive[]: every alive id must
* stat successfully with the right content; every dead id must NOENT;
* and the total count reachable via nested readdir must match the
* reference model's count exactly (not just "at least"). */
static void verify_consistency(Vfs *v) {
int expected_total = 0;
for (int id = 0; id < N; id++) {
char path[64];
name_for(id, path, sizeof(path));
VfsStat st;
int rc = vfs_stat(v, path, &st);
if (g_alive[id]) {
expected_total++;
CHECK_EQ_INT(rc, VFS_OK);
if (rc == VFS_OK) {
CHECK_EQ_INT(st.kind, VFS_KIND_FILE);
int err = 0;
VfsFile *f = vfs_open(v, path, VFS_O_RDONLY, &err);
CHECK(f != NULL);
if (f) {
char buf[32] = {0}, expect[32];
vfs_read(f, buf, sizeof(buf) - 1);
snprintf(expect, sizeof(expect), "id=%d", id);
CHECK_STR_EQ(buf, expect);
vfs_close(f);
}
}
} else {
CHECK_EQ_INT(rc, VFS_ERR_NOENT);
}
}
/* Full nested readdir sweep: sum of children across every /dNNN that
* still has any live file must equal the reference model's count.
* This exercises upper_visit_range's prefix pruning at every
* directory, not just the root. */
int counted = 0;
for (int d = 0; d < 50; d++) {
char dirpath[16];
snprintf(dirpath, sizeof(dirpath), "/d%03d", d);
VfsDir dir;
if (vfs_readdir(v, dirpath, &dir) == VFS_OK) {
counted += (int)dir.count;
vfs_dir_free(&dir);
}
}
CHECK_EQ_INT(counted, expected_total);
}
int main(void) {
srand(20260914);
Vfs *v = vfs_new();
Backend *mem = backend_mem_new();
CHECK_EQ_INT(vfs_mount(v, "/", mem), VFS_OK);
int order[N];
for (int i = 0; i < N; i++) order[i] = i;
/* round 1: create everything, in randomized (non-sequential) order —
* the exact pattern that made the flat array O(n^2); the treap must
* both stay correct and (implicitly, via this test completing in
* reasonable time under sanitizers) stay fast. */
shuffle(order, N);
for (int i = 0; i < N; i++) create_one(v, order[i]);
verify_consistency(v);
/* round 2: delete a random 40%, in a different random order */
shuffle(order, N);
for (int i = 0; i < N * 2 / 5; i++) delete_one(v, order[i]);
verify_consistency(v);
/* round 3: recreate half of what was just deleted, delete a
* different random slice of what's still alive, interleaved by
* iterating one combined shuffled order over "toggle this id" */
shuffle(order, N);
for (int i = 0; i < N; i++) {
int id = order[i];
if (id % 3 == 0) {
if (g_alive[id]) delete_one(v, id); else create_one(v, id);
}
}
verify_consistency(v);
/* renames: move a sample of live files to a fresh, previously-unused
* path, verify old path gone / new path present with right content */
for (int id = 0; id < N; id += 37) {
if (!g_alive[id]) continue;
char from[64], to[80];
name_for(id, from, sizeof(from));
snprintf(to, sizeof(to), "%s.moved", from);
CHECK_EQ_INT(vfs_rename(v, from, to), VFS_OK);
VfsStat st;
CHECK_EQ_INT(vfs_stat(v, from, &st), VFS_ERR_NOENT);
CHECK_EQ_INT(vfs_stat(v, to, &st), VFS_OK);
CHECK_EQ_INT(vfs_rename(v, to, from), VFS_OK); /* move it back so verify_consistency's model still holds */
}
verify_consistency(v);
/* final: delete everything still alive, in yet another random order;
* the tree must end up empty and every readdir empty. */
shuffle(order, N);
for (int i = 0; i < N; i++) if (g_alive[order[i]]) delete_one(v, order[i]);
verify_consistency(v);
for (int d = 0; d < 50; d++) {
char dirpath[16];
snprintf(dirpath, sizeof(dirpath), "/d%03d", d);
VfsDir dir;
if (vfs_readdir(v, dirpath, &dir) == VFS_OK) {
CHECK_EQ_INT(dir.count, 0);
vfs_dir_free(&dir);
}
}
vfs_unmount(v, "/");
backend_free(mem);
vfs_free(v);
TEST_MAIN_END();
}