Skip to content

PE-9203: Sync reads a single gateway, and stops letting one slow item block four - #2179

Merged
vilenarios merged 6 commits into
devfrom
PE-9203-single-gateway-sync
Aug 11, 2026
Merged

PE-9203: Sync reads a single gateway, and stops letting one slow item block four#2179
vilenarios merged 6 commits into
devfrom
PE-9203-single-gateway-sync

Conversation

@vilenarios

@vilenarios vilenarios commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Sync fetched every metadata item through the multi-gateway waterfall — configured gateway, up to two GAR gateways, then arweave.net, serially, 5s each. A flaky gateway made each item cost 20s, and a sync reads hundreds. Building that gateway list also cost a Solana RPC.

What changed

Sync reads one gateway. Two attempts at 5s, then skip. A 404 isn't retried — the same host won't change its mind. 20s → 10.3s per failing item, and the Solana RPC is gone from the per-item path.

The bigger win was scheduling. The concurrency limiter was a barrier, not a pool:

for (start = 0; start < items; start += 5)
  await Future.wait(...five items...);   // waits for ALL five

Each batch waited for its slowest member, so one 10s failure idled four workers for it. A sliding window keeps five in flight continuously.

300 items, 10% failing wall clock
chunked barrier 321.2s
sliding-window pool 82.9s
3.9× faster

Both schedulers produce byte-identical positional output — entityDatas is index-filled, and there's a test for out-of-order completion.

Concurrency stays at 5. The pool is the structural fix; raising the number is an independent knob. Turning both at once — while also removing the fallback — would raise sustained pressure on a single gateway at exactly the moment we removed the thing that absorbs a 429. One knob, measure, then the other.

Snapshot validation loses its GAR branch. It already fast-failed on 404; the remaining path existed only for transient errors, at the cost of that same RPC.

The waterfall is untouched everywhere else — downloads, previews, manifests, shared links, drive attach. A recipient with one dead gateway still gets their file. Caller audit is in the commit message.

The thing this PR found

Sync already drops files silently, today, on dev. A failed metadata read returns Uint8List(0) rather than an error; those empty bytes fail to parse; the parse exception is logged as a warning; the entity never reaches the drive — and the watermark advances regardless. The block-height rewind (kBlockHeightLookBack = 240) is a ~2 hour recency window, not a retry mechanism, and _lastSync is in-memory, so anything older than that is gone until a manual deep sync.

Skipped transaction ids and their drive ids now ride out on the sync result instead of vanishing. docs/SYNC_SKIPPED_ENTITY_PERSISTENCE.md sets out the fix — a table, a block-height range union, a backoff.

No skipped-files UI here, deliberately

Per-file state can't be coherent while the list lives only in memory: rows would appear after one sync and vanish on restart, which is worse than none. It lands with the persistence that makes it stable. Worth knowing for the design — the GraphQL tags give us entity type, file id and parent folder id, but not the name, size or dataTxId, since those live in the body we failed to read. So the row can sit in the right folder, but it can't be named or downloaded.

Verification

flutter analyze clean · 755 tests passing · 22 new, including verifyNever(getGateways) on the sync path and proof the waterfall paths still consult it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UpkQ6gRPTUuy3MCUFxFw2P

Summary by CodeRabbit

  • New Features

    • Sync results now report entities whose metadata could not be retrieved, including counts and transaction details by drive.
    • Metadata requests use bounded concurrency and targeted retries to improve synchronization reliability.
    • Snapshot validation checks the configured gateway directly with clearer failure handling.
  • Bug Fixes

    • Failed metadata reads are skipped instead of being treated as empty entity data.
  • Documentation

    • Added design documentation for tracking and recovering skipped entities.

…our PE-9203

Sync fetched every metadata item through the multi-gateway waterfall: the
configured gateway, up to two GAR gateways, then arweave.net, serially, five
seconds each. A flaky gateway turned each item into twenty seconds, and a sync
reads hundreds of them. Building that list also cost a Solana RPC call.

