Skip to content

fix(core): don't record a fork parent when resuming the same app - #1028

Merged
skrawcz merged 1 commit into
apache:mainfrom
PDGGK:fix-resume-bogus-fork-parent-pointer
Nov 4, 2026
Merged

skrawcz merged 1 commit into
apache:mainfrom
PDGGK:fix-resume-bogus-fork-parent-pointer

Conversation

@PDGGK

@PDGGK PDGGK commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Resuming an application from a persister (same app_id, no fork_from_app_id) gave it a fork parent pointer with app_id=None. Trackers that record the pointer then fail in build(): LocalTrackingClient when its tracking directory has not seen the app yet, and S3TrackingClient on every resume.

_init_state_from_persister sets loaded_from_fork = True whenever the initializer returns state, including a plain resume. _build_common then builds ParentPointer(app_id=self.fork_from_app_id, ...), which is ParentPointer(app_id=None, partition_key=None, sequence_id=None) when there is no fork. Both trackers turn that into PointerModel.from_pointer(parent_pointer) in post_application_create, and PointerModel.app_id is a required str:

pydantic_core._pydantic_core.ValidationError: 1 validation error for PointerModel
app_id
  Input should be a valid string [type=string_type, input_value=None, input_type=NoneType]

LocalTrackingClient returns early when graph.json already exists, so the crash only shows when the state outlives the tracking directory. That happens when state lives in a database and tracking is local to a container or machine, after tracking is added to an app that already has persisted state, or with a different storage_dir or project. S3TrackingClient has no such early return and fails on every resume. Even when nothing fails, Application.parent_pointer reports a parent for an app that was not forked, although its docstring says it "is None if it was not forked".

Changes

  • ApplicationBuilder._init_state_from_persister marks the app as loaded from a fork only when fork_from_app_id is set. A plain resume has no parent pointer. Forks are unchanged.
  • New test test_persister_resume_without_fork_has_no_parent in tests/tracking/test_local_tracking_client.py: it resumes an app from a persister into a fresh local tracking directory, then checks that build() succeeds, that app.parent_pointer is None, and that metadata.json has no parent pointer.

How I tested this

Reproduction: a counter app that uses SQLLitePersister for both initialize_from(..., resume_at_next_action=True) and with_state_persister, plus a tracker. Each run calls build() and step() once with the same database:

# 0.43.0 (PyPI) and main without this change
run 1, tracking dir A:     count = 1 | parent_pointer = None
run 2, same tracking dir:  count = 2 | parent_pointer = ParentPointer(app_id=None, partition_key=None, sequence_id=None)
run 3, fresh tracking dir: pydantic_core._pydantic_core.ValidationError: 1 validation error for PointerModel
run 4, S3TrackingClient:   pydantic_core._pydantic_core.ValidationError: 1 validation error for PointerModel

# with this change
run 1, tracking dir A:     count = 1 | parent_pointer = None
run 2, same tracking dir:  count = 2 | parent_pointer = None
run 3, fresh tracking dir: count = 3 | parent_pointer = None
run 4, S3TrackingClient:   count = 4 | parent_pointer = None

Run 4 used S3TrackingClient with boto3.client mocked, so there was no network access. The failure happens before any S3 call.

  • The new test passes with the change. With only the application.py change reverted, it fails in build() with the PointerModel ValidationError above.
  • tests/tracking/test_local_tracking_client.py: 18 passed. That includes test_persister_tracks_parent and test_fork_children_have_correct_partition_key, which check that forks still record their parent.
  • tests/core/test_application.py: 142 passed. tests/core/test_persistence.py: 50 passed. tests/core/test_parallelism.py: 18 passed.
  • black 23.11.0, isort 5.12.0, flake8 6.1.0 and scripts/check_asf_headers.py pass on the two changed files, all in check mode.

I did not run the full suite or the database-backed persister integration tests locally. CI covers those.

Notes

  • fix(tracking): validate app ids in the local tracking client #946 adds identifier validation to LocalTrackingClient.post_application_create and skips pointers without an app_id ("plain resume, not a fork"). With this change a plain resume no longer produces such a pointer. The two changes touch different files and do not conflict.
  • S3TrackingClient and LocalTrackingClient keep their parent_pointer is not None checks. They now get None for a plain resume, which those checks already handle.

Checklist

  • PR has an informative and human-readable title (this will be pulled into the release notes)
  • Changes are limited to a single goal (no scope creep)
  • Code passed the pre-commit check & code is left cleaner/nicer than when first encountered.
  • Any change in functionality is tested
  • New functions are documented (with a description, list of inputs, and expected output)
  • Placeholder code is flagged / future TODOs are captured in comments
  • Project documentation has been updated if adding/changing functionality.

When initialize_from() loaded state for the same app_id (no
fork_from_app_id), the builder still marked the app as loaded from a
fork. The application then got a parent pointer with app_id=None, and
trackers that record it failed to build: LocalTrackingClient on a
tracking directory that has not seen the app, and S3TrackingClient on
every resume, both with a pydantic ValidationError from PointerModel.

Only mark the app as forked when fork_from_app_id is set, so a plain
resume has no parent pointer, as Application.parent_pointer documents.
@github-actions github-actions Bot added the area/core Application, State, Graph, Actions label Nov 3, 2026

@enekoreto enekoreto left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix looks right to me. I checked out the branch (44cac78) on main (3f16c1e): the new test fails on main with the PointerModel ValidationError and passes here, CI's test command passes, also on Python 3.10, and pre-commit passes on the two files.

I also repeated your four runs with a SQLitePersister. On main, run 2 gets ParentPointer(app_id=None, ...), and runs 3 and 4 (a fresh local tracking directory, and S3TrackingClient with boto3 mocked) fail in build(). Here all four build and parent_pointer is None. A few more cases had the same problem on main and are fixed here: resume_at_next_action=False, resuming with the local tracker as the initializer, and the async builder with AsyncInMemoryPersister into a fresh tracking directory. The new test only covers the sync builder, but the async one goes through the same _init_state_from_persister.

A real fork (fork_from_app_id and fork_from_partition_key) still records the parent's app_id in metadata.json and still writes the parent's children.jsonl, and a fork whose parent doesn't exist still gets no pointer. loaded_from_fork is only read where the fork pointer is built, so nothing else changes.

I ran this on Python 3.13 on macOS, and the tests also on 3.10.

@skrawcz skrawcz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@skrawcz
skrawcz enabled auto-merge (rebase) November 4, 2026 06:48
@skrawcz
skrawcz merged commit cdea4eb into apache:main Nov 4, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core Application, State, Graph, Actions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants