Skip to content
V
For developers
Architecture, conventions, the core and security
Core / Identity map and operation boundary

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 EntityManager is 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() and findAll() 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#

  1. You load an entity with includes; a collection that is not in them stays lazy.
  2. The map is emptied (operation boundary, batch).
  3. Only now you iterate the lazy collection: the children are managed, but their back reference points to the detached owner.
  4. 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#

  1. Load everything BEFORE you start building the graph. Code lists, settings and foreign keys at the start.
  2. 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
  1. 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"#

  1. 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.
  2. Walk the path from loading to flush() and look for a boundary: a call to another internalAction*, a batch (finishBatch()), a rejected save (abort()).
  3. Check includes. Is a collection missing that someone iterates later?
  4. Look into log/operations.log. The OPERATION line shows clear_abort, clear_batch, clear_reset, clear_recover, clear_operation, clear_other and detach, and the reason in reset_reasons. clear_other is an emptying outside UnitOfWorkReset.
  5. 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.

  1. Wrap a script in bin/: OperationRunner::cli($container, 'step-name', fn () => …).
  2. One logical step (one region, one document) = one operation. Return values out of the step, not entities.
  3. A Cron action is wrapped by Cron\BasePresenter itself; add nothing inside the action.
  4. 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

Cookie settings