Skip to content

feat: migrate to utopia-php/storage 3.0#12921

Open
loks0n wants to merge 9 commits into
mainfrom
feat/utopia-storage-3
Open

feat: migrate to utopia-php/storage 3.0#12921
loks0n wants to merge 9 commits into
mainfrom
feat/utopia-storage-3

Conversation

@loks0n

@loks0n loks0n commented Jul 20, 2026

Copy link
Copy Markdown
Member

Summary

Adopts the utopia-php/storage 3.0.0 major release: immutable coroutine-safe devices, enums, a PSR-18 transport with S3-aware retries, typed exceptions, and a smaller honest Device contract.

Changes

  • Device factory (app/init/resources.php): DSN schemes and _APP_STORAGE_DEVICE resolve via DeviceType::tryFrom() (unknown values fall back to Local), ACLs use the Acl enum, and the DOSpaces setHttpVersion(HTTP/1.1) workaround is deleted — the default PSR-18 client already pins HTTP/1.1.
  • Chunked uploads: uploadChunk() is data-based upstream; the three call sites (Files, Sites deployments, Functions deployments) read the bounded HTTP chunk with file_get_contents plus a failure guard.
  • Storage::human() was removed upstream → new Appwrite\Utopia\Bytes::human() helper (12 call sites).
  • Utopia\Storage\Validator\File (the SDK-spec placeholder) was removed upstream → new Appwrite\Utopia\Request\Validator\File (3 endpoints).
  • Health stats unwrap the Telemetry decorator via getDevice() before reading Local-only partition metrics, and report placeholders for non-local devices — previously S3-backed deployments returned nonsense -1 byte values here.
  • getType() comparisons use DeviceType::Local instead of string constants (Messaging worker, antivirus gate).

Singleton devices + tenant scoping

  • getDevice() memoizes one immutable, coroutine-safe device per (root, connection) pair — roots are the storage base constants, so the memo is bounded — and all S3-family devices share one lazy pool of 16 keep-alive cURL connections (borrowed exclusively per request, persisted between borrows, nothing dialled until first use).
  • Project scoping moves from constructor state to the new Appwrite\Utopia\Storage\Tenant decorator, which prefixes paths with the tenant segment and rejects any path resolving outside it — turning per-project isolation into an enforced invariant and closing a gap where S3 paths were never ../-normalized. Project deletion keeps its exact blast radius (the tenant root is the decorator root).
  • Verified by unit tests (scoping, traversal rejection, foreign-tenant rejection, root deletion) and a Swoole coroutine smoke test against MinIO through the full singleton + Tenant + Telemetry stack (40 concurrent coroutines, zero errors, 16 pooled connections created and reused).

