Skip to content

fix: V1 import no longer fails on graphs with 1000+ same-named attachments - #960

Open
maccman wants to merge 4 commits into
masterfrom
fix/import-asset-name-collisions
Open

fix: V1 import no longer fails on graphs with 1000+ same-named attachments#960
maccman wants to merge 4 commits into
masterfrom
fix/import-asset-name-collisions

Conversation

@maccman

@maccman maccman commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Problem

A user importing a multi-year Reflect V1 graph hit a hard import failure:

Import failed — no free asset name after 1000 probes for image.png

Every image pasted into Reflect V1 uploads under the clipboard's generic filename, image.png, and Firebase serves that name back via Content-Disposition. The import names downloaded attachments after that original filename, and its collision probing only tried sequential suffixes: image.png, image-2.png, … image-1000.png. A graph with more than 1000 distinct pasted screenshots exhausts the cap on the 1001st one, and plan_asset_name aborts the entire import — deterministically, so retries and restarts can never help (which is exactly what the reporter saw).

The same 1000-probe cliff exists in persist_unique, the intake path behind in-app paste/drop uploads and file-picker imports, so a graph dense with one filename would eventually break ordinary pasting too.

Before → After

Scenario Before After
V1 import with ≤ ~8 same-named attachments image.png, image-2.png, … Unchanged
V1 import with 1000+ distinct image.png attachments Whole import aborts with "no free asset name after 1000 probes" Each asset lands as image-<8-hex-sha256>.png after the readable suffixes fill up
Re-importing the same export Reuses files found during sequential probing Same, and digest names re-derive deterministically from content, so reuse still short-circuits
Paste into a graph dense with one stem Up to 1000 rename syscalls, then a hard error Jumps to a digest candidate after 8 sequential probes

Changes

  1. fs/assets.rs — new NameCandidates prober shared by both intake paths. Candidate sequence: the desired name, then readable -2-8 suffixes (SEQUENTIAL_NAME_PROBES = 8), then names carrying the first 8 hex chars of the file's sha256 (image-3f9ab2c1.png, image-3f9ab2c1-2.png, …). The digest is computed lazily — the file is only read once sequential probing runs dry — and cached per sequence. MAX_NAME_PROBES = 1000 remains as a backstop, but digest candidates are effectively unique per distinct file, so real graphs resolve in a couple of probes. persist_unique now iterates NameCandidates; its persist_noclobber claim semantics (collision check and claim, race-free) are unchanged.
  2. fs/import_assets.rsplan_asset_name drops its private copy of the sequential loop and cap and iterates the shared NameCandidates instead. Determinism matters here: planning the same bytes again re-derives the same digest name, which is how a re-import finds the file it wrote last time and marks it reuse instead of duplicating it. The taken-set check, on-disk existence/file_occupied checks, and identical-bytes reuse logic are unchanged.

Digest names are content-derived, not random, on purpose: import retries and re-imports stay idempotent, and two distinct files whose 8-hex prefixes ever collided would simply fall through to the -2 variant of the digest name.

Tests

  • plan_switches_to_digest_names_when_sequential_probes_exhaust (import_assets.rs) — with image.png through image-8.png all taken, the plan lands on an 8-hex digest name; the same bytes re-plan to the same name with reuse: true; different bytes land beside it under their own digest.
  • persist_switches_to_digest_names_when_sequential_probes_exhaust (assets.rs) — the upload path falls over to a digest name once the sequential window is dense on disk, and the persisted bytes are intact.
  • Existing probing/reuse tests (plan_reuses_identical_bytes_and_probes_conflicts, persist_probes_numbered_suffixes_on_collision, …) pass unchanged, pinning that behavior below the 8-suffix window is identical to before.

Verification

  • cargo test --lib in apps/desktop/src-tauri: 330 passed, 0 failed.
  • cargo clippy --lib: clean.

Risk / Rollout

  • No migration and no change to existing files on disk — only the names newly persisted assets receive once a stem already has 8+ collisions.
  • Behavior for the common case (first collision on a name) is byte-for-byte identical, so existing graphs see no difference.
  • A partial import made by a previously failed run is safe to re-run: identical bytes found on disk (or planned in-run) are reused, not duplicated.

🤖 Generated with Claude Code


Note

Low Risk
Scoped to new asset filenames after heavy collisions; first eight suffixes and noclobber/reuse semantics are unchanged, with no migration of existing files.

Overview
Fixes hard import/upload failures when a graph has more than ~1000 distinct files sharing one stem (e.g. thousands of V1 image.png pastes). Collision handling no longer stops at image-1000.png; after eight readable -2-8 suffixes it uses deterministic names from the first 8 hex chars of the file’s SHA256 (image-3f9ab2c1.png, with -2 variants if needed).

