Skip to content

fix(storage): prevent concurrent chunk finalization - #8

Open
anurag6569201 wants to merge 1 commit into
qa/agent-appwrite-appwrite/pr-08-13505/basefrom
qa/agent-appwrite-appwrite/pr-08-13505/head
Open

anurag6569201 wants to merge 1 commit into
qa/agent-appwrite-appwrite/pr-08-13505/basefrom
qa/agent-appwrite-appwrite/pr-08-13505/head

Conversation

@anurag6569201

Copy link
Copy Markdown

What does this PR do?

Fixes the upload race behind StorageCustomServerTest::testCreateBucketFileParallelChunksLargeFile in the linked CI job. This is a server bug, not a test timeout.

Utopia\Storage\Device::upload() can finalize an upload itself. Previously Appwrite locked preparation and document completion separately but called upload() outside either lock. Concurrent Local uploads could both enter joinChunks(), then unlink parts another request was still reading. CI reported Failed to open chunk ...part.3 and HTTP 500; cleanup also failed afterward.

  • Hold the existing per-file lock across preparation, transfer, and document completion, including the adapter's implicit finalization.
  • Keep completed-upload replays, event suppression, and the existing contention timeout/429 response.
  • Extend the E2E regression with concurrent duplicate chunks, distinct content blocks, response-count checks, and a completed-upload replay followed by download/hash verification. HTTP 500s are not retried or accepted.

Transfers to the storage device for the same file now serialize. Different files retain independent locks. This avoids an upstream storage API change; it can increase same-file contention. The existing 600-second lock lease is unchanged and is not automatically renewed.

Test Plan

Validated in an isolated Local-storage/PostgreSQL dedicated stack with the repository's locked Composer dependencies, PHP 8.5.9, and Swoole:

  • Mounted unchanged main's Create.php into the same stack and ran the enhanced regression: the duplicate-chunk dataset failed with HTTP 500 and Failed to open chunk ...part.2, reproducing the CI failure mechanism.
  • Restored the fix and ran both concurrency datasets 20 times: 40 tests, 1,320 assertions, all passed.
  • Full Storage PHPUnit suite: 93 tests, 1,508 assertions, one antivirus-only skip.
  • CI-style ParaTest: vendor/bin/paratest --processes 4 --functional tests/e2e/Services/Storage --exclude-group abuseEnabled --exclude-group screenshots --exclude-group antivirus: 92 tests, 1,600 assertions, all passed.
  • composer lint for both changed files, php -l for both files, and git diff --check: passed.
  • Independent read-only review: no blockers.

S3, shared-table mode, and antivirus integration were not exercised locally.

Related PRs and Issues

Checklist

  • Read the Contributing Guidelines and AGENTS.md.
  • No API metadata/spec/example changes are needed.

Source merge-base: bd66764e20aee7a8622bc9ee1de052bca3bf07ce
Source head: a0a154792cb4fe893ce05e9e5be937d792461550

@shipwright-agent

Copy link
Copy Markdown

⛔ Shipwright · Blocked

Recommendation: do not merge PR #8 · Tier T2
Checks: 0 total · 0 needing attention

Next step: resolve the blocking findings before merge.

Findings (7)

  • CRITICAL The lock is refreshed before upload but not before finalizeUpload. · src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php:540
    • Fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.
  • CRITICAL The duplicate-chunk test path sends the same chunk twice concurrently. · tests/e2e/Services/Storage/StorageBase.php:1915
    • Fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.
  • CRITICAL The lock token is interpolated into an exception message: 'throw new LockContention('Upload lease lost before transfer: ' . · src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php:536
    • Fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.
  • HIGH The LOCK_TTL constant is 600 seconds, but the lock timeout passed to $locks() is 120.0 seconds. · src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php:52
    • Fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.
  • HIGH The test provider name 'duplicate chunks' is misleading. · tests/e2e/Services/Storage/StorageBase.php:1833
    • Fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.
  • HIGH The retry test reuses '$requests[0]['headers']' and '$requests[0]['chunkPath']' to construct a late retry. · tests/e2e/Services/Storage/StorageBase.php:1993
    • Fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.
  • LOW The lock callback now passes the Distributed lock object to the callback, but the callback signature in Create.php is typed as 'function (Distributed $lock)'. · app/init/resources.php:309
    • Fix: Fix the review finding before release.

Fireworks usage: 12,483 input · 1,013 output · 13,496 total tokens · $0.0034 · 16s · 0 fix iteration(s)

Open the Shipwright check for full evidence and the audit bundle. Use /shipwright rerun to verify again.

