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.
| Measurement | Before | After |
|---|---|---|
| Ontology rebuild, 8,733 graphs (cold start) | 6.5 min; 279,472 fsync + 122,282 msync; 16.6 GB written; 1.84 GB store | about 3 s; 1 write transaction; 16 MB store |
| Second rebuild of identical content | store +1.53 GB, 14.6 GB written | no growth; nothing committed |
| Production ontology store | 79.7 GB, never compacted | compacts ~5 min after the next deploy, then weekly |
| Page save, request-thread syncs | 168–180 fsync/msync; 180–277 ms | 2–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 s | keeps 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/pages | 450–455 file opens per request | 0 |
/feed.xml | 307 file opens per request | 0 |
| Search, 2-minute load | ~80,000 index-file opens, 7,032 directory listings | index reader shared and reused |
/api/pages/for-agent/{id} (anonymous) | 182 statements | 5 |
/api/search (cold cache) | 204 statements | 4 |
/api/bundle | 28 statements | 2 |
/admin/agent-grade-audit?limit=100 | 2,377 statements | 5 |
/admin/verification?limit=200 | 413 statements | 5 |
/admin/overview | 245 statements | 19 |
/admin/knowledge-graph/proposals?limit=50 | 117 statements | 9 |
| Startup | all vectors loaded twice; 2,326 unchanged rows rewritten | loaded once; no canonical-id rows rewritten |
pg_stat_statements and every statement logged with its parameters. The wiki ran against a copy of the pages and an empty work directory, so a cold start rebuilt every derived store.strace -f recorded every file-system syscall with its path and duration: open, stat, directory listing, fsync, msync, truncate, rename and unlink./proc/<pid>/io gave real device writes per phase.pg_stat_activity every 0.5 s to catch long and idle-in-transaction sessions.The raw measurements are in loadtest/results/CAMPAIGN-2026-10-22-db-disk.md in the repository.
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.
/sparql, /id and /export surfaces saw an empty A-Box between the clear and the refill.Repair.
OntologyModelManager.replaceAbox). Graphs stream into it, readers keep the old A-Box until the commit, and a graph already identical in the store is not rewritten, so rebuilding unchanged content commits nothing.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.
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.
/admin/content/reindex-embeddings remains the explicit full re-embed.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.
Problem.
refmgr.ser were overwritten in place (O_TRUNC, no temp file, no fsync), so a crash mid-write left a truncated file.PrintWriter, which never throws on a write error, so a disk-full truncation was not even logged.FileSystemProvider then swallowed the IOException and the save reported success.Repair.
AtomicFileWriter (wikantik-util) writes a hidden temp file in the same directory, fsyncs it, moves it over the target with ATOMIC_MOVE, and fsyncs the directory.refmgr.ser and the reference-manager attribute cache all go through it.ProviderException.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.
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.
Problem.
CachingProvider.getAllPages() built the page list by iterating the per-page cache. Entries in that cache expire 60 s after they are written, but the listing is only refreshed every wikantik.cache.allPagesTTL (300 s by default). So between 60 s and 300 s after a refresh, getAllPages() silently left pages out, and a renamed page vanished from the list until the next refresh. Production masked this by setting the TTL to 60 s.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.
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.
Problem.
Directory and DirectoryReader: about 80,000 index-file opens and 7,032 directory listings in a 2-minute load.IndexWriter for the delete and another for the add. A full reindex therefore made 1,213 commits and 47,799 fsyncs and took 64 s, where startup builds the same index with one commit in 1.8 s.Repair.
LuceneIndexStore holds one long-lived IndexWriter and a reference-counted ReaderManager.updateDocument, which deletes and adds atomically.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.
| Path | Cause | Repair |
|---|---|---|
/api/pages/for-agent, /admin/agent-grade-audit, /admin/verification, /admin/overview | verificationOf 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_context | the "batched" related-pages lookup ran one query per page | One windowed statement over page_name = ANY( ? ) |
/api/bundle, /api/briefing, assemble_bundle, get_briefing | one page_canonical_ids query per cited section | Resolved 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_proposals | node-by-name and rejection lookups per proposal | Two batched lookups per page of proposals (KgBatchLookups) |
| Page knowledge slice | per-entity node and edge queries | Batched with = ANY( ? ) |
/admin/drift/trend | one count query per sweep | One query for all sweeps |
users.last_login. Concurrent requests by one account queued on that row's lock: mean 2.7 ms against 0.05 ms uncontended. The update is now throttled to once per 5 minutes per login, and the cached profile is updated atomically rather than evicted. The login.ok audit entry per authentication is kept on purpose.ORDER BY seq DESC LIMIT 1 across all 31 monthly partitions, 26 of them empty future months, and checked partition existence every time. The head lookup now prunes older months, and partition checks run once per month. It deliberately has no upper time bound, so a clock that steps back across a month boundary cannot fork the hash chain; a test covers that case. A failed audit batch is retried with backoff before being dropped.AuditEventListener audited only a save event that production never fires. The local audit log held 118 page.delete rows and no page.save rows. Saves are now audited once each, and the row names the authenticated user who saved.Jdbc.forEachRow claimed to stream but ran in auto-commit mode, where PgJDBC ignores the fetch size and buffers the whole result. Startup loaded about 76 MB of vectors this way, twice. It now wraps the scan in a read transaction when a fetch size is given.NodeMentionSimilarity reloaded every mention vector on each call. It is reachable from the public /api/pages/{p}/similar. Centroids are now cached for 60 s and computed with running sums.page_canonical_ids and page_verification rows. Unchanged rows are now left alone, and updated_at again means "last real change".connectTimeout=10, socketTimeout=300, tcpKeepAlive=true). The dev template no longer runs SELECT 1 on every borrow; there were 63,541 of those in a 2-minute local load.users(uid) and retrieval_runs(mode, started_at), and a partial index for the KG judge claim. That claim used to seq-scan 15,002 proposals to find none.The first report got three things wrong, and the repair work caught them:
drift_snapshot_counts(sweep_id) index was needed. The primary key already leads with that column, and the sequential scan was just the planner's choice for a 220-row table. The index was dropped from V062 before it shipped anywhere.access() call per request. Tomcat's resource cache serves the bytes.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.
An independent review of the 19-commit change set found problems in the new code, all fixed with tests before release:
page.save audit rows had no actor.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.
retrieval_query_log, briefing_log, search_visibility_snapshot, drift_* and audit_log only grow. A retention policy is a product decision.similarTo can be up to 60 s stale. No Knowledge Graph write path calls NodeMentionSimilarity.invalidate() yet./admin/content/reindex-embeddings.HubProposalService lookup loops, non-transactional multi-statement Knowledge Graph writes, read-then-insert upserts, and connector credential writes that swallow errors.Data-0002 and Data-0001 is deleted. Expect a few minutes of disk activity. A reconciliation rebuild follows 10 minutes after startup, and commits nothing if the store is current.page.save audit rows will start appearing, with the saving user's login.socketTimeout=300 ends any query that waits more than 5 minutes for the server.The method is reproducible:
strace with the JFR settings.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)