Skip to content

The re-embed's index DDL runs outside the transaction, not inside it - #117

Merged
jasperblues merged 1 commit into
mainfrom
fix/reembed-ddl-outside-the-transaction
Sep 22, 2026
Merged

jasperblues merged 1 commit into
mainfrom
fix/reembed-ddl-outside-the-transaction

Conversation

@jasperblues

Copy link
Copy Markdown
Contributor

Fixes a deadlock introduced by #116. Found by changing the embedding model on a live appliance, not by reading code.

#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-large on a real appliance:

Reindex: text-embedding-3-small (dim=1536) -> text-embedding-3-large (dim=3072)
Index drift on Proposition[embedding]: existing dimensions=1536 ... requested dimensions=3072

…then ten minutes of silence, the HTTP request still open, and Neo4j reporting:

neo4j-transaction-20296   status=Running   elapsed=PT10M27S   currentQuery=NULL

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.reembedAll is not transactional.

Fix

The DDL sits either side of the transaction; only the rewrite is transactional:

@Transactional(propagation = Propagation.NOT_SUPPORTED)
override fun reembedAll(): PropositionReembedReport {
    val shapeChanged = persistenceManager.indexes.ensure(spec) is EnsureResult.Drift
    if (shapeChanged) persistenceManager.indexes.drop(spec)
    val rewritten = rewriteEveryEmbedding()          // @Transactional
    if (shapeChanged) persistenceManager.indexes.ensure(spec)
    …
}

NOT_SUPPORTED rather 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:

DrivinePropositionRepository(graphObjectManager, persistenceManager, FakeEmbeddingService(width), transactionManager)

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 @Transactional put back, it fails with execution timed out after 60000 ms. With the fix, both re-embed tests pass.

#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
jasperblues merged commit d3a3f6f into main Sep 22, 2026
5 checks passed
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant