Skip to content

[OPENJPA-2951] Make the context flush after an in-memory bulk operation escapable - #216

Merged
solomax merged 2 commits into
masterfrom
OPENJPA-2951
Oct 19, 2026
Merged

solomax merged 2 commits into
masterfrom
OPENJPA-2951

Conversation

@rzo1

@rzo1 rzo1 commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

A bulk UPDATE/DELETE that cannot be expressed as one statement is executed in memory, so the result is a modified persistence context rather than a modified database. The flush added in 4.2.0 is what carries the row count executeUpdate() reports to the database — measured on Derby: without it only the candidate SELECT is emitted, and an em.clear() discards the operation after the count was already reported. Section 4.10 mandates neither behaviour ("the persistence context is not synchronized with the result of the bulk update or delete"), so the flush stays the default and becomes escapable through the new FlushAfterInMemoryBulkOperation compatibility option. Suppressing it does not suppress the in-memory application: the instances are still modified and written at commit.

Review turned up something worse than the ticket. Broker.flush() rejects an incremental flush when the store does not support OPTION_INC_FLUSH — which AbstractStoreManager does not by default, so openjpa-xmlstore failed every bulk delete — and when a savepoint is set that the SavepointManager cannot flush incrementally. Both rejections marked the transaction rollback-only, so where 4.1.x applied the operation, 4.2.0 lost the transaction. The operation now checks first and skips the flush with an INFO log.

Two things worth your attention:

  1. It adds kernel API. Broker.isFlushSupported(), a default method returning true, overridden in BrokerImpl and DelegatingBroker. A third-party Broker keeps the default and would still hit the exception. I would rather have avoided new API, but discriminating the rejection after the fact is not possible — DelegatingBroker.flush() wraps the UnsupportedException into a generic PersistenceException, so catching it does not work.
  2. A targeted flush is not feasible today. BrokerImpl.flush(int) is protected and always operates on the whole transactional cache, firing the flush events over it and clearing the flush flags, and the Broker interface exposes only flush()/preFlush(). Flushing the affected instances alone needs more kernel API; left as a follow-up rather than bodged, so unrelated pending changes still ride along — which the migration note now says.

35 tests in the bulk package pass on both the Derby and H2 profiles; 12 are new, and 6 of those fail with the kernel part reverted (the other 6 pin behaviour 4.2.0 already introduced). The documented savepoint fallback covers the flush after the operation only — the pre-flush before the candidate query is not guarded against a savepoint, so that case still fails as before, and both the guide and the javadoc now say so.

@rzo1 rzo1 self-assigned this Oct 13, 2026
@rzo1
rzo1 removed the request for review from rmannibucau October 13, 2026 12:16
@cristof

cristof commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

@rzo1 , can you check, please, if the expected order is really relevant for the test result?

…on escapable

A bulk UPDATE or DELETE that cannot be expressed as a single statement is
executed in memory: OpenJPA loads the matching instances and applies the
operation to them, so the result is a modified persistence context, not a
modified database. The flush added for OPENJPA-2940 therefore carries the
row count that executeUpdate() reports to the database - without it an
em.clear() before the commit discards the operation while the count has
already been reported, and a find() after that clear still sees the old
row. Measured on Derby with SQL logging: with the flush an in-memory
delete emits the owned-table and entity DELETE statements during
executeUpdate() and a find() after em.clear() returns null;
without it only the candidate SELECT is emitted and find() returns the
instance again.

The flush must not turn a legal bulk operation into a failure, though.
Broker.flush() rejects an incremental flush when the runtime does not
support OPTION_INC_FLUSH - which AbstractStoreManager.getUnsupportedOptions()
does by default, so openjpa-xmlstore raised "This data store does not
support incremental flushing" for every bulk delete - and when a savepoint
is set that the SavepointManager cannot flush incrementally, which the
default in-mem manager cannot. Both rejections happen before anything is
written, but the persistence layer marks the transaction rollback-only on
the way out, so the whole transaction was lost where 4.1.x simply applied
the operation. The new Broker.isFlushSupported() reports both conditions,
and the bulk operation now skips the flush and logs it at INFO instead of
flushing blindly. The pre-flush of the candidate query has always been
guarded by OPTION_INC_FLUSH in the same way.

