Skip to content

feat: address migration gaps - #9487

Open
pjwerneck wants to merge 40 commits into
devfrom
pjwerneck/migration-gaps
Open

feat: address migration gaps#9487
pjwerneck wants to merge 40 commits into
devfrom
pjwerneck/migration-gaps

Conversation

@pjwerneck

@pjwerneck pjwerneck commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes most of the migration gaps that block safe upgrades: private Drive folders are adopted by rename, P2P folders are found regardless of the version in the name, and durable surfaces (checkpoints, rolling state, crypto keys, peers JSON, local caches) carry or refuse versions instead of failing silently.

Adds a per-protocol minimum-supported floor (shipped inert at "0"), stops refusing peers on client semver mismatch, and hardens release/CI (export → publish → tag → bump, client migration tests in CI, numeric version ordering). Job protocol skew delivery is covered end-to-end; dataset transport multi-copy (A1) and a few process/policy items remain open.

Note

The base branch is stephen/syft-client-migrations, not dev. This branch assumes that branch will be merged first.

Changes

Done

  • A1 dataset transport. create_dataset writes one copy for each protocol
    version in the audience. Each copy gets its own Drive collection, and every
    collection reaches the whole audience. A peer that upgrades later therefore
    moves to the newer layout with no action by the owner. The collection name
    holds the version as a v<n> infix before the separator. A client that
    predates this change therefore never lists a layout it cannot read. A
    protocol-0 name does not change. The client uploads the private data of a
    copy with that copy. A watcher keeps the newest readable layout for each
    dataset, and warns and skips the rest.
  • A2a private folders. _find_or_adopt_versioned_folder renames the folder
    of the highest earlier version and keeps the data. The personal datasite
    folder, the checkpoints folder, and the rolling-state folder all use it.
  • A2b P2P folders. _find_p2p_folder_id finds a folder whatever version
    its name holds. _filter_patch_compatible is gone.
  • A3 floor. Each package publishes MIN_SUPPORTED_*_PROTOCOL_VERSION. The
    value travels as min_supported_version, and negotiate_protocol_version
    enforces it for jobs and for datasets.
  • A3 peer gate. An incompatible client version no longer skips a peer
    (get_peer_compatibility_status). force_ignore_protocol_version is gone.
  • A3 login. The mismatch prompt keeps local and remote data. A full
    deletion is an explicit second choice. A run with no terminal takes the
    keep-data answer, so a notebook or a scheduled run does not stop. Login
    writes the version file on both sides. A second login therefore shows no
    prompt, and a peer reads a current version.
  • A4. The dead ClientVersionMismatchError and
    ProtocolVersionMismatchError are gone. The inert semver PROTOCOL_VERSION
    field stays on the wire.
  • A5. The defaults for an unknown peer are documented, and a forced path
    logs a warning. See test_unknown_peer_forced_path.py.
  • B1. The client refuses a checkpoint or a rolling state from a later
    client, and downloads all events instead. A local load that fails reports the
    failure.
  • B2. The crypto key file carries CRYPTO_KEYS_VERSION. A later version is
    refused, because a private key cannot be rebuilt.
  • B3. SYFT_peers.json carries a version under _meta. The client logs an
    unknown peer state and skips it, and the rewrite of the raw map keeps the
    entry.
  • B4. PersistedDict writes {"version", "entries"}. An old flat file
    reads as version 0, and a later version resets to empty.
  • C1. The release job exports the artifacts, publishes, tags, then bumps.
    --dependents published pins the published version, and
    protocol_bump_missing compares against the newest released protocol.
  • C2. The client migration tests run in CI, with
    just test-client-migrations, and again after a release.
  • C3 cases 1 and 2. A job that is negotiated down to protocol 0 arrives and
    reads (test_job_protocol_skew_delivery.py). A dataset reaches an audience
    of mixed protocol versions (test_dataset_multicopy_delivery.py).
  • D1. _version_order compares a version as a number, so "10" is after
    "2".
  • D2. Tests guard the additive-only rule for the VersionInfo fields
    (test_version_info_fields.py).
  • D3. The job and the dataset negotiation tests assert the identity of the
    live peer-schema dict.

Open items

One decision on A3 clears the first group. One release
cycle clears the second.

1. Waits on a decision (A3)

A peer can speak a protocol below the floor of a package. The client can refuse
it, warn about it, or go read-only. That question is open, and three items wait
on the answer. Every floor is "0" today, so the mechanism refuses nothing and
no user sees a difference.

  • A3 policy. The rule itself: refuse, warn, or read-only.
  • A3 residual. target_protocol_versions_for_peers takes
    min(ours, theirs) and applies no floor. A1 makes this path live. The
    correct action for a peer below the floor is part of the same decision.
  • C3 case 3. An upgrade in place with mixed peers has no automated test,
    because the correct result is not defined without the policy. A manual test
    covers this ground now.

2. Closed by the next release

