|
46 | 46 | from pydantic import ValidationError |
47 | 47 |
|
48 | 48 | from openarmature.checkpoint.errors import ( |
| 49 | + CheckpointError, |
49 | 50 | CheckpointNotFound, |
50 | 51 | CheckpointRecordInvalid, |
51 | 52 | CheckpointSaveFailed, |
52 | | - CheckpointStateMigrationChainAmbiguous, |
53 | 53 | CheckpointStateMigrationFailed, |
54 | 54 | CheckpointStateMigrationMissing, |
55 | 55 | ) |
@@ -274,10 +274,22 @@ def _apply_migration_step( |
274 | 274 | """ |
275 | 275 | try: |
276 | 276 | return migration.migrate(value) |
| 277 | + except CheckpointError: |
| 278 | + # Preserve canonical category — if a migration raises a |
| 279 | + # CheckpointError subclass itself (rare; migrations are |
| 280 | + # spec-mandated pure per §10.12.2), propagate the original |
| 281 | + # category rather than wrapping it as |
| 282 | + # CheckpointStateMigrationFailed. |
| 283 | + raise |
277 | 284 | except Exception as exc: |
| 285 | + # Concise wrap-message intentionally. ``raise ... from exc`` |
| 286 | + # preserves the original exception on ``__cause__``; |
| 287 | + # Python's traceback formatter surfaces it, so embedding the |
| 288 | + # underlying ``type/str`` in this message would just |
| 289 | + # duplicate information (and confuse the output when the |
| 290 | + # underlying ``__str__`` is multi-line). |
278 | 291 | raise CheckpointStateMigrationFailed( |
279 | | - f"migration {migration.from_version!r}→{migration.to_version!r} " |
280 | | - f"raised while migrating {label}: {type(exc).__name__}: {exc}", |
| 292 | + f"migration {migration.from_version!r}→{migration.to_version!r} raised while migrating {label}", |
281 | 293 | from_version=migration.from_version, |
282 | 294 | to_version=migration.to_version, |
283 | 295 | ) from exc |
@@ -419,24 +431,16 @@ async def _migrate_record( |
419 | 431 | f"support state migration", |
420 | 432 | ) |
421 | 433 |
|
422 | | - try: |
423 | | - chain = self.migration_registry.resolve_chain( |
424 | | - record.schema_version, |
425 | | - current_schema_version, |
426 | | - ) |
427 | | - except ValueError as exc: |
428 | | - # MigrationRegistry signals multi-shortest-path ambiguity |
429 | | - # via ValueError. Per spec §10.10 / §10.12.2 (proposal 0018, |
430 | | - # spec v0.16.0), this routes to the canonical |
431 | | - # CheckpointStateMigrationChainAmbiguous category. Spec |
432 | | - # accepts load-time detection (this is the resume-side |
433 | | - # path); the duplicate-pair case raises the same category |
434 | | - # directly from MigrationRegistry.register at build time. |
435 | | - raise CheckpointStateMigrationChainAmbiguous( |
436 | | - str(exc), |
437 | | - from_version=record.schema_version, |
438 | | - to_version=current_schema_version, |
439 | | - ) from exc |
| 434 | + # resolve_chain raises CheckpointStateMigrationChainAmbiguous |
| 435 | + # directly on multi-shortest-path detection per spec §10.10 |
| 436 | + # / §10.12.2 (proposal 0018, spec v0.16.0). No except-wrap |
| 437 | + # needed here — the canonical category propagates straight |
| 438 | + # through and the registry's exception contract is one type |
| 439 | + # regardless of when ambiguity surfaces (register vs resolve). |
| 440 | + chain = self.migration_registry.resolve_chain( |
| 441 | + record.schema_version, |
| 442 | + current_schema_version, |
| 443 | + ) |
440 | 444 |
|
441 | 445 | if chain is None: |
442 | 446 | raise CheckpointStateMigrationMissing( |
@@ -1505,8 +1509,15 @@ def _dispatch_completed( |
1505 | 1509 | ), |
1506 | 1510 | ) |
1507 | 1511 |
|
1508 | | - @staticmethod |
| 1512 | + # Instance method (not @staticmethod) so the save-time |
| 1513 | + # schema_version read goes through ``self.state_cls`` — matches |
| 1514 | + # the resume-side check, per spec §10.2's "framework reads |
| 1515 | + # schema_version from the state definition at save time" |
| 1516 | + # wording. Reading from ``type(post_state)`` would let a State |
| 1517 | + # subclass instance shadow the declared graph schema and |
| 1518 | + # trigger spurious migrations on resume. |
1509 | 1519 | async def _maybe_save_checkpoint( |
| 1520 | + self, |
1510 | 1521 | context: _InvocationContext, |
1511 | 1522 | *, |
1512 | 1523 | node_name: str, |
@@ -1574,13 +1585,15 @@ async def _maybe_save_checkpoint( |
1574 | 1585 | # within-invocation order. |
1575 | 1586 | last_saved_at=time.time(), |
1576 | 1587 | # Per spec §10.2 (proposal 0014): read the user's |
1577 | | - # state-schema version off the state class at save time. |
1578 | | - # Empty-string sentinel when the user hasn't declared |
1579 | | - # one — those records are not migration-eligible until |
1580 | | - # they declare a non-empty version (per §10.2). The |
1581 | | - # runtime type of ``post_state`` is the authoritative |
1582 | | - # source (subclasses MAY override the ClassVar). |
1583 | | - schema_version=cast("type[State]", type(post_state)).schema_version, |
| 1588 | + # state-schema version off the declared state class at |
| 1589 | + # save time. Empty-string sentinel when the user hasn't |
| 1590 | + # declared one — those records are not migration-eligible |
| 1591 | + # until they declare a non-empty version (per §10.2). |
| 1592 | + # ``self.state_cls`` is the authoritative source so the |
| 1593 | + # save-side read symmetrizes with the resume-side check |
| 1594 | + # (subclass schema_versions don't shadow the declared |
| 1595 | + # graph schema). |
| 1596 | + schema_version=self.state_cls.schema_version, |
1584 | 1597 | ) |
1585 | 1598 | try: |
1586 | 1599 | await checkpointer.save(context.invocation_id, record) |
|
0 commit comments