fs/assets.rs introduces shared NameCandidates (lazy hash, SEQUENTIAL_NAME_PROBES = 8) and wires persist_unique (paste/drop and file-picker intake) through it.

fs/import_assets.rs drops its duplicate probe loop; plan_asset_name uses the same prober, adds legacy_numbered_reuse_name so re-imports can still reuse older image-9.png-style files after the digest switch, and same_file_bytes compares large files incrementally instead of loading them whole.

Reviewed by Cursor Bugbot for commit 3be517a. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • Improvements
    • Asset imports now use readable numeric suffixes (-2 through -8) for initial filename conflicts.
    • When those suffixes are unavailable, assets receive deterministic, content-based names.
    • Re-importing identical files consistently reuses the existing asset, including legacy numbered filenames.
    • Different files receive distinct names, with reliable handling for large files through incremental processing.
  • Bug Fixes
    • Improved handling of filename conflicts during asset persistence and import.

…ments

Every image pasted into Reflect V1 uploads as image.png, so a multi-year
graph can hold thousands of distinct attachments sharing one filename.
The import's collision probing only tried sequential suffixes
(image-2.png … image-1000.png) and aborted the whole import once the cap
was exhausted: "no free asset name after 1000 probes for image.png".

Collision probing (both the import planner and the upload/import intake
path) now switches to content-digest names (image-3f9ab2c1.png) after
eight sequential probes. Digest candidates are effectively unique per
distinct file, so dense stems resolve in a couple of probes, and they
are deterministic, so re-importing the same export re-derives the same
names and reuses the files already on disk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Asset collision handling now tries the desired name and readable numeric suffixes before using deterministic SHA-256-derived names. Asset persistence and V1 import planning share this logic. Byte comparisons now use incremental reads.

Changes

Asset name collision handling

Layer / File(s) Summary
Candidate generation and persistence
apps/desktop/src-tauri/src/fs/assets.rs
NameCandidates yields the desired name, -2 through -8 suffixes, and lazy digest-based candidates. persist_unique uses this sequence. Tests cover digest fallback and incremental hashing.
Import planning integration
apps/desktop/src-tauri/src/fs/import_assets.rs
plan_asset_name uses NameCandidates, reuses identical legacy numbered files, and reports the shared probe limit. same_file_bytes compares files in 64 KiB reads. Tests cover reuse, distinct digest names, and large files.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ImportPlanner
  participant NameCandidates
  participant AssetStorage
  participant ByteComparator
  ImportPlanner->>NameCandidates: request next asset name
  NameCandidates-->>ImportPlanner: return sequential or digest-based candidate
  ImportPlanner->>AssetStorage: check candidate availability
  AssetStorage->>ByteComparator: compare existing and staged bytes
  ByteComparator-->>ImportPlanner: report identical or different content
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title directly addresses the main change: fixing V1 imports that fail when many attachments share the same filename by introducing collision probing with digest-based fallback names.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/import-asset-name-collisions

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
apps/desktop/src-tauri/src/fs/assets.rs (1)

40-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Doc comment on SEQUENTIAL_NAME_PROBES has a broken sentence.

"while a stem the graph is dense with jumps to digests instead of marching toward the probe cap" reads as missing a word (e.g., "whose graph is dense" or similar). Worth tightening for future readers trying to understand the switch-point rationale.

