JuliusBrussee/caveman · warning
prefix replacement: entry unreadable after write
Error message
prefix replacement: entry unreadable after write
What it means
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.
Source
Thrown at proxy/internal/store/prefix_cache.go:59
// returned and the caller forwards it, so two requests can never put two different
// prefixes on the wire for one logical message.
func (s *Store) RememberReplacement(scope string, original, replacement []byte, handle string) ([]byte, error) {
if scope == "" || len(original) == 0 || len(replacement) == 0 || handle == "" {
return nil, errors.New("prefix replacement: incomplete entry")
}
key := prefixCacheKey(scope, original)
now := prefixCacheNow()
if _, err := s.db.Exec(
`INSERT INTO prefix_replacements (original_sha256, handle, replacement, created_at, last_used_at)
VALUES (?,?,?,?,?)
ON CONFLICT(original_sha256) DO UPDATE SET last_used_at=excluded.last_used_at`,
key, handle, replacement, now, now,
); err != nil {
return nil, fmt.Errorf("prefix replacement put: %w", err)
}
stored, _, ok := s.readReplacement(key)
if !ok {
return nil, errors.New("prefix replacement: entry unreadable after write")
}
s.evictPrefixReplacements()
return stored, nil
}
func (s *Store) readReplacement(key string) ([]byte, string, bool) {
var handle string
var replacement []byte
row := s.db.QueryRow(`SELECT handle, replacement FROM prefix_replacements WHERE original_sha256 = ?`, key)
switch err := row.Scan(&handle, &replacement); {
case errors.Is(err, sql.ErrNoRows):
return nil, "", false
case err != nil:
// NOT a miss: the entry may well exist. The caller still has to fail safe and
// forward the original bytes (there is nothing else it can send), so the real
// guarantee comes from the store's WAL + busy_timeout DSN — log this distinctly
// so contention that would flip an upstream prefix is visible, not silent.
if s.logger != nil {View on GitHub (pinned to 766dce6b13)
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.
Example fix
// before stored, err := s.store.RememberReplacement(scope, original, replacement, handle) // keeps failing: pre-existing row has empty handle/replacement columns // after (maintenance) s.db.Exec(`DELETE FROM prefix_replacements WHERE original_sha256 = ?`, key) // drop corrupt row stored, err := s.store.RememberReplacement(scope, original, replacement, handle) // next write sticks
Defensive patterns
Strategy: retry
Try / catch
// Gateway seam: fail open on this error, but retry once after clearing the row —
// first-write-wins refuses to overwrite a corrupt row, so deleting it lets the retry stick.
stored, err := store.RememberReplacement(scope, original, replacement, handle)
if err != nil && errors.Is(err, errUnreadableAfterWrite) {
store.ForgetReplacement(scope, original) // DELETE FROM prefix_replacements WHERE original_sha256 = key
stored, err = store.RememberReplacement(scope, original, replacement, handle)
}
if err != nil {
// Never block traffic: forward the original bytes (byte-safe fail-open).
return original, nil
} Prevention
- 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.
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- ${where} causal slice omits its own manifestation node
- assembly slot {slot_id!r} changed after being declared stabl
- prefix replacement: incomplete entry
AI-assisted analysis of JuliusBrussee/caveman@766dce6b13 (2026-08-18).
Data as JSON: /api/errors/236df582513a9d6e.
Report an issue: GitHub.