Skip to content

fix(export): make export clientId collision-safe to prevent concurrent lost-update#10458

Open
davidfirst wants to merge 4 commits into
masterfrom
fix-concurrent-export-clientid-collision
Open

fix(export): make export clientId collision-safe to prevent concurrent lost-update#10458
davidfirst wants to merge 4 commits into
masterfrom
fix-concurrent-export-clientid-collision

Conversation

@davidfirst

Copy link
Copy Markdown
Member

Problem

export's clientId is both the pending-dir name and the cross-client export lock — export-validate's waitIfNeeded queue sorts pending-dir names and lets only the first proceed to validate+persist. It was generated as Date.now().toString(), which isn't collision-safe.

When two exports hit the same remote within the same millisecond (e.g. concurrent CI runners pushing the same lane), they get the same clientId, share one pending-dir, collapse the queue to a single entry, and both validate against the pre-persist state — silently losing one runner's update (no divergence detected, no rebase).

This surfaced as an intermittently-failing e2e test (ci-commands.e2e.ts → "concurrent runners snapping the SAME component"): both runners exported cleanly and one snap was dropped from the lane. The outcome depended purely on OS scheduling of the two processes.

Fix

Append a random suffix to the generated clientId so same-millisecond exports can't collide. The timestamp prefix is kept so the sorted queue still roughly preserves arrival order. --resume is unaffected (it echoes back a user-supplied id; clientId is never parsed numerically).

…t lost-update

The clientId is the pending-dir name and the cross-client export lock
(export-validate's waitIfNeeded queue sorts pending-dir names; only the
first proceeds to validate+persist). A bare Date.now() isn't collision-
safe: two exports to the same remote within the same millisecond get the
same clientId, share one pending-dir, collapse the queue, and both
validate against the pre-persist state, silently losing one update.
Append a random suffix so same-ms exports can't collide, keeping the
timestamp prefix so the sorted queue still preserves arrival order.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Fix export clientId collisions by adding random suffix for concurrent runs
🐞 Bug fix 🕐 10-20 Minutes

Grey Divider

Description

• Make export clientId unique across same-millisecond concurrent exports.
• Prevent pending-dir/lock collisions that can silently drop one runner’s export.
• Preserve queue ordering by keeping timestamp prefix in the clientId.
Diagram

