oficina / memoria Public

fix: a delete that fails must not leave the passages behind

A code review of the upload branch noticed that Store.Delete had the same
post-commit shape as Ingest and no equivalent guard. It was worse than the
one we had just fixed.

Ingest refreshes the index after its transaction; if that fails, the
document is stored but unfindable — annoying, safe, and it heals at the
next boot. Delete removed the row first and the index entries after, so a
failure left the passages of a deleted document still in the index, still
answering searches. The test written before the fix shows what a reader
got: a result with a blank title, linking to a page that 404s, quoting a
passage of a document that no longer exists.

That asymmetry decided the fix. A document you cannot find is a nuisance;
a findable passage of a deleted document may be exactly the passage
someone deleted because it must not be findable. So the orders now differ
deliberately and AGENTS.md says why: additions to derived state go after
the commit, removals before it. Where the two disagree, the order that
cannot produce a live citation wins. A failed removal now refuses the
whole delete — nothing is removed, the document stays whole, and the page
says so instead of redirecting in silence.

Ordering makes the divergence rare. What makes it harmless is the second
half: Search now drops any hit whose chunk row is gone, so a hit is not a
citation until the database agrees the passage is still there.

That second half found another instance of the same bug, and a worse one.
Ingest also removes the PREVIOUS revision's entries after its commit, on a
re-ingest. A failure there leaves the old revision's passages indexed and
answering searches, quoted under a live title — and because the document
still exists, a document-level check sees nothing wrong. It also made the
sentence added in the last commit false: we told people a document was
"stored but will not be found by a search" while its superseded passages
were still being found.

The document-level half of the new guard was deleted before this landed:
mutating it came back green, because a chunk row cannot outlive its
document row. A guard no test can see fail is the thing this codebase has
been burned by twice, so it is gone rather than kept for comfort. That,
and one behaviour-preserving mutation that stayed green, are both recorded
in the test comments rather than quietly dropped.

Copy for the failed-delete message has NOT been through its review gate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 38b45cac42ecbc506e50f80e11ff433b8ce458bc parent 8a77860
5 files changed, +516 −11
  • AGENTS.md +17 −3
  • internal/memoria/delete_test.go +359 −0
  • internal/memoria/handlers.go +36 −0
  • internal/memoria/lag_test.go +13 −3
  • internal/memoria/store.go +91 −5