✏️ Suggested rewording
-/// Sequential `-2`-style probes before candidates switch to content-digest
-/// names. Small on purpose: everyday collisions stay readable, while a stem
-/// the graph is dense with jumps to digests instead of marching toward the
-/// probe cap — a V1 import can carry thousands of pastes all named
-/// `image.png`.
+/// Sequential `-2`-style probes before candidates switch to content-digest
+/// names. Small on purpose: everyday collisions stay readable, while a stem
+/// whose graph is dense with reuse jumps to digests instead of marching
+/// toward the probe cap — a V1 import can carry thousands of pastes all
+/// named `image.png`.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src-tauri/src/fs/assets.rs` around lines 40 - 48, Rewrite the
doc comment for SEQUENTIAL_NAME_PROBES to fix the missing-word sentence and
clearly explain that stems with many collisions switch to content-digest
candidates instead of approaching MAX_NAME_PROBES; leave the constants and
surrounding behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/desktop/src-tauri/src/fs/assets.rs`:
- Around line 40-48: Rewrite the doc comment for SEQUENTIAL_NAME_PROBES to fix
the missing-word sentence and clearly explain that stems with many collisions
switch to content-digest candidates instead of approaching MAX_NAME_PROBES;
leave the constants and surrounding behavior unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e9153fcc-673f-4ad9-88b9-e0e75fddd4e2

📥 Commits

Reviewing files that changed from the base of the PR and between a92725f and 1b10d76.

📒 Files selected for processing (2)
  • apps/desktop/src-tauri/src/fs/assets.rs
  • apps/desktop/src-tauri/src/fs/import_assets.rs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@maccman

maccman commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Do not merge this until I've done more investigation.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes asset import/persist failures in graphs with extreme filename collisions (e.g. thousands of distinct image.png attachments) by switching from long sequential suffix probing to deterministic content-digest-based names after a small readable suffix window.

Changes:

  • Introduces a shared NameCandidates iterator to generate collision-resistant asset name candidates, including an SHA-256 prefix fallback after -2…-8 probes.
  • Updates V1 import asset planning (plan_asset_name) and upload/file-import persistence (persist_unique) to use the shared candidate generator while preserving reuse/idempotency behavior.
  • Adds tests covering digest-name fallback for both the import planning and persistence paths.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
apps/desktop/src-tauri/src/fs/assets.rs Adds NameCandidates and updates persist_unique to fall back to digest-based names after a small sequential window; includes new persistence test.
apps/desktop/src-tauri/src/fs/import_assets.rs Switches plan_asset_name to use NameCandidates and adds a test ensuring deterministic digest naming and reuse behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread apps/desktop/src-tauri/src/fs/assets.rs Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1dfe7f1. Configure here.

Comment thread apps/desktop/src-tauri/src/fs/assets.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/desktop/src-tauri/src/fs/import_assets.rs`:
- Around line 529-534: Update plan_asset_name so each digest candidate is
checked for exact-byte reuse before deciding it is occupied, preserving
immediate reuse when bytes match. After sequential probing completes, invoke
legacy_numbered_reuse_name once regardless of whether all digest candidates were
occupied, and only then select or report exhaustion for a free digest candidate.
- Around line 585-595: Update same_file_bytes to compare files using read_exact
on equal fixed-size buffer slices, iterating until existing_meta.len() bytes are
consumed. Do not return false merely because individual reads have different
lengths; compare each fully read chunk and preserve the true result only after
all bytes match.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cc8cd10a-d7d1-4a7a-9e84-c65e42126be5

📥 Commits

Reviewing files that changed from the base of the PR and between 1dfe7f1 and 3be517a.

📒 Files selected for processing (2)
  • apps/desktop/src-tauri/src/fs/assets.rs
  • apps/desktop/src-tauri/src/fs/import_assets.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/desktop/src-tauri/src/fs/assets.rs