graph TD
  A["Export CLI"] --> B["export.main.runtime.ts"] --> C["clientId: now-rand"] --> D[("Remote pending-dir")] --> E["export-validate.waitIfNeeded"] --> F["validateRemotes"] --> G["persistRemotes"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use UUID v4 (crypto.randomUUID) as clientId
  • ➕ Stronger uniqueness guarantees with well-known semantics
  • ➕ Avoids needing to reason about random-string implementation quality
  • ➖ Loses natural lexicographic time ordering unless prefixed with a timestamp (still solvable)
  • ➖ May require runtime/polyfill considerations depending on Node target
2. Use a monotonic timestamp (hrtime) + pid
  • ➕ Preserves strict ordering and uniqueness within a host process
  • ➕ Easy to debug (contains pid/time)
  • ➖ Doesn’t guarantee uniqueness across multiple machines/containers hitting same remote
  • ➖ More moving parts than timestamp+random for distributed CI
3. Server-side lock/queue not based on directory names
  • ➕ Avoids relying on client-generated identifiers for correctness
  • ➕ Could enforce fairness and correctness centrally
  • ➖ Larger architectural change; higher risk and more code to maintain
  • ➖ Requires server protocol changes and migrations for existing pending-dir semantics

Recommendation: The chosen approach (timestamp prefix + random suffix) is the best minimal-risk fix: it preserves the existing lexicographic queue behavior while eliminating same-millisecond collisions that break locking. A full server-side lock redesign would be more robust but is not warranted for this targeted concurrency bug.

Files changed (1) +9 / -1

Bug fix (1) +9 / -1
export.main.runtime.tsGenerate collision-safe export clientId using timestamp + random suffix +9/-1

Generate collision-safe export clientId using timestamp + random suffix

• Imports 'generateRandomStr' and changes export clientId generation from 'Date.now()' to a timestamp-prefixed, random-suffixed identifier. Adds inline documentation explaining how clientId doubles as the remote pending-dir name and the export-validate cross-client lock, and why uniqueness is required to prevent lost updates during concurrent exports.

scopes/scope/export/export.main.runtime.ts

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 26, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Weak clientId entropy 🐞 Bug ☼ Reliability ⭐ New
Description
clientId now uses ${Date.now()}-${generateRandomStr()}, but generateRandomStr() defaults to 8
hex chars (32 bits), so same-millisecond exports can still collide and reintroduce the
pending-dir/lock-key collision scenario. The comment also claims this “guarantee[s] uniqueness”,
which is not strictly true with a short random suffix.
Code

scopes/scope/export/export.main.runtime.ts[689]

+    const clientId = resumeExportId || `${Date.now()}-${generateRandomStr()}`;
Evidence
pushToRemotesCarefully() builds the new clientId using generateRandomStr() with no size
argument, and generateRandomStr()’s default is 8 hex chars, i.e., 32 bits of entropy; this is
probabilistic and therefore not a strict uniqueness guarantee.

scopes/scope/export/export.main.runtime.ts[680-699]
scopes/toolbox/string/random/generate-random-string.ts[1-9]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`pushToRemotesCarefully()` uses `generateRandomStr()` with its default size (8 hex chars = 32 bits). This makes collisions still possible (though unlikely) for exports occurring within the same millisecond, and the comment currently overstates this as guaranteeing uniqueness.

### Issue Context
This PR’s goal is to prevent concurrent lost-updates caused by `clientId` collisions (pending-dir name + queue/lock key). The fix should make collisions *practically impossible* and keep comments accurate.

### Fix Focus Areas
- scopes/scope/export/export.main.runtime.ts[682-689]
- scopes/toolbox/string/random/generate-random-string.ts[7-9]

### Proposed fix
- Change the call to pass a larger size explicitly, e.g.:
 - `const clientId = resumeExportId || `${Date.now()}-${generateRandomStr(16)}`;`
 (or larger, depending on desired margin).
- Update the adjacent comment to avoid claiming a strict “guarantee” (e.g., “effectively prevents collisions in practice”).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Stale clientId timestamp wording ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
pushToRemotesCarefully() now generates clientId as "-", but other code still describes
clientId as a millisecond timestamp, which is now inaccurate and can confuse
debugging/maintenance. Update the related comments/user-facing error text to reflect that clientId
is an opaque export identifier (timestamp-prefixed, not purely numeric).
Code

scopes/scope/export/export.main.runtime.ts[689]

+    const clientId = resumeExportId || `${Date.now()}-${generateRandomStr()}`;
Evidence
The PR changes clientId from a timestamp string to a timestamp-random string, but
PushOptions.clientId is still documented as “timestamp in ms”, and the ClientIdInUse error
message still frames collisions as “within the exact same millisecond”. These are now inconsistent
with the new clientId format.

scopes/scope/export/export.main.runtime.ts[680-699]
components/legacy/scope-api/lib/put.ts[11-14]
components/legacy/scope/exceptions/client-id-in-use.ts[3-10]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`clientId` is no longer a pure millisecond timestamp; it is now generated as `"<timestamp>-<random>"`. Some comments and an exception message still describe it as a timestamp-only value.
## Issue Context
- `clientId` is used as the pending-dir name and as a queue/lock key; it should be treated as an opaque identifier.
- Align comments and user-facing messages to avoid incorrect assumptions when troubleshooting or extending the export protocol.
## Fix Focus Areas
- scopes/scope/export/export.main.runtime.ts[680-699]
- components/legacy/scope-api/lib/put.ts[11-14]
- components/legacy/scope/exceptions/client-id-in-use.ts[3-10]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

3. Stale clientId timestamp docs ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
pushToRemotesCarefully() now generates clientId as a timestamp-prefixed unique string, but other
code comments and a user-facing error still describe clientId as “timestamp in ms” / “same
millisecond”, which is now misleading and inconsistent.
Code

scopes/scope/export/export.main.runtime.ts[R682-689]

+    // The clientId is both the pending-dir name AND the cross-client export lock: `export-validate`'s
+    // waitIfNeeded queue sorts pending-dir names and lets only the first proceed to validate+persist.
+    // A pure `Date.now()` is not collision-safe — two exports to the same remote within the same
+    // millisecond (e.g. concurrent CI runners pushing the same lane) get the same clientId, share one
+    // pending-dir, collapse the queue to a single entry, and both validate against the pre-persist
+    // state, silently losing one runner's update. A random suffix keeps the timestamp prefix (so the
+    // sorted queue still roughly preserves arrival order) while guaranteeing uniqueness.
+    const clientId = resumeExportId || `${Date.now()}-${generateRandomStr()}`;
Evidence
The PR changes clientId generation to include a random suffix, while other parts of the codebase
still document/error-message it as a millisecond timestamp, creating inconsistency for
developers/users.

scopes/scope/export/export.main.runtime.ts[680-692]
components/legacy/scope-api/lib/put.ts[11-14]
components/legacy/scope/exceptions/client-id-in-use.ts[3-11]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`clientId` is no longer a pure millisecond timestamp (it is now `${Date.now()}-${generateRandomStr()}`), but some comments and an error message still claim it represents a millisecond timestamp / “exact same millisecond”. This is now misleading.
### Issue Context
The new format keeps the timestamp prefix for ordering, but the identifier should be treated as an opaque, unique, filesystem-safe string.
### Fix Focus Areas
- components/legacy/scope-api/lib/put.ts[11-14]
- components/legacy/scope/exceptions/client-id-in-use.ts[3-11]
### Suggested fix
- Update `PushOptions.clientId` comment to something like: “opaque export identifier (timestamp-prefixed unique string used as pending-dir name/lock)”.
- Update `ClientIdInUse` message to refer to “same clientId” (or “same export-id”) rather than “exact same millisecond”.
- (Optional) update any related docs that imply the export-id is strictly numeric.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Previous review results

Review updated until commit 6e7928c

Results up to commit N/A


🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Remediation recommended
1. Stale clientId timestamp wording ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
pushToRemotesCarefully() now generates clientId as "-", but other code still describes
clientId as a millisecond timestamp, which is now inaccurate and can confuse
debugging/maintenance. Update the related comments/user-facing error text to reflect that clientId
is an opaque export identifier (timestamp-prefixed, not purely numeric).
Code

scopes/scope/export/export.main.runtime.ts[689]

+    const clientId = resumeExportId || `${Date.now()}-${generateRandomStr()}`;
Evidence
The PR changes clientId from a timestamp string to a timestamp-random string, but
PushOptions.clientId is still documented as “timestamp in ms”, and the ClientIdInUse error
message still frames collisions as “within the exact same millisecond”. These are now inconsistent
with the new clientId format.

scopes/scope/export/export.main.runtime.ts[680-699]
components/legacy/scope-api/lib/put.ts[11-14]
components/legacy/scope/exceptions/client-id-in-use.ts[3-10]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`clientId` is no longer a pure millisecond timestamp; it is now generated as `"<timestamp>-<random>"`. Some comments and an exception message still describe it as a timestamp-only value.
## Issue Context
- `clientId` is used as the pending-dir name and as a queue/lock key; it should be treated as an opaque identifier.
- Align comments and user-facing messages to avoid incorrect assumptions when troubleshooting or extending the export protocol.
## Fix Focus Areas
- scopes/scope/export/export.main.runtime.ts[680-699]
- components/legacy/scope-api/lib/put.ts[11-14]
- components/legacy/scope/exceptions/client-id-in-use.ts[3-10]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational
2. Stale clientId timestamp docs ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
pushToRemotesCarefully() now generates clientId as a timestamp-prefixed unique string, but other
code comments and a user-facing error still describe clientId as “timestamp in ms” / “same
millisecond”, which is now misleading and inconsistent.
Code

scopes/scope/export/export.main.runtime.ts[R682-689]

+    // The clientId is both the pending-dir name AND the cross-client export lock: `export-validate`'s
+    // waitIfNeeded queue sorts pending-dir names and lets only the first proceed to validate+persist.
+    // A pure `Date.now()` is not collision-safe — two exports to the same remote within the same
+    // millisecond (e.g. concurrent CI runners pushing the same lane) get the same clientId, share one
+    // pending-dir, collapse the queue to a single entry, and both validate against the pre-persist
+    // state, silently losing one runner's update. A random suffix keeps the timestamp prefix (so the
+    // sorted queue still roughly preserves arrival order) while guaranteeing uniqueness.
+    const clientId = resumeExportId || `${Date.now()}-${generateRandomStr()}`;
Evidence
The PR changes clientId generation to include a random suffix, while other parts of the codebase
still document/error-message it as a millisecond timestamp, creating inconsistency for
developers/users.

scopes/scope/export/export.main.runtime.ts[680-692]
components/legacy/scope-api/lib/put.ts[11-14]
components/legacy/scope/exceptions/client-id-in-use.ts[3-11]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`clientId` is no longer a pure millisecond timestamp (it is now `${Date.now()}-${generateRandomStr()}`), but some comments and an error message still claim it represents a millisecond timestamp / “exact same millisecond”. This is now misleading.
### Issue Context
The new format keeps the timestamp prefix for ordering, but the identifier should be treated as an opaque, unique, filesystem-safe string.
### Fix Focus Areas
- components/legacy/scope-api/lib/put.ts[11-14]
- components/legacy/scope/exceptions/client-id-in-use.ts[3-11]
### Suggested fix
- Update `PushOptions.clientId` comment to something like: “opaque export identifier (timestamp-prefixed unique string used as pending-dir name/lock)”.
- Update `ClientIdInUse` message to refer to “same clientId” (or “same export-id”) rather than “exact same millisecond”.
- (Optional) update any related docs that imply the export-id is strictly numeric.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Qodo Logo

…he new unique format

clientId is no longer a pure millisecond timestamp; update the stale
PushOptions.clientId doc and the ClientIdInUse error text accordingly.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 135e445

@davidfirst

Copy link
Copy Markdown
Member Author

Thanks @qodo — both findings were the same point (the now-stale "timestamp in ms" / "exact same millisecond" wording, since clientId is no longer a pure timestamp). Addressed in 135e445:

  • PushOptions.clientId doc → "opaque export identifier (timestamp-prefixed unique string), used as the pending-dir name and queue/lock key."
  • ClientIdInUse message → "another client is already exporting … using the same export id (…)", dropping the millisecond framing.

The re-review now reports Bugs (0). (Findings were posted as issue comments, so there's no inline thread to resolve.)

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Thanks @qodo — both findings were the same point (the now-stale "timestamp in ms" / "exact same millisecond" wording, since clientId is no longer a pure timestamp). Addressed in 135e445:

  • PushOptions.clientId doc → "opaque export identifier (timestamp-prefixed unique string), used as the pending-dir name and queue/lock key."
  • ClientIdInUse message → "another client is already exporting … using the same export id (…)", dropping the millisecond framing.

The re-review now reports Bugs (0). (Findings were posted as issue comments, so there's no inline thread to resolve.)

Thanks — that makes sense.

  • Updating the docs and error text to reflect the new opaque clientId behavior addresses the stale wording.
  • If the re-review is now showing Bugs 0, then there’s nothing left for me to resolve here.
  • And if the findings were only posted as issue comments, that explains why there’s no inline thread to close.

Looks good to me.

@davidfirst davidfirst enabled auto-merge (squash) June 27, 2026 00:43
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 6e7928c

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.

2 participants