Skip to content

Persist: index new entity first so circular references don't loop#15

Open
Spamercz wants to merge 1 commit into
v2.0from
feature/two-phase-persist
Open

Persist: index new entity first so circular references don't loop#15
Spamercz wants to merge 1 commit into
v2.0from
feature/two-phase-persist

Conversation

@Spamercz

@Spamercz Spamercz commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Problem

Persisting a new entity (EmptyElasticId) whose nested elastic entity references it back loops forever. Insert::execute calls PrepareEntityArray::prepare() first, which persists the child; the child's prepare() persists the parent again, and so on. New entities aren't tracked in IdentityMap (their id is the empty string), so isChanged() always reports them as changed and they get re-persisted.

The v2.0 Title/Image fixtures (Title.backdrop → Image, Image.title → Title) are exactly this circular shape.

Change

Insert::execute now saves a new entity in two phases:

  1. Index it once with a shallow (all-null) body to obtain its id and register it as persisting in IdentityMap.
  2. Prepare/persist its nested entities — a back-reference to the parent now resolves to the parent's id instead of re-persisting it.
  3. Re-index it with the full body.

Entities that already have an id are still indexed once (unchanged).

Details

  • IdentityMap gains persistingList + markPersisting() / unmarkPersisting() / isPersisting() (and clear() resets it).
  • PrepareEntityArray short-circuits to the referenced entity's id (instead of recursing) whenever it isPersisting(), for the nested AbstractElasticEntity, ElasticEntityCollection item, and STIElasticEntity cases. This guard is needed in addition to isChanged() because the parent's serialized hash changes as its children receive ids — so isChanged() alone would still re-enter the loop.
  • The shallow first index uses an all-null body (every field except id), which is a valid non-empty document for any mapping; its content is irrelevant because phase 3 overwrites it. Only the second index refreshes, so there is one refresh per entity as before (one extra cheap index call for new entities).

Test

tests/SpameriTests/Elastic/EntityManager/PersistCircularTest.phpt persists a Title whose backdrop is an Image whose title points back at the Title, and asserts: it terminates (no loop), both ids are assigned, the in-memory graph resolves both ids, and each stored document references the other.

$ vendor/bin/tester tests/SpameriTests/Elastic/EntityManager/PersistCircularTest.phpt
OK (1 test)

PHPStan (level 6) and CS are clean on the changed files.

Note: the existing PersistTest/InsertTest are flaky in some environments because their setUp deletes + recreates indexes with a short sleep (and relies on wildcard deletes); the new test avoids this by using auto-create and reading documents back by id. That flakiness is pre-existing and unrelated to this change.

🤖 Generated with Claude Code

Persisting a new entity (EmptyElasticId) with a nested elastic entity that
references it back looped: Insert prepared the parent first, which persisted
the child, whose prepare persisted the parent again, and so on. New entities
are not tracked in the IdentityMap (empty-string id), so isChanged() always
reported them as changed and they were re-persisted.

Insert::execute now saves a new entity in two phases:
 1. index it once with a shallow (all-null) body to obtain its id and register
    it as "persisting" in the IdentityMap,
 2. prepare/persist its nested entities - a back-reference to the parent now
    resolves to the parent's id instead of re-persisting it,
 3. re-index it with the full body.

Entities that already have an id are still indexed once.

IdentityMap gains a persistingList with markPersisting/unmarkPersisting/
isPersisting. PrepareEntityArray short-circuits to the id (instead of
recursing) whenever a referenced entity is currently being persisted, for the
nested AbstractElasticEntity, ElasticEntityCollection item and STIElasticEntity
cases. The "persisting" check is needed in addition to isChanged() because the
parent's hash changes as its children receive ids.

Adds PersistCircularTest, which persists a Title whose backdrop is an Image
whose title points back at the Title, and asserts it terminates, both ids are
assigned and each stored document references the other.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Spamercz Spamercz force-pushed the feature/two-phase-persist branch from 56a5a10 to 2d801f4 Compare June 18, 2026 21:16
@Spamercz Spamercz changed the base branch from master to v2.0 June 18, 2026 21:16
@Spamercz Spamercz changed the title Two-phase persist: save parent before nested elastic entities/collections Persist: index new entity first so circular references don't loop Jun 18, 2026
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