The second cost is that the kernel has no way to flush single instances.
BrokerImpl.flush(int) is protected, always operates on
getTransactionalStates(), fires the flush events with that whole
collection, calls afterFlush() on every state and clears
FLAG_FLUSH_REQUIRED, and the Broker interface exposes only flush() and
preFlush(); flushing the affected instances alone would need more kernel
API, which is left as a follow-up. So unrelated pending changes are
written as well. Under the default FlushModeType.AUTO that is usually not
a change of its own, because the query that loads the candidates flushes
the context first - but that pre-flush of unrelated changes is itself new
in 4.2.0 (OPENJPA-2940), and it does not happen at all when the query
never reaches the datastore, i.e. when the candidates were handed over
with setCandidateCollection(): isInMemory() short-circuits the pre-flush
before it looks at the flush mode. The trailing flush is then the only
flush even under AUTO, which the SQL log confirms - the candidate SELECT
precedes the UPDATE of the unrelated instance. The other case is
FlushModeType.COMMIT, or FlushBeforeQueries=false or with-connection
without an open connection, where a dirty instance of a type that is not
on the query's access path used to stay pending. IgnoreChanges is not
involved: QueryImpl line 1032 short-circuits it for every operation other
than OP_SELECT.

The flush also decides when the AFTER_DELETE_PERFORMED and
AFTER_UPDATE_PERFORMED callbacks of the affected instances fire, since
StateManagerImpl.afterFlush() raises them. That is the route most
applications take into this code: JDBCStoreQuery.executeDelete() gives up
on the single statement precisely when the candidate has delete listeners,
"so the callbacks are invoked".

Neither behaviour is mandated. Jakarta Persistence 3.1 section 4.10
states that "the persistence context is not synchronized with the result
of the bulk update or delete" and recommends that "bulk update and delete
operations should only be performed within a transaction in a new
persistence context or before fetching or accessing entities whose state
might be affected by such operations". A reported row count that never
reached the database is the worse of the two, so the flush stays the
default and becomes escapable through the compatibility option
FlushAfterInMemoryBulkOperation. Suppressing the flush must not and does
not suppress the in-memory application of the operation: the instances
are still modified or marked for deletion and are written by the next
flush or at commit.

The new section of the migration considerations documents the flush, the
pending changes and callbacks it moves, the two cases in which it is the
only flush, the option and the fallback when the context cannot be flushed, which
covers the flush after the operation only - the flush before the candidate
query is not guarded against a savepoint, so an operation issued with
pending changes and a savepoint set still fails as it did before. No test in the tree depended on the flush - the bulk and criteria suites
all pass with it removed. Six of the twelve new methods in
openjpa-persistence-jdbc fail with the kernel part of this change reverted;
the other six pin behaviour OPENJPA-2940 already introduced and pass on
master as well. TestInMemoryBulkFlush pins the default,
including the candidate-collection case under AUTO and the savepoint
fallback on a clean context; four of its six methods assert the candidate
SELECT so they cannot pass on a datastore-executed operation, and the other
two assert the presence or absence of a single statement; TestInMemoryBulkFlushCompat pins the option;
TestInMemoryBulkFlushCallbacks pins the callback timing on both paths; and
TestPersistence of openjpa-xmlstore covers the AbstractStoreManager
default, which had no bulk-operation test at all.
@rzo1

rzo1 commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

@cristof Good question, and it was the cause of the red build rather than a coincidence: the four legs started failing the moment #215 (OPENJPA-2950) was merged.

The order matters, but the test had it the wrong way round. It asserted that the unrelated pending UPDATE BULK_ITEM precedes the candidate SELECT, i.e. that the query pre-flushes the whole context. After OPENJPA-2950 a query flushes only its own access path, and BulkDeleteItem is not on it, so the pending change now survives the candidate query and is written by the trailing flush this PR adds. The captured SQL after rebasing onto current master:

SELECT t0.number, t0.tenant, t0.name FROM BULK_CID_OWNER t0 WHERE (t0.name = ?)
DELETE FROM BULK_CID_TAGS WHERE OWNER_TENANT = ? AND OWNER_NUMBER = ?
UPDATE BULK_ITEM SET name = ? WHERE id = ?
DELETE FROM BULK_CID_OWNER WHERE number = ? AND tenant = ?

So the two statements are now pinned in the opposite order, and the test is renamed testUnrelatedPendingChangeIsFlushedByTrailingFlush. What the assertion establishes is that the UPDATE comes after the candidate SELECT and therefore cannot come from a pre-flush — that distinction is the whole point of the test, so it has to stay ordered. What does not matter is where the bulk delete's own DELETEs fall around it; those are ordered inside the flush, and the assertion does not pin them. All four order assertions in the class now have that same shape: candidate SELECT first, then the write.

Rebased on master, braces added as solomax asked, and the bulk and flush packages are 58/0/0 on Derby and on H2.

@solomax
solomax merged commit a2c8177 into master Oct 19, 2026
4 checks passed
@solomax
solomax deleted the OPENJPA-2951 branch October 19, 2026 05:18
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.

3 participants