{"record":{"id":"236df582513a9d6e","repo":"JuliusBrussee/caveman","slug":"prefix-replacement-entry-unreadable-after-write","errorCode":null,"errorMessage":"prefix replacement: entry unreadable after write","messagePattern":"prefix replacement: entry unreadable after write","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"proxy/internal/store/prefix_cache.go","lineNumber":59,"sourceCode":"// returned and the caller forwards it, so two requests can never put two different\n// prefixes on the wire for one logical message.\nfunc (s *Store) RememberReplacement(scope string, original, replacement []byte, handle string) ([]byte, error) {\n\tif scope == \"\" || len(original) == 0 || len(replacement) == 0 || handle == \"\" {\n\t\treturn nil, errors.New(\"prefix replacement: incomplete entry\")\n\t}\n\tkey := prefixCacheKey(scope, original)\n\tnow := prefixCacheNow()\n\tif _, err := s.db.Exec(\n\t\t`INSERT INTO prefix_replacements (original_sha256, handle, replacement, created_at, last_used_at)\n\t\t VALUES (?,?,?,?,?)\n\t\t ON CONFLICT(original_sha256) DO UPDATE SET last_used_at=excluded.last_used_at`,\n\t\tkey, handle, replacement, now, now,\n\t); err != nil {\n\t\treturn nil, fmt.Errorf(\"prefix replacement put: %w\", err)\n\t}\n\tstored, _, ok := s.readReplacement(key)\n\tif !ok {\n\t\treturn nil, errors.New(\"prefix replacement: entry unreadable after write\")\n\t}\n\ts.evictPrefixReplacements()\n\treturn stored, nil\n}\n\nfunc (s *Store) readReplacement(key string) ([]byte, string, bool) {\n\tvar handle string\n\tvar replacement []byte\n\trow := s.db.QueryRow(`SELECT handle, replacement FROM prefix_replacements WHERE original_sha256 = ?`, key)\n\tswitch err := row.Scan(&handle, &replacement); {\n\tcase errors.Is(err, sql.ErrNoRows):\n\t\treturn nil, \"\", false\n\tcase err != nil:\n\t\t// NOT a miss: the entry may well exist. The caller still has to fail safe and\n\t\t// forward the original bytes (there is nothing else it can send), so the real\n\t\t// guarantee comes from the store's WAL + busy_timeout DSN — log this distinctly\n\t\t// so contention that would flip an upstream prefix is visible, not silent.\n\t\tif s.logger != nil {","sourceCodeStart":41,"sourceCodeEnd":77,"githubUrl":"https://github.com/JuliusBrussee/caveman/blob/766dce6b1394ebb56a3090748d5a0240a5aefb36/proxy/internal/store/prefix_cache.go#L41-L77","documentation":"RememberReplacement verifies its own write: after the INSERT ... ON CONFLICT succeeds, it reads the row back through readReplacement. This error means the read-back could not return the entry — either the SELECT errored (contention; readReplacement logs 'prefix replacement lookup errored') or the stored row has an empty handle or replacement ('entry incomplete'). Because the upsert never overwrites an existing row's payload (ON CONFLICT only bumps last_used_at), a pre-existing corrupt row under the same key keeps failing every write. The gateway fails open on this error: original bytes are forwarded, at the cost of one prompt-cache rebuild.","triggerScenarios":"A previously corrupted row under the same sha256(scope+original) key (empty handle/replacement columns) that first-write-wins refuses to overwrite; or a transient SQLite read error immediately after the write under concurrent access when the DSN lacks WAL/busy_timeout.","commonSituations":"Rows in ~/.caveman/caveman.db damaged by an external editor, an older buggy writer, or disk corruption; opening the store without journal_mode(WAL) + busy_timeout so concurrent turns contend on the read-back.","solutions":["Check store logs for the companion warnings ('prefix replacement lookup errored' vs 'entry incomplete') to distinguish contention from corruption.","Delete the offending row — DELETE FROM prefix_replacements WHERE original_sha256 = '<key>' — or clear the table (a miss only costs one prompt-cache rebuild), then let the next turn rewrite it.","Verify the SQLite DSN keeps journal_mode(WAL) and busy_timeout so concurrent requests never fail the read-back.","If it recurs, run PRAGMA integrity_check on caveman.db to rule out wider corruption."],"exampleFix":"// before\nstored, err := s.store.RememberReplacement(scope, original, replacement, handle)\n// keeps failing: pre-existing row has empty handle/replacement columns\n\n// after (maintenance)\ns.db.Exec(`DELETE FROM prefix_replacements WHERE original_sha256 = ?`, key) // drop corrupt row\nstored, err := s.store.RememberReplacement(scope, original, replacement, handle) // next write sticks","handlingStrategy":"retry","validationCode":null,"typeGuard":null,"tryCatchPattern":"// Gateway seam: fail open on this error, but retry once after clearing the row —\n// first-write-wins refuses to overwrite a corrupt row, so deleting it lets the retry stick.\nstored, err := store.RememberReplacement(scope, original, replacement, handle)\nif err != nil && errors.Is(err, errUnreadableAfterWrite) {\n\tstore.ForgetReplacement(scope, original) // DELETE FROM prefix_replacements WHERE original_sha256 = key\n\tstored, err = store.RememberReplacement(scope, original, replacement, handle)\n}\nif err != nil {\n\t// Never block traffic: forward the original bytes (byte-safe fail-open).\n\treturn original, nil\n}","preventionTips":["Always open the SQLite store with journal_mode(WAL) and busy_timeout so read-backs never lose to contention.","Never edit prefix_replacements rows by hand; a row with empty handle/replacement columns poisons its key permanently.","Alert on the companion log lines ('lookup errored' / 'entry incomplete') — they are the early signal before this error appears."],"tags":["sqlite","cache","consistency","read-after-write","wal"],"backgroundTag":"read-after-write-inconsistency","analyzedSha":"766dce6b1394ebb56a3090748d5a0240a5aefb36","analyzedAt":"2026-08-18T03:14:35.516Z","contentChangedAt":"2026-08-18T03:14:35.516Z","schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}