Sync now reads the configured gateway only - two attempts, five seconds each,
then the item is skipped. A 404 is not retried, because the same host will not
change its mind. Twenty seconds becomes ten, and the Solana RPC is gone from
the per-item path entirely.

The waterfall is untouched everywhere else. Downloads, previews, manifests,
shared links and drive attach keep it, and a recipient with one dead gateway
still gets their file.

The larger win is scheduling. The concurrency limiter was a barrier, not a
pool:

    for (start = 0; start < items; start += 5)
      await Future.wait(...five items...)

Each batch waited for its slowest member, so one ten-second failure left four
workers idle for it. A sliding window keeps five in flight at all times.
Measured over 300 items with 10% failing: 321s to 83s, 3.9x. Concurrency
itself is unchanged at five - the pool is the structural fix, and raising the
number is a separate knob best turned with evidence, not at the same moment we
removed the fallback.

Snapshot validation loses its GAR branch too; it already fast-failed on 404,
and the remaining path existed only for transient errors at the cost of that
same RPC.

What this PR found is worth more than what it changes: **sync already drops
files silently, today.** A failed metadata read returns empty bytes rather
than an error (`arweave_service.dart`), those bytes fail to parse, the parse
exception is logged as a warning, and the entity never reaches the drive -
while the watermark advances regardless. The block-height rewind is a two hour
recency window, not a retry mechanism, so anything older is gone until a
manual deep sync. Skipped transaction ids and their drive ids are now carried
out on the sync result instead of vanishing, and
`docs/SYNC_SKIPPED_ENTITY_PERSISTENCE.md` sets out the fix: a table, a
block-height range union, and a backoff.

No UI for skipped items in this PR. Per-file state cannot be coherent while
the list lives only in memory - rows would appear and vanish across restarts -
so it lands with the persistence that makes it stable.

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

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@vilenarios, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e6add3e1-b708-4a65-8139-321014eeb692

📥 Commits

Reviewing files that changed from the base of the PR and between 735c9b2 and 84649a9.

📒 Files selected for processing (1)
  • lib/sync/data/snapshot_validation_service.dart
📝 Walkthrough

Walkthrough

The sync flow now bounds metadata requests, retries through the configured gateway, records skipped transaction IDs, and reports them by drive through sync progress and completion states. Snapshot validation no longer uses ArioSDK or GAR fallback. A design note proposes persistent skipped-entity tracking.

Changes

Skipped Entity Sync Reporting

