Database and Disk Profiling: Findings and Repairs (October 2026)

On 2026-10-22 we profiled Wikantik with one question: what does it do badly against PostgreSQL and the local disk? Unlike earlier CPU campaigns, frequency was deliberately ignored. A query that runs once a week counted as much as one that runs on every request. So the run exercised every route and every MCP tool once, plus startup, idle time, shutdown, maintenance actions and a short load test.

The run found two destructive bugs, one production storage problem and a long tail of wasted queries and disk work. All the critical and important defects were repaired test-first: each repair has a regression test in the regular unit suite that was seen to fail before the fix. The fixed build was then profiled again with the same harness.

Results at a glance

MeasurementBeforeAfter
Ontology rebuild, 8,733 graphs (cold start)6.5 min; 279,472 fsync + 122,282 msync; 16.6 GB written; 1.84 GB storeabout 3 s; 1 write transaction; 16 MB store
Second rebuild of identical contentstore +1.53 GB, 14.6 GB writtenno growth; nothing committed
Production ontology store79.7 GB, never compactedcompacts ~5 min after the next deploy, then weekly
Page save, request-thread syncs168–180 fsync/msync; 180–277 ms2–4 fsyncs (the new durable page write); 17–42 ms
"Rebuild indexes" (admin)deleted all 18,627 embeddings and every KG mention; 1,213 Lucene commits; 64 skeeps unchanged chunks, embeddings and mentions; 7 Lucene commits in the whole run
Idle wiki, every 5 minutes~2,600 property-file reads + whole page-text cache evicted~490 property-file reads
/api/changes, /api/recent-changes, /api/me/pages450–455 file opens per request0
/feed.xml307 file opens per request0
Search, 2-minute load~80,000 index-file opens, 7,032 directory listingsindex reader shared and reused
/api/pages/for-agent/{id} (anonymous)182 statements5
/api/search (cold cache)204 statements4
/api/bundle28 statements2
/admin/agent-grade-audit?limit=1002,377 statements5
/admin/verification?limit=200413 statements5
/admin/overview245 statements19
/admin/knowledge-graph/proposals?limit=50117 statements9
Startupall vectors loaded twice; 2,326 unchanged rows rewrittenloaded once; no canonical-id rows rewritten

How the run worked

The raw measurements are in loadtest/results/CAMPAIGN-2026-10-22-db-disk.md in the repository.

Findings and repairs

1. The ontology store grew without bound and wrote ~15 GB per rebuild

Problem. The Jena TDB2 ontology store held 79.7 GB in production, still on its first generation since it was recreated on 2026-08-19. Locally, a full rebuild of 8,733 named graphs took 6.5 minutes, made 279,472 fsync and 122,282 msync calls (338 s inside sync calls), and wrote 16.6 GB to disk to produce a 1.84 GB store. Rebuilding identical content again added another 1.5 GB.

Cause.

Repair.

Tests.

Verified. A cold rebuild now takes about 3 seconds and leaves a 16 MB store. A second rebuild of the same content left the store size unchanged.

2. "Rebuild indexes" deleted every embedding and Knowledge Graph mention

Problem. POST /admin/content/rebuild-indexes ran DELETE FROM kg_content_chunks. The foreign keys cascade, so it also removed every row in content_chunk_embeddings, chunk_entity_mentions and experiment_embeddings: 18,635 row-by-row cascade deletes per table. It then re-inserted every chunk one statement at a time and re-embedded the whole corpus, which takes hours on a CPU embedder, with dense retrieval empty in the meantime. Entity mentions come from LLM extraction, so they never came back.

Repair.

Tests. Real-database tests check that an unchanged page keeps its chunk ids, its embedding and its KG mention; that a page with one edited section re-embeds exactly one chunk; that a deleted page's chunks are swept; that an empty listing deletes nothing; and that a page created mid-rebuild keeps its chunks.

Verified. After a rebuild, 18,544 of 18,635 embeddings remained. The rest belonged to chunks the current chunker splits differently, and are re-embedded by the stale pass. The two mention rows that went were on pages that no longer exist.