diff --git a/AGENTS.md b/AGENTS.md
index de659c3..e265cb3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -63,9 +63,23 @@ so; it never renders as a confident zero.
line, error string or metric label.** `carlos logs` is platform-held
storage we cannot grep. Gate 4 enforces it; it was missing once, and
what it found when written was that we were logging sign-in links.
-- **Derived state is refreshed after the transaction commits, under
- `context.WithoutCancel`.** A client closing the tab must not leave the
- index disagreeing with the database.
+- **An ADDITION to derived state is made after the transaction commits,
+ under `context.WithoutCancel`.** A client closing the tab must not
+ leave the index disagreeing with the database.
+- **A REMOVAL from derived state is made before it**, and `Store.Delete`
+ carries the argument. The two orders fail in opposite directions:
+ indexing early can leave the index holding passages the database does
+ not have, which is a citation to text that is not there; un-indexing
+ early can only leave a stored document unfindable until the next boot,
+ which the app says out loud. Where they disagree, the order that cannot
+ produce a live citation wins. Delete was written the other way and did
+ produce one — found by a code review, 2026-09-04.
+- **A hit is not a citation until the database says the passage is still
+ there.** `Store.Search` drops any hit whose chunk row is gone. Ordering
+ makes the divergence rare; only this makes it harmless, and it is what
+ makes `ErrIndexLag`'s sentence true — without it, "stored but not
+ findable" was said about a document whose previous revision was still
+ answering searches.
**Sealing**
- `internal/memoria/seal` is the only encryption surface. One HKDF purpose
diff --git a/internal/memoria/delete_test.go b/internal/memoria/delete_test.go
new file mode 100644
index 0000000..4ba5349
--- /dev/null
+++ b/internal/memoria/delete_test.go
@@ -0,0 +1,359 @@
+// A deleted document must not survive as a citation.
+//
+// These tests live in package memoria for the same reason lag_test.go
+// does: reaching the state they are about means breaking the in-memory
+// index under a running app, and the only way to do that from outside
+// would be to grow a production method whose only caller is a test.
+package memoria
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "strings"
+ "testing"
+
+ "amadan.net/oficina/memoria/internal/memoria/index"
+)
+
+// storeOneDocument puts a document in and proves it is findable, so every
+// test below starts from a document that is genuinely there.
+func storeOneDocument(t *testing.T, a *app, term string) int64 {
+ t.Helper()
+ ctx := context.Background()
+ id, err := a.store.Ingest(ctx, "upload", "regional-notes.txt", "regional notes",
+ "The "+term+" terms for the departing regional lead were agreed in March.")
+ if err != nil {
+ t.Fatalf("Ingest: %v", err)
+ }
+ hits, err := a.store.Search(ctx, term, 10)
+ if err != nil {
+ t.Fatalf("Search: %v", err)
+ }
+ if len(hits) != 1 {
+ t.Fatalf("%d hits for the freshly stored document, want 1. Nothing below is about a "+
+ "stale citation if the document was never findable in the first place", len(hits))
+ }
+ return id
+}
+
+// postDelete presses the delete button the way the page does.
+func postDelete(t *testing.T, h http.Handler, id int64) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, "/documents/"+strconv.FormatInt(id, 10)+"/delete", nil)
+ req.Header.Set("Origin", lagOrigin)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+}
+
+// getWithFlash follows a redirect carrying whatever cookie came back, so
+// the sentence a scriptless browser is shown is read off the page it is
+// shown on rather than out of a header.
+func getWithFlash(t *testing.T, h http.Handler, path string, from *httptest.ResponseRecorder) string {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ for _, c := range from.Result().Cookies() {
+ req.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET %s answered %d, want 200", path, rec.Code)
+ }
+ return rec.Body.String()
+}
+
+// A search never cites a document the database does not have.
+//
+// The failure this replaces: Store.Delete removed the rows in a
+// transaction and then removed the index entries, and a failure of that
+// second step was returned as a plain error and swallowed by the handler.
+// The document was gone and its passages stayed in the index, so Search
+// returned a hit for it, the handler resolved the title out of the
+// database and found nothing, and the reader got an empty-titled result
+// linking to a page that 404s — a citation to text that is not there,
+// which is the failure §4.1 exists to prevent.
+//
+// The divergence is produced by running the real Delete with the store's
+// index swapped for a scratch one, so the removal does not reach the
+// index Search reads. That is exactly the state a failed RemoveDocument
+// leaves — rows gone, entries still there — and it is reached this way
+// rather than by deleting rows in the test because Delete is the code
+// that produces it. There is no way to make the index's DELETE fail while
+// its SELECT still works: closing it breaks both.
+//
+// This test stays honest after the ordering fix in Delete, and that is
+// the point of it. Removing the index entries first stops Delete creating
+// this state; it does not stop it existing — an ingest racing a delete on
+// the same document can still re-index it between the two steps, and a
+// partial rebuild can leave it at boot. Whatever the index says, the
+// database decides whether a passage may be cited.
+//
+// MUTATION-TESTED 2026-09-04, each reverted, each observed red:
+//
+// (a) the `if !livePassage[h.ChunkID] { continue }` guard deleted from
+// Store.Search — the code as it stood before this review → "the
+// search cited 1 document(s) that are not in the database ... its
+// title came back empty ("")", and the page assertion under it
+// (b) `livePassage` filled from the HITS instead of from the chunk rows
+// the database returned, which is how someone shortens this later
+// → the same two failures. The guard is only a guard because it
+// asks the database.
+//
+// A third was tried and came back GREEN, which is why the code no longer
+// has the thing it mutated: an earlier version of the guard also asked
+// whether the DOCUMENT was still in the database, and filling that map
+// from the hits changed nothing. A chunk row cannot outlive its document
+// row — one transaction, and the schema cascades — so the document half
+// could not fail for anything the passage half does not already catch,
+// and a guard nobody can see fail is the kind this file exists to stop.
+func TestASearchNeverCitesADocumentThatIsNotInTheDatabase(t *testing.T) {
+ h, a := appWithAWorkingIndex(t)
+ ctx := context.Background()
+ const term = "severance"
+ id := storeOneDocument(t, a, term)
+
+ // Delete for real, with an index that does not hear about it.
+ scratch, err := index.Open()
+ if err != nil {
+ t.Fatalf("index.Open: %v", err)
+ }
+ t.Cleanup(func() { scratch.Close() })
+ live := a.store.ix
+ a.store.ix = scratch
+ if err := a.store.Delete(ctx, id); err != nil {
+ t.Fatalf("Delete: %v", err)
+ }
+ a.store.ix = live
+
+ // Two vacuity guards, because either failing would make every
+ // assertion below pass for the wrong reason.
+ docs, err := a.store.Summaries(ctx)
+ if err != nil {
+ t.Fatalf("listing documents: %v", err)
+ }
+ if len(docs) != 0 {
+ t.Fatalf("%d documents are still in the database, want 0; the delete did not happen, so "+
+ "this is not a test about a stale index", len(docs))
+ }
+ if n, err := a.store.Count(); err != nil {
+ t.Fatalf("counting passages: %v", err)
+ } else if n == 0 {
+ t.Fatalf("the index holds no passages, so it is not stale and a search finding nothing " +
+ "here would prove nothing at all")
+ }
+
+ hits, err := a.store.Search(ctx, term, 10)
+ if err != nil {
+ t.Fatalf("Search: %v", err)
+ }
+ if len(hits) != 0 {
+ t.Errorf("the search cited %d document(s) that are not in the database: %v. The row is "+
+ "gone, so its title came back empty (%q) and the link goes to a page that 404s — "+
+ "a citation to text that is not there.", len(hits), hits, hits[0].Title)
+ }
+
+ // And the same thing where a reader would see it. A result rendered
+ // from a hit like that is a blank link on the page.
+ body := getWithFlash(t, h, "/?q="+term, httptest.NewRecorder())
+ if strings.Contains(body, `href="/documents/`+strconv.FormatInt(id, 10)+`#p`) {
+ t.Errorf("the results page links to document %d, which is deleted:\n%s", id, body)
+ }
+}
+
+// A search never cites a passage the document no longer contains.
+//
+// This is the same bug in the other function, and it is the one the
+// document check above does not catch. Ingest replaces a document's
+// chunks inside its transaction and drops the old index entries AFTER the
+// commit; when that drop fails it returns ErrIndexLag, and the document
+// is still there, so the stale entries pass every check that asks only
+// whether the document exists. What the reader gets is a snippet of text
+// the author deleted in the last revision, under a live title, linking to
+// an ordinal that now holds something else.
+//
+// It also makes the sentence the app says about ErrIndexLag wrong.
+// "Stored but will not be found by a search until this instance restarts"
+// is said about a document whose previous revision is still answering
+// searches.
+//
+// Built the same way as the test above, and with the same limitation: the
+// old entries are left behind by re-ingesting with the store's index
+// swapped for a scratch one, because there is no way to make the index's
+// DELETE fail while its SELECT still works.
+//
+// MUTATION-TESTED 2026-09-04, each reverted, each observed red:
+//
+// (a) `!livePassage[h.ChunkID]` weakened to the check this guard was
+// first written as — whether the DOCUMENT is still among the rows
+// the database returned — → "the search cited 1 passage(s) that are
+// no longer in the document ... quoted under the document's live
+// title". That is the mutation that matters here: the document IS
+// still there, so a check that looks at documents sees nothing
+// wrong, and the test above passes throughout.
+// (b) `livePassage` filled from the hits rather than from the chunk
+// rows the database returned → the same failure
+// (c) the guard deleted outright → the same failure, plus the deleted-
+// document test above
+func TestASearchNeverCitesAPassageTheDocumentNoLongerHas(t *testing.T) {
+ _, a := appWithAWorkingIndex(t)
+ ctx := context.Background()
+ const gone = "reorganisation"
+ id := storeOneDocument(t, a, gone)
+
+ // Re-ingest under the same key with the word taken out, with an index
+ // that does not hear about the old passages.
+ scratch, err := index.Open()
+ if err != nil {
+ t.Fatalf("index.Open: %v", err)
+ }
+ t.Cleanup(func() { scratch.Close() })
+ live := a.store.ix
+ a.store.ix = scratch
+ again, err := a.store.Ingest(ctx, "upload", "regional-notes.txt", "regional notes",
+ "The terms for the departing regional lead were agreed in March.")
+ if err != nil {
+ t.Fatalf("re-Ingest: %v", err)
+ }
+ a.store.ix = live
+ if again != id {
+ t.Fatalf("the re-ingest made document %d instead of updating %d, so this is two documents "+
+ "and not a revision at all", again, id)
+ }
+
+ // Vacuity: the document is still there, the word is genuinely out of
+ // it, and the index still holds the passage that had it.
+ body := passageText(t, a, id)
+ if strings.Contains(body, gone) {
+ t.Fatalf("the new revision still contains %q, so there is no stale passage here", gone)
+ }
+ if n, err := a.store.Count(); err != nil {
+ t.Fatalf("counting passages: %v", err)
+ } else if n == 0 {
+ t.Fatalf("the index holds no passages, so nothing stale survived and a search finding " +
+ "nothing here would prove nothing")
+ }
+
+ hits, err := a.store.Search(ctx, gone, 10)
+ if err != nil {
+ t.Fatalf("Search: %v", err)
+ }
+ if len(hits) != 0 {
+ t.Errorf("the search cited %d passage(s) that are no longer in the document: %v. The "+
+ "snippet is text the last revision removed, and it is quoted under the document's "+
+ "live title — a citation to text that is not there.", len(hits), hits)
+ }
+}
+
+// passageText joins a document's stored passages, so a test can say what
+// the document now contains rather than what it was given.
+func passageText(t *testing.T, a *app, id int64) string {
+ t.Helper()
+ ps, err := a.store.Passages(context.Background(), id)
+ if err != nil {
+ t.Fatalf("Passages: %v", err)
+ }
+ var b strings.Builder
+ for _, p := range ps {
+ b.WriteString(p.Text)
+ b.WriteString("\n")
+ }
+ return b.String()
+}
+
+// A delete the index refused is not a delete.
+//
+// Delete removes the index entries BEFORE the transaction, so a failure
+// there leaves the document whole and findable rather than deleted and
+// findable. The old order left the second of those: the row gone, the
+// passages still in the index, and — since deleteDocument swallowed the
+// error and redirected — nobody told. The passage someone deleted may be
+// exactly the passage that must not be findable, so the delete that
+// cannot be completed is refused outright.
+//
+// MUTATION-TESTED 2026-09-04, each reverted, each observed red:
+//
+// (a) Store.Delete's index removal moved back after the transaction —
+// the code as it stood before this review → "the document was
+// deleted from the database even though the index still holds its
+// passages"
+// (b) `flash.Set` deleted from deleteDocument's error branch → "the
+// page says nothing about the delete that did not happen"
+//
+// One more was tried and came back GREEN, recorded because a mutation
+// nobody can see fail is worth knowing about: deleting the early `return`
+// from that branch so it falls through to the redirect below. It is
+// behaviour-preserving — the flash is already set and one redirect still
+// goes out — so it is a style edit, not a defect, and no test should be
+// contorted to catch it.
+func TestADeleteTheIndexRefusesLeavesTheDocumentAlone(t *testing.T) {
+ h, a := appWithAWorkingIndex(t)
+ ctx := context.Background()
+ id := storeOneDocument(t, a, "redundancy")
+
+ // From here the index refuses everything, removals included.
+ if err := a.store.ix.Close(); err != nil {
+ t.Fatalf("closing the index: %v", err)
+ }
+
+ rec := postDelete(t, h, id)
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("delete answered %d, want 303:\n%s", rec.Code, rec.Body.String())
+ }
+
+ docs, err := a.store.Summaries(ctx)
+ if err != nil {
+ t.Fatalf("listing documents: %v", err)
+ }
+ if len(docs) != 1 {
+ t.Errorf("%d documents are in the database, want 1: the document was deleted from the "+
+ "database even though the index still holds its passages, which leaves a passage "+
+ "that can be found and cited for a document that no longer exists.", len(docs))
+ }
+
+ c := newDropZone()
+ body := getWithFlash(t, h, "/", rec)
+ if !strings.Contains(body, c.DeleteFailed) {
+ t.Errorf("the page says nothing about the delete that did not happen:\n want %q in the "+
+ "page after the redirect", c.DeleteFailed)
+ }
+}
+
+// The ordinary delete still deletes, said here because the test above
+// only proves the refusal. A guard that made every delete fail would pass
+// it.
+//
+// MUTATION-TESTED 2026-09-04, reverted: the transaction removed from
+// Store.Delete, leaving only the index removal → "1 documents are in the
+// database after a delete that reported no error".
+func TestAnOrdinaryDeleteStillDeletes(t *testing.T) {
+ _, a := appWithAWorkingIndex(t)
+ ctx := context.Background()
+ const term = "restructure"
+ id := storeOneDocument(t, a, term)
+
+ if err := a.store.Delete(ctx, id); err != nil {
+ t.Fatalf("Delete: %v", err)
+ }
+ docs, err := a.store.Summaries(ctx)
+ if err != nil {
+ t.Fatalf("listing documents: %v", err)
+ }
+ if len(docs) != 0 {
+ t.Errorf("%d documents are in the database after a delete that reported no error", len(docs))
+ }
+ if n, err := a.store.Count(); err != nil {
+ t.Fatalf("counting passages: %v", err)
+ } else if n != 0 {
+ t.Errorf("%d passages are still indexed after a delete that reported no error", n)
+ }
+ hits, err := a.store.Search(ctx, term, 10)
+ if err != nil {
+ t.Fatalf("Search: %v", err)
+ }
+ if len(hits) != 0 {
+ t.Errorf("a deleted document is still searchable: %v", hits)
+ }
+}
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go
index 21012f9..321a16c 100644
--- a/internal/memoria/handlers.go
+++ b/internal/memoria/handlers.go
@@ -260,6 +260,27 @@ type dropZone struct {
// Form-level refusals, reached through /?err= codes.
NothingSubmitted string
Wrong string
+
+ // ---- the delete button ---------------------------------------------
+ //
+ // Not the upload form's copy, but here for the reason the rest is
+ // here: one place, reviewed once, and incapable of drifting from the
+ // sentence beside it.
+ //
+ // DeleteFailed is the only thing a delete ever says. A delete that
+ // worked says nothing — the row is gone from the list below and the
+ // list is the answer — and a delete that did not says this, because
+ // the alternative is what this branch was opened to fix: the error
+ // swallowed, the redirect issued, and a person who asked for a
+ // passage to stop being findable left believing it had.
+ //
+ // It says "still stored" and stops there. Delete removes the index
+ // entries before the transaction, so a failure can leave the document
+ // stored and momentarily unfindable as well as stored and findable;
+ // the half that is true either way is the half that is said, and the
+ // document's presence in the list under this sentence is the rest of
+ // the answer.
+ DeleteFailed string
}
// newDropZone builds that.
@@ -325,6 +346,8 @@ func newDropZone() dropZone {
NothingSubmitted: "There was nothing to store — the box was empty and no file was chosen.",
Wrong: "That did not work.",
+
+ DeleteFailed: "That document was not deleted — it is still stored.",
}
}
@@ -920,8 +943,21 @@ func (a *app) deleteDocument(w http.ResponseWriter, r *http.Request) {
return
}
if err := a.store.Delete(r.Context(), id); err != nil {
+ // The delete did not happen, and this used to say nothing about
+ // that: the error was logged and the redirect issued exactly as
+ // if it had. Someone who asked for a passage to stop being
+ // findable was left believing it had stopped.
+ //
+ // No document id in the sentence and no content anywhere near
+ // the log line — the id is a coordinate, not a passage, and it
+ // is the only thing logged.
a.logger.Error("delete document", "id", id, "err", err)
+ flash.Set(w, "danger", newDropZone().DeleteFailed)
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+ return
}
+ // A delete that worked says nothing. The row is gone from the list
+ // on the page this redirects to, and the list is the answer.
http.Redirect(w, r, "/", http.StatusSeeOther)
}
diff --git a/internal/memoria/lag_test.go b/internal/memoria/lag_test.go
index aab2673..d5662d6 100644
--- a/internal/memoria/lag_test.go
+++ b/internal/memoria/lag_test.go
@@ -35,6 +35,19 @@ const lagOrigin = "http://app.test"
// index is touched, deliberately (see Ingest), so a broken index cannot
// roll a document back.
func appWithABrokenIndex(t *testing.T) (http.Handler, *app) {
+ t.Helper()
+ mux, a := appWithAWorkingIndex(t)
+ if err := a.store.ix.Close(); err != nil {
+ t.Fatalf("closing the index: %v", err)
+ }
+ return mux, a
+}
+
+// appWithAWorkingIndex is the same app with its index intact. Delete's
+// tests need one: a document has to be stored and indexed before there is
+// anything to break, and closing the index at construction (which is all
+// appWithABrokenIndex adds) leaves nothing to delete.
+func appWithAWorkingIndex(t *testing.T) (http.Handler, *app) {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
@@ -54,9 +67,6 @@ func appWithABrokenIndex(t *testing.T) (http.Handler, *app) {
if err != nil {
t.Fatalf("AppForTest: %v", err)
}
- if err := a.store.ix.Close(); err != nil {
- t.Fatalf("closing the index: %v", err)
- }
return mux, a
}
diff --git a/internal/memoria/store.go b/internal/memoria/store.go
index 758d775..e0a05f0 100644
--- a/internal/memoria/store.go
+++ b/internal/memoria/store.go
@@ -316,8 +316,66 @@ func (s *Store) Search(ctx context.Context, query string, limit int) ([]Result,
titles[d.ID] = string(t)
sources[d.ID] = d.Source
}
+ // livePassage is the database's answer to "is there still such a
+ // passage", and the database is the only thing allowed to answer it.
+ // See the loop below for why the index is not.
+ chunkIDs := make([]int64, 0, len(hits))
+ for _, h := range hits {
+ chunkIDs = append(chunkIDs, h.ChunkID)
+ }
+ var present []int64
+ if err := s.db.G.WithContext(ctx).Model(&Chunk{}).Where("id IN ?", chunkIDs).Pluck("id", &present).Error; err != nil {
+ return nil, err
+ }
+ livePassage := map[int64]bool{}
+ for _, id := range present {
+ livePassage[id] = true
+ }
out := make([]Result, 0, len(hits))
for _, h := range hits {
+ // A hit whose chunk row is no longer in the database is DROPPED,
+ // never rendered. The index proposes a passage; the database
+ // decides whether it may be spoken.
+ //
+ // The index is derived state and it can outlive the rows it
+ // describes. Both ways of getting there have happened in this
+ // file, and both end as a citation to text that is not there —
+ // the failure §4.1 exists to prevent:
+ //
+ // - Delete removed the rows and then the entries. A failure in
+ // between left the passages of a deleted document indexed,
+ // and the reader got a result with an empty title linking to
+ // a page that 404s. Delete now removes the entries first, so
+ // it cannot produce that state; an ingest racing a delete on
+ // the same document still can.
+ // - Ingest replaces a document's chunks in its transaction and
+ // drops the old entries AFTER the commit. A failure there
+ // leaves the PREVIOUS revision's passages indexed under a
+ // document that still exists — a snippet of text the author
+ // has since removed, quoted under a live title.
+ //
+ // It is the CHUNK that is checked, not the document, and the
+ // second case is why: there the document is still there and only
+ // the passage is gone. The document check that was written first
+ // caught nothing the chunk check does not — a chunk row cannot
+ // outlive its document row, they go in one transaction and the
+ // schema cascades — so it was removed rather than left as a guard
+ // no test can see fail.
+ //
+ // It also makes ErrIndexLag's sentence true. "Stored but not
+ // findable until this instance restarts" was a claim about a
+ // document whose previous revision could still be answering
+ // searches; with the stale passages dropped, the document really
+ // is unfindable and the sentence really is the whole of what is
+ // wrong with it.
+ //
+ // Dropped rather than reported: the passage is genuinely gone, so
+ // its absence from the results is the true answer and not a
+ // truncation of one. Nothing is logged, because the only thing
+ // worth logging here would sit beside the query.
+ if !livePassage[h.ChunkID] {
+ continue
+ }
out = append(out, Result{
DocumentID: h.DocumentID,
ChunkID: h.ChunkID,
@@ -422,16 +480,44 @@ func (s *Store) Meta(ctx context.Context, id int64) (source string, revision, by
}
// Delete removes a document, its chunks, and its index entries.
+//
+// The index entries go FIRST, and that ordering is the whole of this
+// function. It is the opposite of Ingest's, deliberately, because the two
+// have opposite failure states and only one of them is survivable.
+//
+// AGENTS.md's rule — derived state is refreshed after the commit — is a
+// rule about ADDING to the index, and its reason says so: a rolled-back
+// transaction that had already mutated the index would leave the index
+// claiming passages the database does not have. Removing entries cannot
+// produce that. It can only make the index claim fewer passages than the
+// database holds, which is the direction this codebase can survive: a
+// document that is stored and not findable until the next boot, which is
+// exactly what Ingest's ErrIndexLag already says out loud.
+//
+// Run the other way round — the way this did, and the way the rule read
+// literally would have it — a failed removal leaves the rows gone and the
+// passages indexed. Search then answers with a passage from a document
+// that no longer exists, and the passage someone deleted may be precisely
+// the passage that had to stop being findable. So a delete whose index
+// removal fails is not reported, it is REFUSED: nothing is deleted, the
+// document stays whole and findable, the person is told (see
+// deleteDocument), and they can try again. An annoyance beats a citation
+// to text that is not there.
+//
+// The residual case is a delete that removes the entries and then fails
+// to commit: a stored document, unfindable until the next boot, and an
+// error the person is shown. That is the survivable direction, and it is
+// self-healing — the index is rebuilt from the sealed rows at every boot.
func (s *Store) Delete(ctx context.Context, id int64) error {
- if err := s.db.G.WithContext(ctx).Transaction(func(tx *gormTx) error {
+ if err := s.ix.RemoveDocument(id); err != nil {
+ return err
+ }
+ return s.db.G.WithContext(ctx).Transaction(func(tx *gormTx) error {
if err := tx.Where("document_id = ?", id).Delete(&Chunk{}).Error; err != nil {
return err
}
return tx.Where("id = ?", id).Delete(&Document{}).Error
- }); err != nil {
- return err
- }
- return s.ix.RemoveDocument(id)
+ })
}
// IndexFile reports the file backing the index, empty when in memory.