Dependencies

  • utopia-php/storage 2.*3.0.0
  • utopia-php/migration 1.17.*1.19.0 (adds storage 3.0 support, utopia-php/migration#209)

Testing

  • Appwrite's PHPStan passes on every changed file against the real 3.0 package
  • Pint passes on all changed files
  • Runtime smoke test through Appwrite's autoloader: chunked upload assembly, enums, Telemetry decorator + getDevice(), S3 construction with the default retry client

🤖 Generated with Claude Code

Adopt the storage 3.0 major release:

- Device factory uses the DeviceType and Acl enums; DSN schemes and the
  _APP_STORAGE_DEVICE env resolve via DeviceType::tryFrom with unknown
  values falling back to Local. The DOSpaces setHttpVersion(HTTP/1.1)
  workaround is gone: the default PSR-18 client already pins HTTP/1.1
- uploadChunk() takes chunk contents instead of a source file path;
  bounded HTTP chunks are read with file_get_contents and a guard
- Storage::human() was removed upstream; replaced by the new
  Appwrite\Utopia\Bytes::human() helper
- The Utopia\Storage\Validator\File SDK-spec placeholder was removed
  upstream; replaced by Appwrite\Utopia\Request\Validator\File
- Health stats unwrap the Telemetry decorator via getDevice() before
  reading Local-only partition metrics, and report placeholders for
  non-local devices instead of nonsense -1 byte values
- getType() comparisons use DeviceType::Local instead of string
  constants

Blocked on utopia-php/migration supporting storage 3.* before the lock
file can resolve.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@loks0n
loks0n marked this pull request as ready for review July 20, 2026 13:33
@greptile-apps

greptile-apps Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR migrates Appwrite storage integration to utopia-php/storage 3.0. The main changes are:

  • Storage devices now use 3.0 enums, immutable shared devices, and a pooled S3 client.
  • Project storage devices are wrapped with a tenant-scoping decorator.
  • Upload endpoints now pass chunk bytes to uploadChunk().
  • Binary upload validators and SDK schema mappers use the new Appwrite file validator.
  • Storage size formatting moved to Appwrite\Utopia\Bytes.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.
  • The binary upload schema paths now recognize the replacement file validator.

Important Files Changed

Filename Overview
app/init/resources.php Migrates storage device construction to storage 3.0 enums, shared immutable devices, and a pooled S3 client.
app/init/resources/request.php Wraps per-project request storage devices in the new tenant-scoping decorator.
app/init/worker/message.php Wraps worker storage devices in the new tenant-scoping decorator.
src/Appwrite/Utopia/Storage/Tenant.php Adds a tenant-scoped storage decorator around shared storage devices.
src/Appwrite/Utopia/Request/Validator/File.php Adds the replacement marker validator for binary file upload parameters.
src/Appwrite/SDK/Specification/Format/OpenAPI3.php Maps the new file validator to multipart binary OpenAPI parameters.
src/Appwrite/GraphQL/Types/Mapper.php Maps the new file validator to the GraphQL input file scalar.
src/Appwrite/Platform/Modules/Storage/Http/Buckets/Files/Create.php Updates storage file uploads to use the new validator and data-based chunk upload call.
src/Appwrite/Platform/Modules/Sites/Http/Deployments/Create.php Updates site deployment uploads to use the new validator and data-based chunk upload call.
src/Appwrite/Platform/Modules/Functions/Http/Deployments/Create.php Updates function deployment uploads to use the new validator and data-based chunk upload call.

Reviews (7): Last reviewed commit: "feat: singleton storage devices with ten..." | Re-trigger Greptile

Comment on lines +32 to +35
public function getType(): string
{
return self::TYPE_STRING;
}

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.

P1 Binary Uploads Become Strings

The upload endpoints now use this replacement validator, but the existing schema mappers only special-case the removed storage validator class for binary multipart fields. When SDK specs are generated for Storage, Functions, or Sites uploads, these parameters can be emitted as plain strings instead of file uploads, so generated clients can send the wrong request shape.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Appwrite/Utopia/Request/Validator/File.php
Line: 32-35

Comment:
**Binary Uploads Become Strings**

The upload endpoints now use this replacement validator, but the existing schema mappers only special-case the removed storage validator class for binary multipart fields. When SDK specs are generated for Storage, Functions, or Sites uploads, these parameters can be emitted as plain strings instead of file uploads, so generated clients can send the wrong request shape.

**Knowledge Base Used:**
- [Functions and Sites: Build and Execution Flow](https://app.greptile.com/appwrite/-/custom-context/knowledge-base/appwrite/appwrite/-/docs/functions-execution.md)
- [Storage, Avatars, and Proxy](https://app.greptile.com/appwrite/-/custom-context/knowledge-base/appwrite/appwrite/-/docs/storage-proxy-avatars.md)

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown

✨ Benchmark results

Comparing main (before) → feat/utopia-storage-3 (after).

Metric Before After Change
🚀 Requests/sec 325.45 297.48 🔴 -8.6%
⏱️ Latency P50 45.8 ms 50.02 ms 🔴 +9.2%
⏱️ Latency P95 151.35 ms 166.35 ms 🔴 +9.9%
Per-scenario breakdown & investigation details

Metrics below reflect the current branch (after). Δ P95 compares against the base.

Scenario P50 (ms) P95 (ms) Requests RPS Δ P95 (ms)
API total 50.02 166.35 18,354 297.48 +15
Account 84.77 207.61 966 16.09 +20.31
TablesDB 47.04 159.06 9,982 163.13 +15.2
Storage 45.4 150.7 4,830 80.62 +9.71
Functions 72.11 197.74 2,576 43.55 +20.96

Top API waits (after)

API request Max wait (ms)
tablesdb.rows.get 571.19
account.name.update 524.69
storage.files.download 523.73
tablesdb.rows.create 515.69
account.get 512.93

loks0n and others added 6 commits July 20, 2026 14:44
The GraphQL type mapper and the OpenAPI generator match file parameters
by validator class. Both referenced the Utopia\Storage\Validator\File
removed in storage 3.0, so file params stopped mapping to the InputFile
scalar and multipart binary schema, breaking every GraphQL storage and
deployment mutation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
S3-family devices share a lazy pool of 16 keep-alive cURL connections
(getStorageClient() in resources.php): concurrent coroutines borrow a
connection exclusively per request, and connections persist between
borrows so the TCP/TLS handshake is paid per connection rather than
per request. The pool is lazy, so runtimes that never touch S3 dial
nothing. Local devices are unaffected.

Verified with a Swoole coroutine smoke test against MinIO through a
Telemetry-wrapped device: 40 concurrent coroutines, zero errors, 16
connections lazily created at the cap and reused.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Devices are immutable per-project value objects (the root is constructor
state), so the process-wide singleton lives at the right layer: the
connection pool. Fold the static pooled client into getDevice() and drop
the separate getStorageClient() helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
getDevice() now memoizes one immutable, coroutine-safe device per
(root, connection) pair — roots are the storage base constants, so the
memo is bounded — and every S3-family device shares one lazy pool of 16
keep-alive connections. Project scoping moves from constructor state to
the new Appwrite\Utopia\Storage\Tenant decorator, which prefixes every
path with the tenant segment and rejects any path resolving outside it.

This turns per-project isolation from a convention into an enforced
invariant: ../ traversal and foreign-tenant paths now throw, closing a
gap where S3 paths were never normalized. Project deletion keeps its
exact blast radius, since the tenant root is the decorator's root.

Verified by unit tests (scoping, traversal rejection, foreign-tenant
rejection, root deletion) and a Swoole coroutine smoke test against
MinIO through the full singleton + Tenant + Telemetry stack.

Co-Authored-By: Claude Fable 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