Index drift recurs on 6.7.0 — 762 phantom entries across four namespaces #28
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Observed on: 6.7.0
What happened
healthreported drift on every namespace on a long-running instance:reindex()cleared all 762.healthafterwards returns"drift": {}with index counts matching record counts exactly on all five indexes. Recall was verified unaffected — same top hit at the same score to 9 d.p. before and after.Why this needs more than the cleanup
reindex()fixed the symptom. Two things suggest the cause is still live:The
healthdocstring already names the likely cause — deletes the search module didn't observe, e.g. keyspace notifications disabled. If that's it, thenreindex()is a recurring chore rather than a repair, and any instance whose operator never callshealthcarries a silently growing set of phantom vectors.Suggested investigation
notify-keyspace-eventson the Valkey side, and confirm what valkey-search actually needs configured to observe hash deletes.healthalready does a record scan, so the same comparison could run on boot and either self-heal or log a warning.health. Nothing currently prompts an operator to look.Open question worth answering first
What is the actual impact of a phantom entry? If an orphaned vector can be returned as a search hit with no backing hash, this is a correctness bug and should be prioritised accordingly. If it only costs memory and a little recall latency, it's housekeeping. Worth settling before deciding how much of the above to build.
Fixed in 6.7.1 (
f9e8f64,b4822f3,32e5f9d,7eaeafconv6.7.x).Answering the open question first, because it changes the priority: a phantom could reach a caller. An index entry whose backing hash is gone comes back from
FT.SEARCHas a document id and a score with no fields, and recall built a result out of it — real key, empty content, a slot intop_k. So this was a correctness bug, not only housekeeping. Recall now skips those and logs them.One subtlety worth recording, because the first version of that guard was wrong. It tested "content is empty", justified by
remember()rejecting empty content. True for episodic, knowledge and preference — but not for project:compile_project_context(auto_save=True)writescontent=""whenever a project has no description yet and keeps its real text incurrent_state. That made a fully populated project context — the one the documented session-start bootstrap creates — permanently unrecallable, with a log line telling the operator to runreindex(), which cannot help. A phantom is now identified by carrying no fields at all beyond the key and score, which needs no per-namespace knowledge.On the cause. The shipped compose files have set
--notify-keyspace-events AKEsince #11 in April, so disabled notifications are not the explanation for drift on a current deployment.health()on the live instance after yourreindex()showed"drift": {}with index counts matching record counts exactly across 28 minutes and new writes, so observation is working in steady state. I could not reproduce fresh drift to attribute it, and I have not claimed a cause the evidence doesn't support.On surfacing. You were right that this is the real problem: drift was only ever a field in
health(), and nothing prompts an operator to callhealth(), which is how 762 orphans accumulated before anyone looked. The comparison moved intostore.index_report()sohealth(),briefing()and a new startup check share one definition, and it is now logged at boot (INDEX_DRIFT_CHECK=falseopts out, costs oneSCANofmem:*) and reported in every briefing.Deliberately not self-healing. Dropping and recreating every index automatically on startup is a much bigger hammer than the problem and
reindex()is one call away. Related: positive and negative drift are now described separately, because they are different faults —reindex()clears orphans and does nothing for an index that is merely behind, which is the expected state moments after_migrate_indexes()recreates one. The startup check no longer warns in that case.