The first two items block the release. Do them before it. No code change on
this branch can close any of the three, because each one needs a release that
happened.

  • syft-migration is not on PyPI, and no CD workflow releases it.
    syft-client, syft-job, and syft-dataset all depend on it with no pin. The
    monorepo resolves the dependency from the workspace, so a local install
    works. An install of the next release fails.
  • The one-time version increase. The branch still names the published
    versions: syft-client 0.1.117, syft-job 0.1.39, syft-dataset 0.1.20. The
    release job publishes the version that is on the branch, and PyPI refuses a
    version that exists.
  • The 0.1.117 fixture and package artifact. Both hold
    syft_client_install_source and attestation_token. The published wheel does
    not define these fields, because PyPI received it 12 days before the fields
    arrived. A correct file needs the published wheel as the source.

3. Needs a rehearsal by a person

  • C4 and E1. Nobody followed the migration guidance end to end, and only
    VersionInfo has a real migration pair. Both items close when one person
    does a full object version increase on a branch that never merges. That
    person records each point where the tools say nothing. A synthetic V2 must not
    merge: a released V2 freezes its schema and owes a migration forever.

4. Open by decision

  • The provisional protocol lock (C1). It is a workflow contract for the whole team, not a part of the migration system. The cost of the decision is measured and written down.

5. Permanent, and not work

  • The semver PROTOCOL_VERSION field (A4). It carries no meaning, and it
    must stay on the wire, because a supported older client parses it.

6. A rare failure, kept loud on purpose

  • A2c. Two compatible private folders reach _expect_one, which raises and
    names both folders. An automatic merge can put the data of a user out of
    reach, so the client stops instead. Folder adoption makes this case rare.

7. Not measured

  • B4 residual. A reset of file_hashes replays the event stream. The reset
    works. The cost for a large datasite is not measured.

Testing

  • Unit and migration tests for each gap

Asana task

https://app.asana.com/1/1185126988600652/project/1216249688888494/task/1217005273770094?focus=true

bitsofsteve and others added 29 commits July 23, 2026 05:45
- This is a deferral, in line with #9443 which introduced the breaking change. There's no point in writing v1/ metadata until transport can place v1/.
- Fix C2 item from migration gaps review
- Fix D1 item from migration gaps review
- Fix C1 item from migration gaps review
- Pin dependents to the published version, not the bumped one, so a package released later in the same run does not need a version  PyPI lacks
- Fix A2 item from migration gaps review, private folders only
- Code quality fixes
- Fix A3 item from migration gaps review, floor mechanism only
- Every floor starts at 0, so no peer is refused; protocol 1 has never shipped, so 0 is the only correct value today.
- Fix A3 item from migration gaps review, peer gate policy
- A peer with UNKNOWN version is still skipped, because nothing can be negotiated without its version
 - Fix A2b item from migration gaps review, completing A2
 - A folder this client owns is reused after an upgrade, becasuse a peer that has not upgraded still looks for the old name
 - Delete _filter_patch_compatible, which has no caller left.
- B2: version the crypto key file; refuse an unknown later version because a private key cannot be rebuilt
- B4: version the persisted caches; reset on an unknown later version, because the client rebuilds them. On-disk format becomes {"version", "entries"}
- D2: freeze the VersionInfo V1 field set; every field V2 adds needs a default.
- A4: delete the two unused version exception classes.
- Fix B3 item from migration gaps review
- Stamp the format version under a reserved _meta key, so older clients
  that treat every top-level key as a peer email skip it safely
- Log and skip an unknown peer state instead of dropping the peer in silence;
  the writer keeps other entries, so the record is not erased on Drive
- Fix B1 item from migration gaps review; A2a already fixed the folder half
- A later client can reshape a field while the object still parses, which gives a wrong restore silently. Ever load site already falls back to downloading all events, so refusing costs one slow cold start.
- Fix C3 case 1 from migration gaps review
- The existing tests assert the negotiated version only; removing the protocol-0 codec fails this test and leaves those passing
- Fix A5 item from migration gaps review; the entry named the wrong pair, the different is between the two dataset methods, not jobs vs datasets.
- Fix A1 from migration gaps review. The dataset transport dropped the
  protocol version: the sender flattened every file and the receiver rebuilt
  a flat path, so a v1 dataset arrived with metadata that pointed at a
  directory that was not there.
- create_dataset now writes one copy for each layout in the audience, and
  each copy gets its own collection. Every collection is shared with the
  whole audience, so a peer that upgrades later moves to the newer layout
  with no action by the owner.
- The collection folder name carries the version as a v<n> infix before the
  separator. A client that predates multi-copy searches for the separator
  and so never lists a layout it cannot read. A protocol-0 name is
  unchanged, byte for byte.
- Private data goes up with the copy that owns it. The copies hold separate
  private directories, so one upload of the newest left the others local
  only and a cold start did not restore them.
- The watcher keeps the newest readable layout for each dataset, and warns
  and skips the rest. It keeps a local copy when the owner still publishes
  the dataset but in no layout this client reads, because that copy is the
  last one this client could read.
