The re-embed's index DDL runs outside the transaction, not inside it - #117
Merged
Merged
Conversation
#116 put the index drop and remake inside reembedAll, which this class makes transactional: there is a class-level @transactional, so the whole method ran in one transaction. Neo4j will not take schema work and data work together. The DDL runs on its own session and waits on the schema locks the open data transaction holds, while that transaction waits to commit. It does not fail. It HANGS. Observed on a live appliance changing model from text-embedding-3-small to text-embedding-3-large: the request never returned, and Neo4j showed a transaction Running for ten minutes with no current query. The graph was left with the proposition index dropped and the vectors not yet rewritten — the worst of both, and exactly the inconsistency #116 set out to prevent. The DDL now sits either side of the transaction, and only the rewrite is transactional. NOT_SUPPORTED rather than simply dropping the annotation: the class default would otherwise still apply, and NOT_SUPPORTED additionally suspends a transaction a CALLER opened, which is the same hazard arriving from outside. save() already overrides the class default for its own reasons. WHY THE EXISTING TEST PASSED, which is the part worth keeping. It constructs the repository DIRECTLY, following the precedent of the other tests here — so no Spring proxy, no transaction, and the DDL and the writes landed in separate transactions. It exercised the class, not the bean, and the bean is what production calls. The new test uses the autowired bean and forces the shape mismatch on the index rather than needing a second model. A deadlock does not fail, it hangs, so it is wrapped in assertTimeoutPreemptively — which turns a stuck build into a failure. Verified both ways: with the annotation put back, it times out after 60s.
jasperblues
added a commit
to embabel/embabel-chat-store
that referenced
this pull request
Sep 22, 2026
MessageData has recorded `embeddingModel` per row from the start, "so that
consumers can detect when stored vectors were produced by a different model from
the one currently configured — important if the embedding model is later changed
and old vectors need to be re-embedded". Nothing could act on it: there was no
re-embed to call.
Measured on a live appliance after moving from a 1536-wide model to a 3072-wide
one:
["Chunk", "ContentElement"] 1 node 3072 rewritten
["Proposition"] 3 nodes 3072 rewritten
["StoredMessage"] 2 nodes 1536 left behind
The reindex reported success. The messages stayed searchable and stayed wrong:
a 1536 vector under a 1536 index is internally consistent, so nothing errors —
the rows were simply made by a model the queries no longer use.
`reembedMessages` rewrites what the current model did not make, in pages, and
leaves the index describing what the rows now hold.
THE INDEX SPEC IS SUPPLIED rather than built here. It is described by
ChatStoreProperties in the autoconfiguration module, which this one cannot see,
so the caller that owns the spec for the startup catalog passes the same one —
the index made at boot and the index remade around a re-embed cannot then
describe different things.
NOT TRANSACTIONAL, and the DDL sits either side of the rewrite rather than
inside it. Neo4j will not take schema work and data work in one transaction:
doing exactly this inside a transactional method deadlocked dice's equivalent,
and the request never returned (embabel/dice#117). Drop before rewriting, for
the same reason DrivineStore does: an index left standing through the run spends
it describing a width none of the rewritten vectors have.
Rows already made by the current model are filtered in Cypher, so a second run
costs a scan and no embedding calls, and an interrupted run resumes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#116 put the index drop and remake inside
reembedAll. This class carries a class-level@Transactional, so the whole method ran in one transaction — and Neo4j will not take schema work and data work together. The DDL runs on its own session and waits on the schema locks the open data transaction holds, while that transaction waits to commit.It does not fail. It hangs.
Observed changing
text-embedding-3-small→text-embedding-3-largeon a real appliance:…then ten minutes of silence, the HTTP request still open, and Neo4j reporting:
The graph was left with the proposition index dropped and the vectors not rewritten — the worst of both, and precisely the inconsistency #116 set out to prevent. Chunks had already moved to 3072, because
DrivineStore.reembedAllis not transactional.Fix
The DDL sits either side of the transaction; only the rewrite is transactional:
NOT_SUPPORTEDrather than simply removing the annotation: the class default would otherwise still apply, and it additionally suspends a transaction a caller opened — the same hazard arriving from outside.save()already overrides the class default for its own reasons and is the precedent.Why the existing test passed — the part worth keeping
It constructs the repository directly, following the precedent of the other tests in this file:
No Spring proxy, so no transaction, so the DDL and the writes landed in separate transactions. It passed on a path production never takes. It exercised the class; the bean is what production calls.
The new test
Uses the autowired bean, and forces the shape mismatch on the index rather than needing a second model. A deadlock doesn't fail, it hangs — so it is wrapped in
assertTimeoutPreemptively, which turns a stuck build into a failure.Verified both ways: with
@Transactionalput back, it fails withexecution timed out after 60000 ms. With the fix, both re-embed tests pass.