Comment on lines +529 to +534
if probe_count > super::assets::SEQUENTIAL_NAME_PROBES {
if let Some(name) = legacy_numbered_reuse_name(assets_dir, desired, staged, taken)?
{
return Ok(PlannedAssetName { name, reuse: true });
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Run the legacy reuse scan after checking the first digest candidate.

The legacy scan runs only when a digest candidate is free. If all digest candidates exist with different bytes, plan_asset_name returns the exhaustion error even when a legacy numbered file has identical bytes.

Check the current digest candidate for exact-byte reuse first. Then run legacy_numbered_reuse_name once after sequential probing, before selecting a free digest candidate.

Proposed fix
     let mut candidates = super::assets::NameCandidates::new(desired, staged.to_path_buf());
     let mut probe_count = 0;
+    let mut legacy_reuse_checked = false;
     while let Some(candidate) = candidates.next()? {
         probe_count += 1;
         if taken.contains(&candidate) {
             continue;
         }
         let target = assets_dir.join(&candidate);
+        if target.is_file() && same_file_bytes(&target, staged)? {
+            return Ok(PlannedAssetName {
+                name: candidate,
+                reuse: true,
+            });
+        }
+        if !legacy_reuse_checked
+            && probe_count > super::assets::SEQUENTIAL_NAME_PROBES
+        {
+            legacy_reuse_checked = true;
+            if let Some(name) = legacy_numbered_reuse_name(assets_dir, desired, staged, taken)? {
+                return Ok(PlannedAssetName { name, reuse: true });
+            }
+        }
         if !target.exists() && !super::io::file_occupied(&target) {
-            if probe_count > super::assets::SEQUENTIAL_NAME_PROBES {
-                if let Some(name) = legacy_numbered_reuse_name(assets_dir, desired, staged, taken)?
-                {
-                    return Ok(PlannedAssetName { name, reuse: true });
-                }
-            }
             return Ok(PlannedAssetName {
                 name: candidate,
                 reuse: false,
             });
         }
-        if target.is_file() && same_file_bytes(&target, staged)? {
-            return Ok(PlannedAssetName {
-                name: candidate,
-                reuse: true,
-            });
-        }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if probe_count > super::assets::SEQUENTIAL_NAME_PROBES {
if let Some(name) = legacy_numbered_reuse_name(assets_dir, desired, staged, taken)?
{
return Ok(PlannedAssetName { name, reuse: true });
}
}
let mut candidates = super::assets::NameCandidates::new(desired, staged.to_path_buf());
let mut probe_count = 0;
let mut legacy_reuse_checked = false;
while let Some(candidate) = candidates.next()? {
probe_count += 1;
if taken.contains(&candidate) {
continue;
}
let target = assets_dir.join(&candidate);
if target.is_file() && same_file_bytes(&target, staged)? {
return Ok(PlannedAssetName {
name: candidate,
reuse: true,
});
}
if !legacy_reuse_checked
&& probe_count > super::assets::SEQUENTIAL_NAME_PROBES
{
legacy_reuse_checked = true;
if let Some(name) = legacy_numbered_reuse_name(assets_dir, desired, staged, taken)? {
return Ok(PlannedAssetName { name, reuse: true });
}
}
if !target.exists() && !super::io::file_occupied(&target) {
return Ok(PlannedAssetName {
name: candidate,
reuse: false,
});
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src-tauri/src/fs/import_assets.rs` around lines 529 - 534,
Update plan_asset_name so each digest candidate is checked for exact-byte reuse
before deciding it is occupied, preserving immediate reuse when bytes match.
After sequential probing completes, invoke legacy_numbered_reuse_name once
regardless of whether all digest candidates were occupied, and only then select
or report exhaustion for a free digest candidate.

Comment on lines +585 to +595
loop {
let existing_read = existing_file.read(&mut existing_buffer)?;
let staged_read = staged_file.read(&mut staged_buffer)?;
if existing_read != staged_read {
return Ok(false);
}
if existing_read == 0 {
return Ok(true);
}
if existing_buffer[..existing_read] != staged_buffer[..staged_read] {
return Ok(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

curl -fsSL https://doc.rust-lang.org/std/io/trait.Read.html |
  rg -n -C 2 'smaller than the length of the buffer|shorter'

Repository: team-reflect/reflect-open

Length of output: 163


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- target function context ---'
sed -n '530,620p' apps/desktop/src-tauri/src/fs/import_assets.rs

printf '%s\n' '--- related symbols and call sites ---'
rg -n -C 3 'same_file_bytes|existing_meta|existing_buffer|staged_buffer|asset reuse|reuse' apps/desktop/src-tauri/src/fs/import_assets.rs

printf '%s\n' '--- Read::read documentation available locally ---'
rustc --print sysroot 2>/dev/null || true
rg -n -C 3 'smaller than the length of the buffer|short read|shorter than' "$(rustc --print sysroot 2>/dev/null)/share/doc/rust/html/std/io/trait.Read.html" 2>/dev/null || true

Repository: team-reflect/reflect-open

Length of output: 9042


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- Rust Read contract ---'
curl -fsSL https://doc.rust-lang.org/std/io/trait.Read.html |
  rg -n -C 4 'It is not an error|fewer bytes|less than|short read|number of bytes' |
  head -n 80

printf '%s\n' '--- read_exact contract ---'
curl -fsSL https://doc.rust-lang.org/std/io/trait.Read.html |
  rg -n -C 4 'read_exact|UnexpectedEof' |
  head -n 80

printf '%s\n' '--- relevant tests ---'
sed -n '740,885p' apps/desktop/src-tauri/src/fs/import_assets.rs

Repository: team-reflect/reflect-open

Length of output: 33925


Do not treat different short-read lengths as different file contents.

Read::read permits short reads before EOF. Two reads from identical files can return different lengths, causing same_file_bytes to return false before comparing all bytes.

Use read_exact on equal fixed-size slices until existing_meta.len() is consumed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src-tauri/src/fs/import_assets.rs` around lines 585 - 595,
Update same_file_bytes to compare files using read_exact on equal fixed-size
buffer slices, iterating until existing_meta.len() bytes are consumed. Do not
return false merely because individual reads have different lengths; compare
each fully read chunk and preserve the true result only after all bytes match.

@ocavue

ocavue commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

@maccman is this one ready for review?

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.

3 participants