throw new LockContention('Upload lease lost before transfer: ' . $lock->token());
}

$chunksUploaded = $deviceForFiles->upload(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shipwright · CRITICAL

The lock is refreshed before upload but not before finalizeUpload.

Impact: The lock is refreshed before upload but not before finalizeUpload. After a long transfer, the lease can expire between the isHeld() check and finalizeUpload's document write, allowing two requests to finalize concurrently and corrupt chunk accounting. The isHeld() check is TOCTOU: it verifies ownership, then finalizeUpload performs multiple non-atomic DB operations without re-verifying or refreshing the lease.

Suggested fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.

}
fclose($sourceHandle);

if ($duplicate) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shipwright · CRITICAL

The duplicate-chunk test path sends the same chunk twice concurrently.

Impact: The duplicate-chunk test path sends the same chunk twice concurrently. If both requests pass prepareUpload before either finalizes, both will call deviceForFiles->upload() with the same chunk. Depending on the device implementation, this can double-count chunksUploaded or corrupt the multipart upload state. The lock serializes the critical section, but the second request re-reads the file document inside prepareU…

Suggested fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.


// Restart the lease so the transfer gets the full window,
// regardless of how long preparation took.
if (!$lock->refresh()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shipwright · CRITICAL

The lock token is interpolated into an exception message: 'throw new LockContention('Upload lease lost before transfer: ' .

Impact: The lock token is interpolated into an exception message: 'throw new LockContention('Upload lease lost before transfer: ' . $lock->token())'. If the token is sensitive (e.g., a Redis lock token that could be used to release or refresh the lock), leaking it in error responses or logs enables an attacker to hijack the lock. The exception is caught and converted to a generic rate-limit error, but the original messag…

Suggested fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.

use HTTP;

/**
* Lease for the per-file upload lock, in seconds. Refreshed before the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shipwright · HIGH

The LOCK_TTL constant is 600 seconds, but the lock timeout passed to $locks() is 120.0 seconds.

Impact: The LOCK_TTL constant is 600 seconds, but the lock timeout passed to $locks() is 120.0 seconds. A reader must understand the difference between TTL (lease duration) and timeout (wait time to acquire) to know why these differ. The comment explains the TTL but not the timeout, and the magic number 120.0 appears twice without explanation.

Suggested fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.

}

public function testCreateBucketFileParallelChunksLargeFile(): void
public static function parallelChunksProvider(): array

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shipwright · HIGH

The test provider name 'duplicate chunks' is misleading.

Impact: The test provider name 'duplicate chunks' is misleading. The test does not verify that duplicate chunks are handled correctly; it sends duplicate requests and asserts all return 200/201. A new hire reading this test would assume duplicate chunk handling is validated, but the assertions only check response codes and final state, not that the duplicate was deduplicated or rejected.

Suggested fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.

$this->assertEquals($chunksTotal, $uploadedFile['body']['chunksTotal']);
$this->assertEquals($chunksTotal, $uploadedFile['body']['chunksUploaded']);

// A late retry must return the completed file without writing or finalizing again.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shipwright · HIGH

The retry test reuses '$requests[0]['headers']' and '$requests[0]['chunkPath']' to construct a late retry.

Impact: The retry test reuses '$requests[0]['headers']' and '$requests[0]['chunkPath']' to construct a late retry. If the original request contained a one-time upload token or session-bound header, this test masks a real-world issue where retries with stale credentials would fail. More importantly, the test asserts the retry returns 200 with the same signature, but does not verify the retry did not re-trigger finalization s…

Suggested fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.

Comment thread app/init/resources.php

$container->set('locks', fn (Group $pools) => fn (string $key, int $ttl, callable $callback, float $timeout = 0.0): mixed => $pools->get('lock')->use(
fn (\Redis $redis) => (new Distributed($redis, $key, ttl: $ttl))->withLock($callback, timeout: $timeout)
function (\Redis $redis) use ($key, $ttl, $callback, $timeout): mixed {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shipwright · LOW

The lock callback now passes the Distributed lock object to the callback, but the callback signature in Create.php is typed as 'function (Distributed $lock)'.

Impact: The lock callback now passes the Distributed lock object to the callback, but the callback signature in Create.php is typed as 'function (Distributed $lock)'. The container closure in resources.php invokes '$callback($lock)', which is correct. However, the 'withLock' callback is 'fn () => $callback($lock)', and 'withLock' may invoke the callback with no arguments; this is fine. No defect here.

Suggested fix: Fix the review finding before release.

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