Layer / File(s) Summary
Bounded sync metadata fetching
lib/services/arweave/arweave_service.dart, lib/services/arweave/data_gateway_fallback.dart, test/services/arweave/data_gateway_fallback_test.dart
Metadata reads use a sliding-window worker pool and a sync-specific gateway retry path. Failed reads return null and retain transaction IDs. Non-sync fallback behavior remains unchanged.
Skipped entity progress reporting
lib/sync/domain/repositories/sync_repository.dart, lib/sync/domain/sync_progress.dart, lib/sync/domain/cubit/*, test/sync/domain/sync_progress_skipped_entities_test.dart
SyncRepository tracks skipped transaction IDs by drive. SyncProgress and SyncCompleteWithErrors expose counts and IDs. SyncCubit retains the latest values for full and single-drive syncs.
Gateway-only snapshot validation
lib/sync/data/snapshot_validation_service.dart, lib/main.dart, test/sync/data/snapshot_validation_service_test.dart
SnapshotValidationService no longer requires ArioSDK or GAR fallback. It validates snapshots through the configured gateway only.
Persistent skipped-entity design
docs/SYNC_SKIPPED_ENTITY_PERSISTENCE.md
The design note proposes a sync_failed_entities table with retry state, terminal failures, block-height queries, and record lifecycle handling.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SyncRepository
  participant ArweaveService
  participant DataGatewayFallback
  participant SyncCubit
  SyncRepository->>ArweaveService: Fetch drive entity metadata
  ArweaveService->>DataGatewayFallback: fetchDataForSync(txId)
  DataGatewayFallback-->>ArweaveService: Return metadata or failure
  ArweaveService-->>SyncRepository: Return history with skippedTxIds
  SyncRepository-->>SyncCubit: Return SyncProgress with skipped IDs by drive
  SyncCubit-->>SyncCubit: Emit SyncCompleteWithErrors metadata
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 title clearly summarizes the main sync gateway and concurrency changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch PE-9203-single-gateway-sync

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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.

Actionable comments posted: 3

🤖 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 `@lib/services/arweave/arweave_service.dart`:
- Around line 553-565: Convert the clamped concurrency values to integers by
appending `.toInt()` to both `maxConcurrentDataFetches.clamp(1, 100)`
expressions. Update the value passed to `runPooled` and the second value used
with `b +=`, `skip`, and `take`, without changing the existing bounds.

In `@test/services/arweave/data_gateway_fallback_test.dart`:
- Around line 23-35: Reset DataGatewayFallback.cachedGateways in setUp before
constructing fallback so every test starts with an empty gateway cache. Preserve
the existing mock setup and ensure each test independently exercises
arioSDK.getGateways() without relying on execution order.

In `@test/sync/data/snapshot_validation_service_test.dart`:
- Around line 40-50: Update the test around
SnapshotValidationService.validateSnapshotItems to pass a nonempty SnapshotItem
representing an unreachable configured gateway, then assert the returned
verified list is empty. Remove the vacuous _MockArioSDK interaction assertions,
since that mock is not injected into the service.
🪄 Autofix

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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9988efb8-b606-4ce3-8c20-645ebd8be313

📥 Commits

Reviewing files that changed from the base of the PR and between 39ac014 and fa15c94.

📒 Files selected for processing (12)
  • docs/SYNC_SKIPPED_ENTITY_PERSISTENCE.md
  • lib/main.dart
  • lib/services/arweave/arweave_service.dart
  • lib/services/arweave/data_gateway_fallback.dart
  • lib/sync/data/snapshot_validation_service.dart
  • lib/sync/domain/cubit/sync_cubit.dart
  • lib/sync/domain/cubit/sync_state.dart
  • lib/sync/domain/repositories/sync_repository.dart
  • lib/sync/domain/sync_progress.dart
  • test/services/arweave/data_gateway_fallback_test.dart
  • test/sync/data/snapshot_validation_service_test.dart
  • test/sync/domain/sync_progress_skipped_entities_test.dart
💤 Files with no reviewable changes (1)
  • lib/main.dart

Comment thread lib/services/arweave/arweave_service.dart
Comment thread test/services/arweave/data_gateway_fallback_test.dart
Comment thread test/sync/data/snapshot_validation_service_test.dart
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

Visit the preview URL for this PR (updated for commit 84649a9):

https://ardrive-web--pr2179-pe-9203-single-gatew-7z9espzg.web.app

(expires Tue, 18 Aug 2026 03:48:32 GMT)

🔥 via Firebase Hosting GitHub Action 🌎

Sign: a224ebaee2f0939e7665e7630e7d3d6cd7d0f8b0

…E-9203

From CodeRabbit. `validateSnapshotItems([])` returns before the loop, so the
test asserted nothing about the path it was named for - no HEAD request, no
rejection. It now passes a snapshot item, so the unreachable configured
gateway is really contacted and really rejected, which is the point where
`dev` would have reached for the GAR list.

The `verifyZeroInteractions` on the SDK stays, but labelled for what it is:
the service is never handed that object, so the compile-time signature above
is what proves the branch is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vilenarios

Copy link
Copy Markdown
Collaborator Author

Went through all three. One was right and is fixed in a384d78e; two I'm rejecting with evidence.

Fixed — vacuous snapshot validation test

Correct, and the sharpest of the three. validateSnapshotItems([]) returns before the loop is entered (snapshot_validation_service.dart:25-27while (remaining.isNotEmpty)), so the test made no HEAD request and exercised nothing it was named for.

It now passes a snapshot item, so the unreachable configured gateway is genuinely contacted and genuinely rejects — visible in the test output as OS Error: The remote computer refused the network connection, which the empty-list version never produced. That is exactly the point where dev would have reached for the GAR list.

I kept verifyZeroInteractions(arioSDK) but labelled it honestly: the service is never handed that SDK, so the assertion is decorative. The compile-time proof is the real one — SnapshotValidationService(configService: ...) only builds because arioSDK is no longer a parameter.

Rejected — .toInt() on the clamped concurrency

clamp returns num on num, but Dart special-cases it: the static type of e.clamp(a, b) is int when e, a and b are all int. maxConcurrentDataFetches is int and the bounds are int literals, so both results are already int.

The proof is that the branch analyzes clean. concurrency: on runPooled is int, and b += / skip / take all require int — if either value were num, those would be compile errors, not lint. Adding .toInt() would be a no-op on an already-int expression.

Rejected — static gateway cache leaking across tests

cachedGateways is an instance field, not static — data_gateway_fallback.dart:51, List<Gateway>? cachedGateways; with no static modifier.

And fallback = DataGatewayFallback(arioSDK: arioSDK) is inside setUp() (data_gateway_fallback_test.dart:35), so every test gets a fresh instance with a fresh null cache. There is no order dependency between the tests at lines 144 and 156.


flutter analyze clean; sync and arweave suites green (67 tests).

`getUniqueUserDriveEntities` is sync's drive-discovery phase, and it called
the waterfall once per drive transaction, in parallel. A user with a dozen
drives opened a dozen fan-outs to GAR gateways on every sync - the exact cost
this change exists to remove, sitting in the one sync path that was left
alone.

It was left alone on the belief that the call is shared with login. It is not:
`getUniqueUserDriveEntities` has exactly one caller in the codebase,
`_SyncRepository.updateUserDrives`. The login path is
`getDriveSignatureForDrive` (`ardrive_auth.dart`), a different method, which
keeps the waterfall and should.

Audited the rest rather than trusting the earlier list: no other waterfall
method - getLatestDriveEntityWithId, getAllFileEntitiesWithId,
getLatestFileEntityWithId, dataFromTxId - is reachable from lib/sync at all.
Drive discovery was the last one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vilenarios

Copy link
Copy Markdown
Collaborator Author

Correction — you were right, and there was still a waterfall in the sync path.

getUniqueUserDriveEntities — sync's drive-discovery phase — was calling fetchData (the full waterfall) once per drive transaction, in parallel:

driveTxs.map((e) => _gatewayFallback.fetchData(e.id, client)...)

So a user with a dozen drives opened a dozen fan-outs to GAR gateways on every sync. That is precisely the cost this PR exists to remove, sitting in the one sync path that was left untouched.

The reason it was left is wrong. It was believed to be shared with login. It is not — getUniqueUserDriveEntities has exactly one caller in the whole codebase, _SyncRepository.updateUserDrives (sync_repository.dart:1294). The login path is getDriveSignatureForDrive (ardrive_auth.dart:323), a different method, which keeps the waterfall and should.

Fixed in 5e99f7ac. I also re-audited the rest by grep rather than trusting the earlier table: getLatestDriveEntityWithId, getAllFileEntitiesWithId, getLatestFileEntityWithId and dataFromTxId are not reachable from lib/sync/ at all. Drive discovery was the last one.

So the claim in the PR description now actually holds: no fallback fan-out and no Solana RPC anywhere in a sync.

flutter analyze clean · 755 tests passing.

Auditing the rest of sync after the drive-discovery miss turned up two more,
both reachable from every sync.

`getTransactionConfirmations` had the identical chunked barrier the metadata
reads had: chunks of GraphQL confirmation queries dispatched
`maxConcurrent` at a time with an `await Future.wait` between batches, so each
batch waited for its slowest query while the other workers sat idle. It runs
on every sync through `_updateTransactionStatuses`. Same pool, same bound.

Drive discovery was unbounded - a `Future.wait` over every drive transaction
at once. That was survivable while it fanned out across several gateways;
now that it reads one, an unbounded burst is aimed entirely at that single
host, and a user with many drives would open every connection simultaneously.
Bounded to the same limit the metadata reads use.

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

@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: 1

🤖 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 `@lib/services/arweave/arweave_service.dart`:
- Around line 915-942: Keep getUniqueUserDriveEntities sync-only by adding a
sync-specific private-drive signature lookup that uses
_gatewayFallback.fetchDataForSync instead of getDriveSignatureForDrive’s
waterfall fetchData path. Use this path when the private-drive key is not
cached, while preserving getDriveSignatureForDrive for non-sync callers; add
coverage for an uncached private-drive key verifying GAR is not contacted.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6b547fee-3a49-461e-8332-4ec827d8bf44

📥 Commits

Reviewing files that changed from the base of the PR and between fa15c94 and 97fa10e.

📒 Files selected for processing (2)
  • lib/services/arweave/arweave_service.dart
  • test/sync/data/snapshot_validation_service_test.dart
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/sync/data/snapshot_validation_service_test.dart

Comment thread lib/services/arweave/arweave_service.dart
From CodeRabbit, and correct. Drive discovery fetches a drive signature for
every private drive whose key is not already in memory, and it did so through
`getDriveSignatureForDrive` - the login path, which uses the waterfall. So the
fan-out was back in the sync path by the side door, one private drive at a
time.

Both readers now share one implementation and differ only in how they fetch:
`getDriveSignatureForDrive` keeps the waterfall for login, where one
unreachable gateway must not cost someone their session, and
`getDriveSignatureForDriveOnSync` reads the configured gateway only.

Worth recording why this survived two audits: both greps that looked for
waterfall callers excluded `arweave_service.dart` itself, and this call is
inside it. An internal caller is invisible to a search that assumes the caller
lives somewhere else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vilenarios

Copy link
Copy Markdown
Collaborator Author

Valid, and fixed in 735c9b26.

Drive discovery fetches a drive signature for every private drive whose key isn't already in memory, and it did so via getDriveSignatureForDrive — the login path, which uses the waterfall. So the fan-out was back in the sync path by the side door, once per private drive.

Both readers now share one implementation and differ only in how they fetch:

  • getDriveSignatureForDrive — waterfall, unchanged. This is ArDriveAuth, where one unreachable gateway must not cost someone their session.
  • getDriveSignatureForDriveOnSync — configured gateway only, used by drive discovery.

Added the coverage you asked for: a test asserting the sync-path signature read never contacts the GAR, alongside the existing pair that proves fetchData still does.

Worth recording why this survived two prior audits. Both greps that hunted for waterfall callers excluded arweave_service.dart itself — and this call site is inside that file. A search that assumes the caller lives elsewhere cannot see an internal one. Two of the three misses in this PR share that root cause.

flutter analyze clean · 756 tests passing.

Sync metadata reads get two attempts, because one blip should not cost a
file. Snapshot validation got one - and failing there is the more expensive
outcome: a rejected snapshot discards its whole block range, which sync then
re-queries over GraphQL. The cheap failure had the retry and the expensive one
did not.

Two attempts now, same budget and the same 300ms gap. Statuses the host will
simply repeat - 400, 401, 402, 403 - still reject on the first answer rather
than spending time to be told twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vilenarios
vilenarios merged commit ff925de into dev Aug 11, 2026
7 checks passed
@vilenarios
vilenarios deleted the PE-9203-single-gateway-sync branch August 11, 2026 04:10
vilenarios added a commit that referenced this pull request Aug 11, 2026
Brings in everything that landed after the branch was cut:

  #2161  parallelize upload preparation
  #2174  read the Turbo free-item cap from /v1/info
  #2175  self-describing share links, authenticated downloads, recipient page
  #2176  vendor pdf.js and add pdfx
  #2177  CLAUDE.md
  #2178  gateway fallback for every fetch, vendored CDN scripts, dead code
  #2179  sync reads a single gateway, pooled instead of chunked

No conflicts. Verified on the merged tree: analyze clean, 1319 app tests,
43 crypto tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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