The identity map and the operation boundary#
Doctrine keeps loaded entities in the identity map (UnitOfWork). CMS does not empty it
on reads but at the boundaries of an operation. Knowing that tells you when an entity is
still live and when it is only a detached object; not knowing it means a save that fails
somewhere other than where the mistake was made.
What an operation is#
An operation is one meaningful action on the data: one Api call (internalAction<Action>()
of an Api presenter), one Cron action or one step of a command-line script. It has a start and
an end, and in between it may read and write as it needs.
| Entry | Who opens the operation | source in the log |
|---|---|---|
| Api over HTTP | BasePresenter::operation() in the body of internalAction<Action>() |
http |
| Api over the internal bridge (Front and Admin) | the same code | bridge |
| a Cron action | Cron\BasePresenter::tryCall() around the action<Action>() method |
cron |
bin/* and command-line scripts |
OperationRunner::cli($container, $name, $body) |
cli |
An operation started inside another one only joins it and clears nothing. The identity map is emptied only at the boundaries of a root operation.
What happens at a boundary#
DoctrineOperationParticipant (app/Core/Doctrine/Operations/) guards the identity map:
- Start: empties the map, but only when the
EntityManageris open, no transaction is running and nothing is scheduled for writing. - End: the same. If the operation failed inside an open transaction, the map is left to whoever rolls the transaction back.
- Reads never clear.
find(),findBy()andfindAll()do not empty the map.
A boundary never runs flush() or any SQL. It only empties the map, and what was not saved
is gone.
Contract violations#
When an operation starts with work in progress, ends with unwritten changes (succeeded or
unknown) or leaves a transaction open, that is a contract violation:
| Code | Meaning |
|---|---|
begin.pending_work |
the operation started while something already waited for flush() |
end.pending_work |
the operation ended successfully and something stayed unwritten |
end.transaction_left_open |
the operation opened a transaction and did not close it |
begin.transaction_active, begin.em_closed |
only written to the log, never an exception |
What happens is set by params.1.operations.contractViolation in config/Shared/operations.neon:
| Value | Behaviour |
|---|---|
auto (default) |
outside Tracy production mode (debugging) an OperationContractViolationException, in production just a log line |
throw |
always an exception |
log |
never an exception |
The exception comes after the cleanup (rolling back the transactions the operation left open, then the closing clear) and never hides the operation's own exception.
What follows for your code#
An entity does not cross the boundary of an operation
An entity loaded in one operation is detached once it ends. Reads and getters keep
working, but flush() no longer knows it. What leaves an operation should be values,
IDs and a Result, not entities.
Three symptoms show where it hurts. They appear when the map is emptied between loading an
entity and saving it: at an operation boundary, after abort() of a rejected save, or after a
batch.
1. A detached reference#
Doctrine\ORM\ORMInvalidArgumentException:
A new entity was found through the relationship 'X#owner' that was not
configured to cascade persist operations
At flush() a detached entity looks like a new entity without a cascade.
Fix: re-attach it with $em->getReference() right before saving, or keep only the ID
and load the entity in the operation that saves it.
2. A lazy collection initialised after the map was emptied#
- You load an entity with
includes; a collection that is not in them stays lazy. - The map is emptied (operation boundary, batch).
- Only now you iterate the lazy collection: the children are managed, but their back reference points to the detached owner.
flush()throws the same message as in point 1.
includes must cover every collection that anything started from the operation touches. Where
that is impossible, hydrate the collections with an explicit "touch" while the owner is still
managed.
3. A duplicate instance#
While adding an entity of class …\OrderShipper with an ID hash of "1" to the
identity map, another object of class …\OrderShipper was already present for
the same ID.
The same entity reached the map a second time by another path.
Who may empty the map otherwise#
No code other than UnitOfWorkReset (app/Core/Doctrine/Operations/UnitOfWorkReset.php) may
call clear() or detach() in app/. The test UnitOfWorkResetBoundaryTest enforces it. Each
method is named after its reason so the reasons can be told apart in the log:
| Method | When |
|---|---|
abort() |
a rejected save or a rollback; a half-built graph must not be saved later |
finishBatch() |
a long run finished a batch and drops what it loaded |
resetBatch() |
a phase after writes through DBAL starts with a clean map |
discard() |
after a rollback detaches only the given entities |
recoverAfterFailure(), reopenIfClosed() |
recovery after a failed flush() |
beginOperation(), endOperation() |
the boundaries of a root operation; only the participant calls them |
Procedure: I write a hook that needs foreign data#
- Load everything BEFORE you start building the graph. Code lists, settings and foreign keys at the start.
- Raw DBAL is a legitimate substitute for simple reads inside a half-built graph:
| I need | Substitute |
|---|---|
| one code-list row | a raw DBAL query through $em->getConnection() |
| a setting | SettingContext (in memory, does not touch the DB) |
| a foreign key into an association | $em->getReference() |
| check existence | SELECT COUNT through DBAL |
- Verify by saving, not by reading. The error shows up only at
flush().
'Reads first, then build the graph' is still useful
A read no longer detaches the graph, but calling another operation inside a hook (through
internalAction*) brings its own rules. Loading everything beforehand is simpler.
Procedure: I got "A new entity was found through the relationship"#
- Do not look at the place where
flush()failed. The exception arises at saving; the cause is earlier: the map was emptied between loading the entity and saving it. - Walk the path from loading to
flush()and look for a boundary: a call to anotherinternalAction*, a batch (finishBatch()), a rejected save (abort()). - Check
includes. Is a collection missing that someone iterates later? - Look into
log/operations.log. TheOPERATIONline showsclear_abort,clear_batch,clear_reset,clear_recover,clear_operation,clear_otheranddetach, and the reason inreset_reasons.clear_otheris an emptying outsideUnitOfWorkReset. - Only then reach for
getReference()as a patch.
Procedure: a command-line script or a cron job#
Without an operation the map is never emptied, so a second find() of the same entity
after the database changed from outside (raw SQL, another process) returns the stale
instance.
- Wrap a script in
bin/:OperationRunner::cli($container, 'step-name', fn () => …). - One logical step (one region, one document) = one operation. Return values out of the step, not entities.
- A Cron action is wrapped by
Cron\BasePresenteritself; add nothing inside the action. - Split a long cron run into batches and call
finishBatch()after each, otherwise the whole action holds every loaded entity.
The operation log#
log/operations.log receives an OPERATION_REQUEST summary per request and an OPERATION
line only for an operation worth a look (params.1.operations.log: summary): outcome failed
or rejected, a contract violation, clear_other other than zero, clear_abort /
clear_reset / clear_recover / detach above zero, or an exceeded logThreshold (ms: 250,
sql: 50, uowSize: 500). The value verbose writes every operation and the log grows fast
(hundreds of MB per hour in E2E tests). The log is not rotated.
Changing the parameters requires deleting the container cache
It is decided when the container is compiled. After changing operations.neon, delete
temp/cache/nette.configurator.
Where to look#
| I want | Where |
|---|---|
| the boundaries and rules | app/Core/Doctrine/Operations/DoctrineOperationParticipant.php |
| every permitted emptying | app/Core/Doctrine/Operations/UnitOfWorkReset.php |
| opening an operation in the Api | operation() in app/Core/Base/Api/BasePresenter.php |
| operations in Cron and CLI | app/Core/Operations/OperationRunner.php |
| settings and the log | config/Shared/operations.neon |
| a finished example of going around through DBAL | app/UI/Api/Invoicer/Models/Generators/InvoiceNumberGenerator.php |
| lifecycle hooks | app/Core/Traits/Api/Models/Managers/SaveTrait.php |
Follow-up chapters: Manager lifecycle · Hydrators · Calling the API · Five-layer model