- Login writes the remote version file. Only test helpers wrote it before,
  so the remote file kept the version that first created it. The mismatch
  check then prompted at every login, and a peer negotiated a job or
  dataset protocol version from a stale number. Closes the login item of A3.
- The login mismatch prompt keeps local and remote data by default and
  repairs on the next sync. A full wipe is an explicit second choice.
  delete_unversioned_state is gone with the old first choice. A run with no
  terminal takes the keep-everything default instead of blocking.
@pjwerneck
pjwerneck changed the base branch from stephen/syft-client-migrations to dev August 28, 2026 16:16
@review-notebook-app

Copy link
Copy Markdown

Check out this pull request on  ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

@pjwerneck
pjwerneck marked this pull request as ready for review September 1, 2026 15:10
@pjwerneck pjwerneck changed the title feat: address migration gaps (WIP) feat: address migration gaps Sep 1, 2026
Comment on lines +429 to +430
if status == CompatibilityStatus.INCOMPATIBLE:
# A different client version does not refuse the peer. What each side

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

for A3 I think we should refuse by default, but it should be possible for the user to override this. I think in general this should often be the case

@koenvanderveen koenvanderveen Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I also wonder if this is a good idea. I think we should be more clear about what compatibility status means. If two peers have a incompatible syft-client version, we should skip syncing for them

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The override already exists. ignore_peer_version per call and force_ignore_peer_version on the config. The skip message already says Use ignore_peer_version=True to override.

Refusing on a client version difference by default is what deliberately tried to avoid. The live test on real Google Drive confirmed the current policy works. A 0.1.117 DS exchanged a job and read a dataset from a 0.2.0 DO. Under refuse-by-default, every rollout window breaks that.

What exactly is the problem you see? Do you prefer it to fail by default and be explicitly overrided by the user?

_VersionedFolder = tuple[int, int, int, str, str]


def _sorted_by_version(folders: list[tuple[str, str]]) -> list[tuple[str, str]]:

@koenvanderveen koenvanderveen Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I wonder if this is the way we should do it. We should be able to know a peers version from their version.json, we dont need multiple sources of truth, we should probably be able to decide based on that instead of this information. For instance, what happens if a peer downgrades?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Peer capability comes solely from version.json, but it lives inside the folder we are trying to find, so it's a chicken-and-egg situation. The folder name is a recognizable address. It's not a source of truth for the negotiation.

Downgrade cases are handled depending on the direction and other details. I will confirm in the code and add another comment later.

},
)

def negotiate_protocol_version(

@koenvanderveen koenvanderveen Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This sounds like we are actively communicating with the peer, which is not what's happening. Something like infer_protocol_version_to_use makes more sense to me.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agree. Will fix.

# Format of the crypto key file. Raise it when the layout of the file changes,
# and add a read path for every earlier version. A file with no version was
# written before the field, and is version 0.
CRYPTO_KEYS_VERSION = 1

@koenvanderveen koenvanderveen Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

wrt crypto keys version and persisted dict, I think the assumption is that they are backwards compatible. Until we have migrations for them we should probably have a test that we can read old versions?

@pjwerneck pjwerneck Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The tests exist in:

  • tests/unit/test_crypto_keys_version.py
  • tests/unit/test_persisted_dict_version.py

# Return the newest protocol version written (richest layout).
return created[max(created, key=int)]

def create_all(

@koenvanderveen koenvanderveen Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

W.r.t. datasets I would like to balance writing multiple versions (heavy) against missing a version. It's quite easy to write an additional version later on version mismatch, and it should be possible for the user to modify this easily from the top-level API.

f"folder(s) on Drive (keeping the one with your data) and retry."
)

def _find_or_adopt_versioned_folder(

@koenvanderveen koenvanderveen Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

how do we know this data is compatible?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Adoption renames a folder and does nothing else. Compatibility is enforced per file, downstream. A checkpoint from a newer client is refused, a cache with unknown version starts empty, a versioned object loads through the registry with the frozen protocol history and the drift check, and so on.

Comment thread syft/sync/login_utils.py
"""Handle a client major/minor mismatch at login.

The default is to keep local and remote data. Folder adopt, refuse-later
checks, and cache reset repair state on the next sync. A full wipe is an

@koenvanderveen koenvanderveen Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re: Flow 2.7 — login with a version mismatch
If the answer is "keep everything and repair on the next sync", we should probably just call sync() right there, and print that we are doing it.

Comment thread scripts/bump_version.py
Comment on lines +112 to +113
choices=["bumped", "published"],
default="bumped",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re: Flow 2.8 — scripts/bump_version.py --dependents published
I wonder if we need the old version (bumped), we can probably remove that

Comment on lines +1496 to +1499
execute_with_retries(
self.drive_service.files().update(
fileId=folder_id, body={"name": current_name}
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

if we do _find_or_adopt_versioned_folder and we upgrade, how do we know that the renamed folder is in the right format?

return [json.loads(uncompress_data(raw))["version"] for raw in raw_list]


def test_a_v2_proposal_for_a_protocol0_peer_downgrades_on_the_wire(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

we probably need a migration test with encryption on as well

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agree. Will add it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants