Skip to content

fix(agent-registry-migration): implement transform.duplicateNames = "suffix" - #1984

Open
dgallitelli wants to merge 2 commits into
awslabs:mainfrom
dgallitelli:fix/agent-registry-migration-duplicate-names-suffix
Open

fix(agent-registry-migration): implement transform.duplicateNames = "suffix"#1984
dgallitelli wants to merge 2 commits into
awslabs:mainfrom
dgallitelli:fix/agent-registry-migration-duplicate-names-suffix

Conversation

@dgallitelli

Copy link
Copy Markdown

Issue number: #1983

Concise description of the PR

runtime.transform.duplicateNames is documented (docs/configuration.md, docs/detaileddoc.md) with two modes, "fail" (default) and "suffix", for the case where two source records share a target name+recordVersion — legal under the old bedrock-agentcore schema, illegal as a dedup key under the new agent-registry one. "suffix" was a complete no-op: it was never read by the code that claims target names, so a "suffix"-configured run failed colliding records identically to "fail".

This PR implements "suffix": a colliding record after the first claimant gets a deterministic, suffixed target name (derived from its own source identity — sha256("{accountId}/{region}/{registryId}/{recordId}")[:8]), while its displayName and the id-crosswalk's previewName keep the original name. "fail"'s behavior and error text are unchanged (byte-identical, diffed against main).

Getting this right took two extra rounds past the first pass, because the interesting failure mode isn't "does it work once" — it's "does it stay correct across re-runs and re-orderings":

  • v1 picked the first claimant by staged/arrival order. That's fine within one run, but it means an --incremental run that stages only the previously-suffixed record renames it back onto the base name (silently colliding with the record that already holds it), and a full re-extract that receives records in a different pagination order can rename both records. Both reproduced concretely, with no error surfaced (SUCCEEDED, not even a warning).
  • v2 fixed that by keeping the source of truth in the id-map (an additive names block, backward-compatible with maps that don't have it) and deciding ownership from a total order over source identities before the load loop runs, seeded from every mapping's committed state — not from what happens to be staged this run. This closes both repros, verified by direct re-attempts and by an exhaustive (uncommitted, local-only) sweep over 3375 run/order/subset combinations for a 3-record collision set.
  • v3 closed one more gap found while stress-testing v2: the plan reserved the names records ask for, but not the suffixed names it assigns, so a record that happens to be named exactly another record's suffixed form could still get a different, order-dependent outcome. Fixed by having the plan also reserve assigned suffixed names, with a real permutation test (ClaimingThePlannedName.test_a_name_that_coincides_with_another_records_suffix_resolves_the_same_in_every_order, all 6 orderings of 3 records) — this one is committed, since it's small and fast.

User experience

Before: setting duplicateNames: "suffix" had no observable effect. A colliding record still failed at load with "Rename one of them in the source registry... and re-extract," regardless of the setting.

After: the colliding record loads under a suffixed name (e.g. python-sort-dict-by-value-a1b2c3d4), keeps that identity across incremental runs and re-extracts regardless of ordering, and its displayName stays the original, human-readable name. If the suffixed name is also already taken by a different source record, suffix still fails that record — with an error that now names both identities and both holders, rather than reusing fail mode's text.

Known follow-ups (not fixed in this PR, flagged for visibility rather than left silent)

  • Adding duplicateNames to DEFAULT_TRANSFORM changes the config's replay-fingerprint hash, so an extract staged by a pre-this-PR build can't be --resume-loaded by a post-this-PR build. Correct fail-safe (a re-extract is already the documented remediation for any fingerprint mismatch), just worth knowing at upgrade time.
  • If an operator flips a registry from suffix back to fail after a record has already been migrated under a suffixed name, that record fails with fail mode's stock message ("...loading the second would overwrite the first"), which doesn't quite describe the situation (it already exists, under a distinct name). Not incorrect, just imprecise; the fingerprint change above already forces a re-extract on a mode flip, which limits exposure.
  • write_idmap(names=None) silently omits the persisted block. The one caller in this codebase always passes it correctly, but a future caller that forgets to would silently drop the state that suffix-stability depends on. Worth a follow-up to make it required or merge inside write_idmap instead of at call sites.
  • A pre-existing (not introduced here) narrow window exists where a record can be written under a name an about-to-be-superseded incumbent still holds, for one transient step of a run; confirmed this PR doesn't change that behavior in either mode.

Checklist

  • I have reviewed the contributing guidelines
  • Add your name to CONTRIBUTORS.md
  • Have you checked to ensure there aren't other open Pull Requests for the same update/change?
  • Are you uploading a dataset?
  • Have you documented Introduction, Architecture Diagram, Prerequisites, Usage, Sample Prompts, and Clean Up steps in your example README? (n/a — bug fix in existing tool, no README changes needed)
  • I agree to resolve any issues created for this example in the future.
  • I have performed a self-review of this change
  • Changes have been tested
  • Changes are documented

Acknowledgment

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of the project license.

…suffix"

`runtime.transform.duplicateNames` was documented with two modes but only
ever had one. The setting was read by nothing: it was not in the Python
transform defaults, not in `TransformConfig`, and no code branched on it,
so two source records transforming to the same target `(name,
recordVersion)` failed identically whether it was set to `fail` or
`suffix` -- the second claimant was always refused in
`TargetNameClaims.claim`, and a registry with duplicate preview names
could not be fully migrated at all.

Implementing the mode means answering two separate questions. What name
does a record that cannot keep its own get? And which of the records
wanting one name is the one that has to move?

The first is a pure function of the record: `suffix` moves a record onto
`<name>-<sha256(source claimant id)[:8]>`, where the suffix material is
the canonical `account/region/registry/recordId` identity of the source
record and nothing else -- not the run, the attempt, the batch position
or a counter. Names stay within the target's 255-character bound by
truncating the base, as the transform's sanitisation fallback does.

The second cannot be answered from a per-record view, because it depends
on the other records in the registry and on what earlier runs already
created. Answering it from arrival order would corrupt data: a re-extract
that paginated differently, or an incremental run carrying a different
subset, would rename records that are already in the target registry and
referenced by name. So it is answered before the load loop starts:

* The id map gains a `names` block recording the `name` and
  `recordVersion` each source record was migrated under, beside the
  target recordId it already records. Additive and backward compatible:
  a map written before this reads as "not recorded".
* `plan_target_names` (new, used only in `suffix` mode) seeds ownership
  from that committed state -- including records the current run does not
  stage at all, whose names an incremental run must not hand to something
  else -- then re-reads the staged records and gives each remaining
  contested identity to the lowest canonical claimant id. It runs the
  same `RecordTransformer.transform` the load will run, so a planned name
  cannot drift from the claimed one. A record renamed at source releases
  the identity it no longer holds; a record already holding the suffixed
  form of the name it still comes with keeps it. It also reserves the
  suffixed names it hands out, not only the names records asked for, so a
  third record whose own name happens to be another record's suffixed
  form is not handed it as well.
* `TargetNameClaimPool` carries that plan into the claim as
  `preferred_name`, so the name a record gets is a function of its own
  identity and of committed state, never of when it happened to arrive.
  It also seeds each claim set with the plan as claims already held (in
  `suffix` only), so the guard *enforces* the plan instead of giving a
  contested identity to whichever record reaches it first -- the one case
  where two records are planned onto one identity is the coincidental
  name above, and without this it would be resolved by staged order.
  `TargetNameClaims` stays deliberately narrow: it enforces uniqueness
  over the claims it is given and no longer decides who moves.
* The load stage applies the resolved name to the payload, re-checks it
  against the target request bounds, and records a per-record warning
  naming it. Only the dedup key moves: `displayName` and the crosswalk's
  `previewName` keep the name the source record has, so the record stays
  recognisable.
* The target client's own pre-write backstop deliberately stays in `fail`
  mode: every record a live load writes has already passed through the
  planned claim, so a collision reaching that point means two source
  records really do want one identity, and renaming there -- after the
  lookup that chose create-or-update -- would be a silent overwrite.
* `duplicateNames: "fail"` is added to `DEFAULT_TRANSFORM` and to
  `TransformConfig`, with matching validation on both sides, so a local
  run and a deployed run agree on the default and an unrecognised value
  is refused instead of being read as `fail`.

What this guarantees, and what it does not. Once a record is in the
target registry under a name, every later run leaves it there: the name
is committed state, not a re-derived decision. Among records not yet
migrated, the lowest source identity keeps the shared name, which is a
total order and so gives the same answer for any staged order and any
subset of the registry. `suffix` still reduces rather than removes
collisions: when both the name a record would take and its own suffixed
form are held by other records, that record fails and is reported, with
an error naming both identities.

The default path is untouched, including its error text: a collision
under `fail` still fails that one record, ends the run
`PARTIAL_SUCCESS`, and details the record in the failure report. Nothing
is planned, and no extra pass over the staged records is made, unless
`suffix` is set.

Tests, all of which fail on the code before this change:

* `WhichRecordKeepsASharedName` in test_load_guards.py -- lowest identity
  wins, the plan is identical for either staged order, a record already
  in the registry keeps its name over a lower identity, a name held by a
  record this run does not stage is not handed out, a suffixed record
  staged alone does not take the base name back, a map with no recorded
  names credits a known record with its own name, a record renamed at
  source releases what it held, the suffixed name a record is moved onto
  is reserved for it, and a record whose own name is another record's
  suffixed form keeps it.
* `ClaimingThePlannedName` in test_load_guards.py -- the planned owner
  keeps the name even when it claims second, an established suffixed name
  is honoured, a stale one is not, the plan is ignored under `fail`, the
  error when both names are taken names both, a name coinciding with
  another record's suffixed form resolves identically in all six staged
  orders of the three records, and there is no suffixed form of an empty
  name.
* `DuplicateNameHandling` in test_load_guards.py -- deterministic suffix
  across attempts, re-claim stability, 255-character bound, distinct
  `recordVersion` still not a collision.
* test_jobs_end_to_end.py -- both colliding records created under
  distinct names with `previewName`/`displayName` preserved and a re-run
  that creates nothing; a suffixed record keeps its name when a later
  incremental run stages only it; which record keeps a shared name does
  not change when a re-extract stages them in the other order; a dry run
  predicts the same names whatever order it reads.
* `WhatNameEachRecordWasMigratedUnder` in test_watermark.py -- the
  `names` block round-trips with its `recordVersion`, merges additively,
  tolerates individually unusable entries and a map written without it,
  and rejects a non-object `names`.
* `DuplicateNameModes` in test_settings.py -- the new validation.

Docs: documented how `suffix` chooses which record is renamed, including
the three consequences an operator can be surprised by (the renamed
record is not necessarily "the second one", `suffix` can still fail, and
a record named like another record's suffixed form keeps its own name);
corrected the `duplicateNames` reference entry and three troubleshooting
rows that described an extract-stage duplicate-name guard and an
extract-summary field that do not exist.

Fixes awslabs#1983

Signed-off-by: Davide Gallitelli <davidegallitelli@gmail.com>
@dgallitelli

Copy link
Copy Markdown
Author

I don't have permission to apply the Review Ready label myself (external contributor) — could a maintainer add it? This has been through two rounds of adversarial review (including independent reproduction of the bugs found in each round) plus direct re-verification of the full test suite, ruff, and the new permutation tests before opening. Happy to address any further feedback.

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