Repository navigation
fix(core): don't record a fork parent when resuming the same app - #1028
Conversation
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.
enekoreto
left a comment
There was a problem hiding this comment.
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.
Resuming an application from a persister (same
app_id, nofork_from_app_id) gave it a fork parent pointer withapp_id=None. Trackers that record the pointer then fail inbuild():LocalTrackingClientwhen its tracking directory has not seen the app yet, andS3TrackingClienton every resume._init_state_from_persistersetsloaded_from_fork = Truewhenever the initializer returns state, including a plain resume._build_commonthen buildsParentPointer(app_id=self.fork_from_app_id, ...), which isParentPointer(app_id=None, partition_key=None, sequence_id=None)when there is no fork. Both trackers turn that intoPointerModel.from_pointer(parent_pointer)inpost_application_create, andPointerModel.app_idis a requiredstr:LocalTrackingClientreturns early whengraph.jsonalready 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 differentstorage_diror project.S3TrackingClienthas no such early return and fails on every resume. Even when nothing fails,Application.parent_pointerreports 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_persistermarks the app as loaded from a fork only whenfork_from_app_idis set. A plain resume has no parent pointer. Forks are unchanged.test_persister_resume_without_fork_has_no_parentintests/tracking/test_local_tracking_client.py: it resumes an app from a persister into a fresh local tracking directory, then checks thatbuild()succeeds, thatapp.parent_pointerisNone, and thatmetadata.jsonhas no parent pointer.How I tested this
Reproduction: a counter app that uses
SQLLitePersisterfor bothinitialize_from(..., resume_at_next_action=True)andwith_state_persister, plus a tracker. Each run callsbuild()andstep()once with the same database:Run 4 used
S3TrackingClientwithboto3.clientmocked, so there was no network access. The failure happens before any S3 call.application.pychange reverted, it fails inbuild()with thePointerModelValidationErrorabove.tests/tracking/test_local_tracking_client.py: 18 passed. That includestest_persister_tracks_parentandtest_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.scripts/check_asf_headers.pypass 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
LocalTrackingClient.post_application_createand skips pointers without anapp_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.S3TrackingClientandLocalTrackingClientkeep theirparent_pointer is not Nonechecks. They now getNonefor a plain resume, which those checks already handle.Checklist