3. Page files were rewritten in place, and write errors were lost

Problem.

Repair.

Tests. A write into a read-only page directory throws and leaves the old content intact, where it used to succeed silently. A failed write never truncates refmgr.ser or an attribute cache file. A temp file is not an attachment version.

4. Every save committed ontology transactions on the request thread

Problem. Each page save committed the page graph and then each of its concept graphs (cluster, tags) as separate TDB2 transactions, synchronously in the HTTP request. That was about 170 fsync/msync calls per save, or 130–224 ms of a 180–277 ms save.

Repair. Page sync moved to a single background worker (OntologyPageSyncQueue):

Verified. A page save now takes 17–42 ms with 2–4 syncs, which are the new durable page write itself.

5. The page listing dropped pages, and refreshed by rescanning

Problem.

Repair.

Tests. A page whose per-page cache entry expired is still listed. A save, a delete and a rename are reflected immediately, and a rename lists the page under its new name. An unchanged page's text survives a refresh.

6. The public change feeds scanned the page store on every request

Problem. /api/changes, /api/recent-changes, /api/me/pages and /feed.xml read the version properties of hundreds of pages on every request (300–455 file opens each), unauthenticated, because getAllChangedSince bypassed the cache.

Repair. Changed-since queries are served from the page listing (finding 5). Verified: zero page-store file opens for these routes.

7. Lucene opened a reader per query and two writers per update

Problem.

Repair.

Tests. Draining 25 queued pages makes 1 commit, not 25. Searches reuse one reader until a commit. A failed re-index keeps the page's previous document. Closing from an interrupted thread still commits and releases the index lock.

8. N+1 queries on reader, agent and admin paths

PathCauseRepair
/api/pages/for-agent, /admin/agent-grade-audit, /admin/verification, /admin/overviewverificationOf was an uncached one-row query, called once per page (46,186 times in 2 minutes)Verification rows are held in memory with the structural index (VerificationStore), loaded in one query at rebuild and kept current by its writes
/api/search, retrieve_contextthe "batched" related-pages lookup ran one query per pageOne windowed statement over page_name = ANY( ? )
/api/bundle, /api/briefing, assemble_bundle, get_briefingone page_canonical_ids query per cited sectionResolved from the structural index. A page with no authored canonical_id (an in-memory id that changes per restart) is never cited by its unstable id
/admin/knowledge-graph/proposals, MCP list_proposalsnode-by-name and rejection lookups per proposalTwo batched lookups per page of proposals (KgBatchLookups)
Page knowledge sliceper-entity node and edge queriesBatched with = ANY( ? )
/admin/drift/trendone count query per sweepOne query for all sweeps

9. Authentication and audit

10. Transactions held across remote calls, and reads that did not stream

11. Startup waste and robustness

Corrections

The first report got three things wrong, and the repair work caught them:

Also note: the per-page text cache expires entries after 60 s regardless of refreshes, so finding 5's text-cache benefit is smaller than first estimated. The listing fix is the important part.

Found while reviewing the repairs

An independent review of the 19-commit change set found problems in the new code, all fixed with tests before release:

The CI complexity ratchet had also been red since 2.4.60. The cause was BundleServiceWiring, which tripped the GodClass rule. It now passes across every module, after splitting out BundleRerankers, VerificationStore, PageRevisions, KgBatchLookups and EventActors.

Not repaired yet

Notes for the next deploy

Re-running the profile

The method is reproducible:

  1. Restore the database into an instrumented container.
  2. Point the wiki at copies of the pages and work directory.
  3. Start Tomcat under strace with the JFR settings.
  4. Run the coverage sweep phases, taking a statement snapshot between each.

The scripts and the before/after measurements are summarised in the campaign report. The most useful single technique was per-request attribution: a sequential sweep with timestamps, joined against the statement log and the syscall trace.


Cluster Hub: Wikantik Platform Development Hub: Architecture, MCP Protocols, and Agent Briefings (wikantik-development)