Skip to content

refactor: replace AsebytesStorage + StorageRouter with FrameStorage#877

Merged
PythonFZ merged 12 commits intomainfrom
feat/storage-refactor
Mar 19, 2026
Merged

refactor: replace AsebytesStorage + StorageRouter with FrameStorage#877
PythonFZ merged 12 commits intomainfrom
feat/storage-refactor

Conversation

@PythonFZ
Copy link
Copy Markdown
Member

@PythonFZ PythonFZ commented Mar 18, 2026

Summary

  • Replace AsebytesStorage + StorageRouter (two wrapper layers with 6 _backend SLF001 violations) with a single thin FrameStorage registry class
  • All frame I/O now goes through AsyncBlobIO's pandas-like subscript API (io[index], io[start:stop].to_list(), io[b"key"][0], etc.) directly
  • Write guards moved from storage layer to FastAPI dependency (RequireWritableDep)
  • Single FrameStorageDep replaces both StorageDep and StorageRouterDep (fixes broken typing where runtime was StorageRouter but type said AsebytesStorage)

What's deleted

  • AsebytesStorage class, StorageRouter class, storage/router.py
  • StorageDep, StorageRouterDep, get_storage(), get_storage_router()
  • reserve(), remove_items() (dead code — never called from routes)
  • All 6 noqa: SLF001 suppressions (zero _backend access)
  • Redundant to_raw_frame() calls inside storage methods
  • try/except NotImplementedError in update_room

What's new

  • FrameStorage — room-scoped AsyncBlobIO registry + provider frame count metadata (Redis). No read/write wrappers.
  • RequireWritableDep — FastAPI dependency for write guard on mutation endpoints
  • Routes use storage[room_id] to get AsyncBlobIO, then subscript API for all operations

Performance

E2E benchmarks (LMDB backend, 200-500 iterations per test, stdev-aware significance):
Zero regressions — all 16 benchmarks show "same" within noise. Payload reduction of 92.8% for filtered key reads (34KB → 2.5KB).

Test plan

  • 998 tests passing, 0 failures
  • Zero SLF001 violations in storage/routes
  • No old class references (AsebytesStorage, StorageRouter, StorageDep) anywhere
  • E2E performance benchmarks: no regressions (LMDB backend, 200-500 iterations)
  • Provider dispatch tests pass (IndexError handling for provider-backed frames)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Refactor

    • Reworked storage into a room-scoped registry exposing per-room blob IO via a simple subscript API; lifecycle and provider-frame-count operations centralized and Redis-backed.
    • Removed legacy wrapper layers and tightened write-permission checks for provider-backed (read-only) rooms.
  • Tests

    • Added new FrameStorage tests and migrated many existing suites to the new per-room storage API.
  • Documentation

    • Added detailed refactor design and migration plan.

PythonFZ and others added 9 commits March 17, 2026 15:47
Replace AsebytesStorage + StorageRouter with thin FrameStorage registry.
All I/O through AsyncBlobIO public API. Zero _backend access.
Selective key loading at call sites (isosurface, frames, result backend).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add AsyncBlobIO API reference table (row vs column access patterns)
- Use column view API for result backend (io[b"_"][0] not io.get(keys=))
- Document why provider dispatch doesn't need keys passthrough
- Document why reserve()/remove_items() are dead code (provider uses Redis)
- Fix create_room write guard (checks source room, not target)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add design principle: prefer io[...] subscript over method calls
- Document multi-key access: io[[b"k1", b"k2"]][i], .to_list(), .to_dict()
- Expand API reference with column, multi-key, and mutation sections
- Update all examples to use subscript (isosurface, result backend, batch)
- Add "when to use which" decision guide
- Update success criteria to require subscript API throughout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add OOB index handling note (IndexError vs None, routes pre-validate)
- Add explicit create_room before/after (two deps -> one, subscript API)
- Add trajectory.py isinstance removal example
- Add update_room frame_count patch clarification
- Note slice view extra len() call overhead
- Note build_room_update callers must update in same PR

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
14 tasks across 5 chunks: core FrameStorage class, route migrations,
test migrations, cleanup and verification.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
FrameStorage is a thin room-scoped AsyncBlobIO registry with
provider frame count support. No I/O wrappers - routes will use
AsyncBlobIO subscript API directly.

- Create storage/frame_storage.py with FrameStorage class
- Update dependencies.py: FrameStorageDep + RequireWritableDep
- Update database.py: direct FrameStorage init
- Update result_backends.py: io[b"_"][0] column view
- Delete asebytes_backend.py and router.py

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- frames.py: subscript reads/writes, RequireWritableDep on mutations
- isosurface.py: subscript frame load
- trajectory.py: subscript batch reads, remove isinstance check
- rooms.py: single FrameStorageDep, subscript copy/extend
- step.py, server_settings.py, socketio.py: FrameStorageDep swap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace AsebytesStorage/StorageRouter/get_storage with FrameStorage/get_frame_storage
across all test files. Storage calls now use the AsyncBlobIO subscript API
(storage[room_id].extend, storage[room_id][i], etc.) instead of wrapper methods.

- Create tests/test_frame_storage.py (replaces test_storage_asebytes.py)
- Rewrite tests/test_storage_router.py for FrameStorage provider metadata
- Update all integration test fixtures in 7 files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
AsyncBlobIO raises IndexError for OOB indices (unlike old AsebytesStorage
which returned None). Provider-backed rooms have frames declared in Redis
but not yet materialized in storage, so reads at valid provider indices
hit OOB in the actual backend.

Use io.get(index) with try/except IndexError for provider-dispatch paths
in get_frame, get_frame_metadata, and get_isosurface. For list_frames
with sparse indices, pre-filter to in-bounds indices before to_list().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 18, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1ded5b53-d30e-4487-993b-31cedcf6fa3e

📥 Commits

Reviewing files that changed from the base of the PR and between f7a5042 and 93afb3e.

📒 Files selected for processing (1)
  • src/zndraw/routes/trajectory.py

📝 Walkthrough

Walkthrough

Replaces the AsebytesStorage/StorageRouter wrappers with a new FrameStorage registry exposing per-room AsyncBlobIO via a subscript API; updates DI (FrameStorageDep, RequireWritableDep), app lifespan, result backend, routes, and tests; removes old storage modules and adds FrameStorage unit tests.

Changes

Cohort / File(s) Summary
New core storage
src/zndraw/storage/frame_storage.py, src/zndraw/storage/__init__.py
Adds FrameStorage, RawFrame, to_raw_frame, and _create_blob_backend; re-exports FrameStorage API and removes AsebytesStorage/StorageRouter exports.
Removed storage wrappers
src/zndraw/storage/asebytes_backend.py, src/zndraw/storage/router.py
Deletes legacy AsebytesStorage and StorageRouter implementations and their helpers.
Dependencies & app init
src/zndraw/dependencies.py, src/zndraw/database.py
Introduces get_frame_storage, FrameStorageDep, RequireWritableDep/require_writable_room; app lifespan now constructs FrameStorage(uri, redis) and stores it on app.state.
Result backend
src/zndraw/result_backends.py
StorageResultBackend now accepts FrameStorage and uses per-room storage[room_id] IO operations (len, clear, extend, unpack).
Routes (read/write migration)
src/zndraw/routes/...
src/zndraw/routes/frames.py, .../isosurface.py, .../trajectory.py, .../rooms.py, .../step.py, .../server_settings.py, .../socketio.py
Replaces StorageDep with FrameStorageDep; handlers obtain per-room IO via storage[room_id], use IO get/set/extend/delete/to_list patterns, and add RequireWritableDep guards on write endpoints.
Tests & fixtures
tests/*, tests/test_frame_storage.py, tests/test_storage_router.py, (deleted) tests/test_storage_asebytes.py
Refactors tests to use FrameStorage and per-room API; adds tests/test_frame_storage.py; removes tests/test_storage_asebytes.py; updates fixtures and dependency overrides to get_frame_storage.
Test helpers & conftest
tests/conftest.py
Adds frame_storage fixture (FrameStorage backed by memory:// + mock Redis) and updates test DI overrides to get_frame_storage.
Docs / plans
docs/superpowers/specs/..., docs/superpowers/plans/...
Adds design and migration plan documents describing the FrameStorage API, provider frame-count semantics, and migration steps.

Sequence Diagram

sequenceDiagram
    participant Client
    participant RouteHandler
    participant RequireWritableDep
    participant FrameStorageDep
    participant FrameStorage
    participant AsyncBlobIO
    participant Redis

    rect rgba(200,150,255,0.5)
    Note over Client,Redis: Write flow (permission enforced)
    Client->>RouteHandler: POST append_frames(room_id, frames)
    RouteHandler->>RequireWritableDep: require_writable_room(room_id)
    RequireWritableDep->>FrameStorageDep: get_frame_storage(request)
    FrameStorageDep->>FrameStorage: return instance
    RequireWritableDep->>Redis: check provider frame-count key
    alt provider-backed (read-only)
        RequireWritableDep-->>RouteHandler: raise RoomReadOnly
        RouteHandler-->>Client: 409 / RoomReadOnly
    else writable
        RequireWritableDep-->>RouteHandler: allow
        RouteHandler->>FrameStorage: storage[room_id]
        FrameStorage->>AsyncBlobIO: return per-room IO
        RouteHandler->>AsyncBlobIO: .extend(frames)
        AsyncBlobIO-->>RouteHandler: ack
        RouteHandler-->>Client: 200
    end
    end

    rect rgba(150,200,255,0.5)
    Note over Client,AsyncBlobIO: Read flow
    Client->>RouteHandler: GET frame(room_id, index)
    RouteHandler->>FrameStorageDep: get_frame_storage(request)
    FrameStorageDep->>FrameStorage: return instance
    RouteHandler->>FrameStorage: storage[room_id]
    FrameStorage->>AsyncBlobIO: return per-room IO
    RouteHandler->>AsyncBlobIO: [index] or [start:stop]
    AsyncBlobIO-->>RouteHandler: frame(s) or IndexError
    RouteHandler-->>Client: 200 / 404
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hopped through bytes and tidied each nest,

Swapped wrappers for frames so each room can rest.
Redis hums counts while blobs find their place,
Subscript and extend — a neat little race.
I munched old wrappers, then leapt with a cheer!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'refactor: replace AsebytesStorage + StorageRouter with FrameStorage' accurately and concisely describes the primary change—replacing two storage wrapper classes with a single FrameStorage registry.
Docstring Coverage ✅ Passed Docstring coverage is 81.41% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 feat/storage-refactor
📝 Coding Plan
  • Generate coding plan for human review comments

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 and usage tips.

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Mar 18, 2026

Codecov Report

❌ Patch coverage is 95.01312% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.37%. Comparing base (7fa097a) to head (93afb3e).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/zndraw/storage/frame_storage.py 84.50% 11 Missing ⚠️
src/zndraw/routes/frames.py 88.46% 3 Missing ⚠️
src/zndraw/routes/rooms.py 66.66% 3 Missing ⚠️
src/zndraw/routes/isosurface.py 60.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #877      +/-   ##
==========================================
- Coverage   91.48%   91.37%   -0.11%     
==========================================
  Files         180      179       -1     
  Lines       17466    17292     -174     
==========================================
- Hits        15978    15800     -178     
- Misses       1488     1492       +4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/zndraw/result_backends.py (1)

99-137: ⚠️ Potential issue | 🟠 Major

Don't memoize provider-result hashes in FrameStorage.

Lines 120, 128, and 137 route arbitrary provider cache keys through FrameStorage.__getitem__(), which memoizes every key in _rooms. Because src/zndraw/database.py now shares one app-wide registry here, even cache misses leave an AsyncBlobIO handle behind until shutdown. With hashed provider-result keys, normal cache churn turns into unbounded registry growth. Use an ephemeral accessor for result-cache I/O, or add a public discard()/open_ephemeral() path and call it after each operation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/zndraw/result_backends.py` around lines 99 - 137, The code currently
calls FrameStorage.__getitem__ via self._storage[key] in store, get, and delete
which memoizes keys in FrameStorage._rooms and leaks AsyncBlobIO handles;
replace those direct __getitem__ uses with a non-memoizing ephemeral accessor
(e.g. self._storage.open_ephemeral(key)) or, if such API doesn't exist, add and
call a public open_ephemeral()/discard() pair on FrameStorage so each operation
opens an ephemeral AsyncBlobIO, uses it (clear/extend/len/__getitem__), and then
disposes/discards the handle; update the store, get, and delete methods to use
that ephemeral open/discard pattern (or call discard immediately after each
operation) instead of keeping references via __getitem__.
🧹 Nitpick comments (3)
src/zndraw/database.py (1)

238-243: Hoist FrameStorage to the module import block.

Line 239 runs during every lifespan startup and doesn't look like a lazy-load path. Moving it next to the other imports keeps the dependency graph easier to follow.

As per coding guidelines, "Import statements at top of file unless they affect startup time (lazy load)".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/zndraw/database.py` around lines 238 - 243, The FrameStorage import and
instantiation should be moved out of the lifespan block: add "from
zndraw.storage import FrameStorage" to the module-level imports and initialize
app.state.frame_storage where app startup config is assembled (using
settings.storage and app.state.redis) rather than inside the lifespan startup
code; update or remove the local import at the former site and ensure the
module-level import is used when creating app.state.frame_storage (referencing
FrameStorage, app.state.frame_storage, settings.storage, and app.state.redis).
tests/test_trajectory.py (1)

62-73: Move AsyncMock import to module top-level.

The function-local import at line 65 violates the guideline that import statements should be at the top of the file unless they affect startup time. AsyncMock has no startup-time reason to be lazy-loaded.

♻️ Proposed cleanup
 import io
 from collections.abc import AsyncIterator
 from typing import Any
+from unittest.mock import AsyncMock
@@
 async def traj_storage_fixture() -> AsyncIterator[FrameStorage]:
     """Create a fresh FrameStorage instance for each test."""
-    from unittest.mock import AsyncMock
-
     mock_redis = AsyncMock()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_trajectory.py` around lines 62 - 73, The local import of AsyncMock
inside traj_storage_fixture should be moved to the module top-level: add "from
unittest.mock import AsyncMock" at the top of the test module and remove the
function-local import; leave the rest of the fixture (mock_redis setup and
FrameStorage("memory://", mock_redis) yield) unchanged so traj_storage_fixture
continues to return the same AsyncMock-based mock Redis instance.
docs/superpowers/plans/2026-03-18-storage-refactor.md (1)

914-922: Minor documentation inconsistency with actual implementation.

The plan suggests replacing storage.reserve(room.id, 3) with storage.set_frame_count(room.id, 3), but the actual test implementation in test_frames_provider_dispatch.py uses storage[room.id].reserve(3) (calling AsyncBlobIO.reserve() directly). Both approaches are valid for simulating provider-backed rooms with empty slots, but the plan doesn't match the implemented approach.

This is informational only — the implementation is correct.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/superpowers/plans/2026-03-18-storage-refactor.md` around lines 914 -
922, The docs refer to using reserve() to simulate provider mounts but the
implementation and tests use AsyncBlobIO.reserve via
storage[room.id].reserve(3); update the plan text to match the implementation by
replacing the example that calls reserve() with the set_frame_count API (use
set_frame_count(room.id, 3)) or, if you prefer to keep the test pattern,
explicitly note that storage[room.id].reserve(3) (AsyncBlobIO.reserve) is an
equivalent valid approach and show that symbol as an acceptable alternative;
ensure you reference both set_frame_count() and
AsyncBlobIO.reserve/storage[room.id].reserve in the updated paragraph so the
plan matches the code in test_frames_provider_dispatch.py.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/zndraw/storage/frame_storage.py`:
- Around line 20-31: The to_raw_frame function currently decodes every str value
as base64; change it to only base64-decode values that are explicitly marked
(e.g., keys that start with "b64:"), leave unmarked strings unchanged (convert
plain str values to bytes via UTF-8 or keep them as non-encoded metadata per
RawFrame contract), and normalize keys to bytes; specifically update the logic
in to_raw_frame to: if a key is a str and startswith "b64:", strip that marker,
base64-decode the corresponding value (value must be str), if a value is a str
but key has no "b64:" marker then encode it with .encode("utf-8") (or wrap as
metadata according to RawFrame), and ensure keys are converted to bytes (e.g.,
key.encode("utf-8") if key is str); also update the docstring and any checks in
to_raw_frame to reflect the explicit "b64:" marker protocol and validate
decoding errors.
- Around line 134-152: The get_length method currently checks io.len() first
which can undercount for provider-mounted rooms; change the logic to read the
mounted provider count from Redis first via
self._redis.get(RedisKey.provider_frame_count(room_id)) and, if that value
exists, return it (int); only if no cached provider_frame_count is present
should you call io.len() on self[room_id] and return that result. Ensure you
still handle missing/None cached values and convert the cached string to int
before returning.

In `@tests/test_lifespan.py`:
- Around line 90-94: The test method test_memory_storage_creates_frame_storage
is defined as a class method; convert it to a module-level pytest function by
moving the def out of any test class (remove any leading self parameter) and
keep the body intact: instantiate AsyncMock(), create
FrameStorage(uri="memory://", redis=mock_redis) and assert isinstance(...,
FrameStorage). Ensure imports (AsyncMock and FrameStorage) remain available at
module scope and the function name stays
test_memory_storage_creates_frame_storage so pytest will discover it.

---

Outside diff comments:
In `@src/zndraw/result_backends.py`:
- Around line 99-137: The code currently calls FrameStorage.__getitem__ via
self._storage[key] in store, get, and delete which memoizes keys in
FrameStorage._rooms and leaks AsyncBlobIO handles; replace those direct
__getitem__ uses with a non-memoizing ephemeral accessor (e.g.
self._storage.open_ephemeral(key)) or, if such API doesn't exist, add and call a
public open_ephemeral()/discard() pair on FrameStorage so each operation opens
an ephemeral AsyncBlobIO, uses it (clear/extend/len/__getitem__), and then
disposes/discards the handle; update the store, get, and delete methods to use
that ephemeral open/discard pattern (or call discard immediately after each
operation) instead of keeping references via __getitem__.

---

Nitpick comments:
In `@docs/superpowers/plans/2026-03-18-storage-refactor.md`:
- Around line 914-922: The docs refer to using reserve() to simulate provider
mounts but the implementation and tests use AsyncBlobIO.reserve via
storage[room.id].reserve(3); update the plan text to match the implementation by
replacing the example that calls reserve() with the set_frame_count API (use
set_frame_count(room.id, 3)) or, if you prefer to keep the test pattern,
explicitly note that storage[room.id].reserve(3) (AsyncBlobIO.reserve) is an
equivalent valid approach and show that symbol as an acceptable alternative;
ensure you reference both set_frame_count() and
AsyncBlobIO.reserve/storage[room.id].reserve in the updated paragraph so the
plan matches the code in test_frames_provider_dispatch.py.

In `@src/zndraw/database.py`:
- Around line 238-243: The FrameStorage import and instantiation should be moved
out of the lifespan block: add "from zndraw.storage import FrameStorage" to the
module-level imports and initialize app.state.frame_storage where app startup
config is assembled (using settings.storage and app.state.redis) rather than
inside the lifespan startup code; update or remove the local import at the
former site and ensure the module-level import is used when creating
app.state.frame_storage (referencing FrameStorage, app.state.frame_storage,
settings.storage, and app.state.redis).

In `@tests/test_trajectory.py`:
- Around line 62-73: The local import of AsyncMock inside traj_storage_fixture
should be moved to the module top-level: add "from unittest.mock import
AsyncMock" at the top of the test module and remove the function-local import;
leave the rest of the fixture (mock_redis setup and FrameStorage("memory://",
mock_redis) yield) unchanged so traj_storage_fixture continues to return the
same AsyncMock-based mock Redis instance.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8267aa89-000c-43e9-a9c3-2dd1ce11cc14

📥 Commits

Reviewing files that changed from the base of the PR and between 7fa097a and cec848e.

📒 Files selected for processing (27)
  • docs/superpowers/plans/2026-03-18-storage-refactor.md
  • docs/superpowers/specs/2026-03-17-storage-refactor-design.md
  • src/zndraw/database.py
  • src/zndraw/dependencies.py
  • src/zndraw/result_backends.py
  • src/zndraw/routes/frames.py
  • src/zndraw/routes/isosurface.py
  • src/zndraw/routes/rooms.py
  • src/zndraw/routes/server_settings.py
  • src/zndraw/routes/step.py
  • src/zndraw/routes/trajectory.py
  • src/zndraw/socketio.py
  • src/zndraw/storage/__init__.py
  • src/zndraw/storage/asebytes_backend.py
  • src/zndraw/storage/frame_storage.py
  • src/zndraw/storage/router.py
  • tests/test_constraints.py
  • tests/test_frame_storage.py
  • tests/test_frames_provider_dispatch.py
  • tests/test_isosurface.py
  • tests/test_lifespan.py
  • tests/test_result_backends.py
  • tests/test_routes_frames.py
  • tests/test_routes_step.py
  • tests/test_storage_asebytes.py
  • tests/test_storage_router.py
  • tests/test_trajectory.py
💤 Files with no reviewable changes (3)
  • src/zndraw/storage/router.py
  • src/zndraw/storage/asebytes_backend.py
  • tests/test_storage_asebytes.py

Comment on lines +20 to +31
def to_raw_frame(frame: dict[str, Any] | RawFrame) -> RawFrame:
"""Convert input frame dict to raw bytes format.

Handles two input formats:
1. Already raw: dict[bytes, bytes] -- pass through
2. Base64 encoded: dict[str, str] with keys like "b64:..." -- decode

Parameters
----------
frame
Input frame data (dict[str, Any] or dict[bytes, bytes]).
"""
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Mar 18, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don't treat every string value as base64.

The public API here accepts dict[str, Any], but Line 53 unconditionally decodes every str value as base64. Any ordinary string field will either be mangled or fail decoding. This needs an explicit marker/protocol for encoded values, with plain strings packed separately.

Also applies to: 52-59

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/zndraw/storage/frame_storage.py` around lines 20 - 31, The to_raw_frame
function currently decodes every str value as base64; change it to only
base64-decode values that are explicitly marked (e.g., keys that start with
"b64:"), leave unmarked strings unchanged (convert plain str values to bytes via
UTF-8 or keep them as non-encoded metadata per RawFrame contract), and normalize
keys to bytes; specifically update the logic in to_raw_frame to: if a key is a
str and startswith "b64:", strip that marker, base64-decode the corresponding
value (value must be str), if a value is a str but key has no "b64:" marker then
encode it with .encode("utf-8") (or wrap as metadata according to RawFrame), and
ensure keys are converted to bytes (e.g., key.encode("utf-8") if key is str);
also update the docstring and any checks in to_raw_frame to reflect the explicit
"b64:" marker protocol and validate decoding errors.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Intentional. Values arriving through this path are always base64-encoded binary blobs from the frontend JSON API (msgpack-numpy data). Keys use the b64: marker because they can be either plain strings or encoded bytes, but values are always binary. No plain string values exist in the frame protocol. This code is unchanged from the original AsebytesStorage.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@PythonFZ, understood — thanks for the clarification. Given that values arriving via this path are contractually always base64-encoded binary blobs from the frontend JSON API (msgpack-numpy encoded data), the unconditional decoding is correct. I'll note this protocol for future reviews.


✏️ Learnings added
Learnt from: PythonFZ
Repo: zincware/ZnDraw PR: 877
File: src/zndraw/storage/frame_storage.py:20-31
Timestamp: 2026-03-19T10:02:10.740Z
Learning: In `src/zndraw/storage/frame_storage.py`, the `to_raw_frame` function unconditionally base64-decodes all `str` values. This is intentional: values arriving through the frontend JSON API path are always base64-encoded binary blobs (msgpack-numpy data). Keys use the `b64:` marker to distinguish plain string keys from encoded bytes keys, but values are always binary — no plain string values exist in the ZnDraw frame protocol. This behavior is preserved from the original `AsebytesStorage` implementation.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment on lines +134 to +152
async def get_length(self, room_id: str) -> int:
"""Get frame count for a room.

Checks the AsyncBlobIO length first, then falls back to the
Redis provider frame count key (set by mounted providers).

Parameters
----------
room_id
Room identifier.
"""
io = self[room_id]
length = await io.len()
if length > 0:
return length
cached = await self._redis.get( # type: ignore[misc]
RedisKey.provider_frame_count(room_id)
)
return int(cached) if cached else 0
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Mar 18, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Mounted rooms should prefer Redis frame counts over io.len().

For provider-backed rooms, io.len() is only the locally materialized subset. Once a few frames are cached, callers like room_join() and /step can start reporting the cache size instead of the provider's advertised total. Read provider_frame_count first and only fall back to io.len() when no mount metadata exists.

💡 One safe way to keep mounted counts authoritative
     async def get_length(self, room_id: str) -> int:
         """Get frame count for a room.
@@
-        io = self[room_id]
-        length = await io.len()
-        if length > 0:
-            return length
         cached = await self._redis.get(  # type: ignore[misc]
             RedisKey.provider_frame_count(room_id)
         )
-        return int(cached) if cached else 0
+        if cached is not None:
+            return int(cached)
+        return await self[room_id].len()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/zndraw/storage/frame_storage.py` around lines 134 - 152, The get_length
method currently checks io.len() first which can undercount for provider-mounted
rooms; change the logic to read the mounted provider count from Redis first via
self._redis.get(RedisKey.provider_frame_count(room_id)) and, if that value
exists, return it (int); only if no cached provider_frame_count is present
should you call io.len() on self[room_id] and return that result. Ensure you
still handle missing/None cached values and convert the cached string to int
before returning.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The current order is intentional and matches the original StorageRouter.get_length(). Provider-backed rooms use reserve() to pre-allocate all slots, so io.len() returns the full provider count. Real frames from extend() take precedence over the Redis provider count — this is validated by test_get_length_returns_storage_count. Checking Redis first would break that invariant.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@PythonFZ, thanks for the detailed clarification — that's a key design invariant I missed. Since reserve() pre-allocates all slots upfront for provider-backed rooms, io.len() already reflects the full provider frame count, and real materialized frames from extend() naturally take precedence. Checking Redis first would indeed break that invariant. The current ordering is correct.


✏️ Learnings added
Learnt from: PythonFZ
Repo: zincware/ZnDraw PR: 877
File: src/zndraw/storage/frame_storage.py:134-152
Timestamp: 2026-03-19T10:02:21.264Z
Learning: In `src/zndraw/storage/frame_storage.py` (ZnDraw project), `FrameStorage.get_length()` intentionally checks `io.len()` (AsyncBlobIO length) **before** the Redis `provider_frame_count` key. Provider-backed rooms call `reserve()` to pre-allocate all slots in the AsyncBlobIO, so `io.len()` already returns the full provider frame count. Real frames written via `extend()` must take precedence over the Redis provider count. Reversing the order (Redis first) would break this invariant. This is validated by the `test_get_length_returns_storage_count` test.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment thread tests/test_lifespan.py Outdated
Comment on lines +90 to +94
def test_memory_storage_creates_frame_storage(self) -> None:
"""memory:// URI should create FrameStorage backend."""
mock_redis = AsyncMock()
backend = FrameStorage(uri="memory://", redis=mock_redis)
assert isinstance(backend, FrameStorage)
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Mar 18, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify class-based pytest tests in this file.
rg -nP '^\s*class\s+Test' tests/test_lifespan.py
rg -nP '^\s{4}def\s+test_' tests/test_lifespan.py

Repository: zincware/ZnDraw

Length of output: 753


Convert this to a module-level pytest test function.

The test at line 90 is defined as a class method, which violates the guideline: tests/**/*.py requires tests to be functions, not class methods.

♻️ Suggested fix
-class TestStorageInitialization:
-    """Test storage backend initialization based on config."""
-
-    def test_memory_storage_creates_frame_storage(self) -> None:
-        """memory:// URI should create FrameStorage backend."""
-        mock_redis = AsyncMock()
-        backend = FrameStorage(uri="memory://", redis=mock_redis)
-        assert isinstance(backend, FrameStorage)
+def test_memory_storage_creates_frame_storage() -> None:
+    """memory:// URI should create FrameStorage backend."""
+    mock_redis = AsyncMock()
+    backend = FrameStorage(uri="memory://", redis=mock_redis)
+    assert isinstance(backend, FrameStorage)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_lifespan.py` around lines 90 - 94, The test method
test_memory_storage_creates_frame_storage is defined as a class method; convert
it to a module-level pytest function by moving the def out of any test class
(remove any leading self parameter) and keep the body intact: instantiate
AsyncMock(), create FrameStorage(uri="memory://", redis=mock_redis) and assert
isinstance(..., FrameStorage). Ensure imports (AsyncMock and FrameStorage)
remain available at module scope and the function name stays
test_memory_storage_creates_frame_storage so pytest will discover it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Pre-existing pattern — the other tests in this file (TestLifespanWithoutRedisUrl, TestLifespanWithRedisUrl, TestFakeRedisServer) are also class-based. Refactoring the whole file is out of scope for this PR.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@PythonFZ, understood — the new test is consistent with the existing class-based pattern in the file, so it makes sense to leave it as-is for this PR. Would you like me to open a follow-up issue to track converting all tests in tests/test_lifespan.py to module-level functions in a future PR?


🧠 Learnings used
Learnt from: CR
Repo: zincware/ZnDraw PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-06T10:07:24.874Z
Learning: Applies to tests/**/*.py : Use `pytest.mark.parametrize` for tests. Each test must be a function, not a class method

Learnt from: CR
Repo: zincware/ZnDraw PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-06T10:07:24.874Z
Learning: Applies to tests/**/*.py : Write specific tests that test one thing. Avoid complex setups

…is with real Redis in tests

upload_trajectory was missing write-protection for provider-backed rooms
after the StorageRouter removal. All test FrameStorage fixtures now use
the shared conftest frame_storage fixture backed by real Redis instead
of AsyncMock.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/zndraw/routes/trajectory.py (2)

33-39: ⚠️ Potential issue | 🟡 Minor

Declare the new read-only problem response on this endpoint.

Line 242 adds RequireWritableDep, so upload_trajectory can now fail with RoomReadOnly, but the decorator still omits that branch from problem_responses(...). The handler will still work, but the OpenAPI contract will miss this Problem JSON response.

Proposed fix
 from zndraw.exceptions import (
     InvalidPayload,
     NotAuthenticated,
     RoomLocked,
+    RoomReadOnly,
     RoomNotFound,
     problem_responses,
 )
@@
     "",
     status_code=status.HTTP_201_CREATED,
     responses=problem_responses(
-        NotAuthenticated, RoomNotFound, RoomLocked, InvalidPayload
+        NotAuthenticated, RoomNotFound, RoomLocked, RoomReadOnly, InvalidPayload
     ),
 )

As per coding guidelines: "Use RFC 9457 Problem JSON (application/problem+json) for all 4xx/5xx REST API responses".

Also applies to: 230-243

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/zndraw/routes/trajectory.py` around lines 33 - 39, The endpoint's OpenAPI
problem responses omit the new read-only error branch: add RoomReadOnly to the
import list (alongside InvalidPayload, NotAuthenticated, RoomLocked,
RoomNotFound, problem_responses) and include RoomReadOnly in the
problem_responses(...) call used on the upload_trajectory handler (the decorator
that now also uses RequireWritableDep) so the handler's Problem JSON response
for read-only rooms is documented.

156-189: ⚠️ Potential issue | 🟠 Major

Validate selection against every exported frame before streaming.

Line 160 only checks the first selected frame. If a later frame has fewer atoms, Line 186 can still raise inside _generate() after the 200 response has already started, which turns a bad request into a truncated download.

Proposed fix
     mime_type, ext = _FORMAT_INFO[format]
+    download_batch = 100
 
     # Validate atom selection against the first frame (before streaming starts)
     if atom_indices is not None:
-        first_frame = await storage[room_id][index_list[0]]
-        if first_frame is not None:
-            n_atoms = len(decode(first_frame))
-            invalid_atoms = [i for i in atom_indices if i < 0 or i >= n_atoms]
-            if invalid_atoms:
-                raise InvalidPayload.exception(
-                    f"Atom indices {invalid_atoms} out of range (0-{n_atoms - 1})"
-                )
+        for batch_start in range(0, len(index_list), download_batch):
+            raw_frames = await storage[room_id][
+                index_list[batch_start : batch_start + download_batch]
+            ].to_list()
+            for raw_frame in raw_frames:
+                if raw_frame is None:
+                    continue
+                n_atoms = len(decode(raw_frame))
+                invalid_atoms = [i for i in atom_indices if i < 0 or i >= n_atoms]
+                if invalid_atoms:
+                    raise InvalidPayload.exception(
+                        f"Atom indices {invalid_atoms} out of range (0-{n_atoms - 1})"
+                    )
@@
-    download_batch = 100
-
     async def _generate():
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/zndraw/routes/trajectory.py` around lines 156 - 189, Currently only the
first selected frame is validated which can let later frames trigger an
out-of-range error during streaming; before yielding any data in _generate(),
iterate all frames in index_list (using storage[room_id][...] and decode(...) as
done now) to compute n_atoms per frame and collect any invalid atom indices
relative to each frame, and if any invalids are found raise
InvalidPayload.exception (same as existing) before starting the response so the
request fails fast rather than producing a truncated download; keep references
to atom_indices, index_list, storage, decode, and _generate when locating where
to add this pre-check.
tests/test_routes_frames.py (1)

151-335: 🛠️ Refactor suggestion | 🟠 Major

Please parameterize repeated endpoint test matrices.

A large set of touched tests follows the same arrange/act/assert structure with only small input/expected variations. Converting these groups to pytest.mark.parametrize will reduce duplication and maintenance overhead. As per coding guidelines: Use pytest.mark.parametrize for tests. Each test must be a function, not a class method.

Also applies to: 343-403, 494-752, 847-935, 965-1024

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_routes_frames.py` around lines 151 - 335, Many of the test
functions (e.g., test_list_frames_empty_room, test_list_frames_with_data,
test_list_frames_with_range, test_list_frames_with_indices,
test_list_frames_with_keys_filter, test_list_frames_with_indices_and_keys)
repeat the same arrange/act/assert pattern with only inputs and expected outputs
varying; replace these repetitive test functions with
pytest.mark.parametrize-driven tests by grouping common setup (user, token, room
creation and frame_storage population) into a single test function per scenario
type and drive different inputs (storage payload, query params like
start/stop/indices/keys, and expected frame dicts/counts) via parametrize
tuples; ensure each parametrized case is a top-level test function (not a class
method), preserve helper calls (_create_user, _create_room, make_raw_frame,
raw_frame_to_dict, decode_msgpack_response, _auth_header) inside the test, and
convert the other similar ranges mentioned (lines noted in the review: 343-403,
494-752, 847-935, 965-1024) in the same manner to remove duplication and improve
maintainability.
🧹 Nitpick comments (4)
tests/test_trajectory.py (1)

421-651: Please lock in the provider-backed upload behavior with a dedicated test.

Lines 421-651 only exercise normal writable rooms. Since this PR moved the upload guard to RequireWritableDep, add one case that does await frame_storage.set_frame_count(room.id, 1) and asserts the provider-backed room returns the read-only problem response.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_trajectory.py` around lines 421 - 651, Add a test that exercises
the provider-backed (read-only) upload guard: create user and room as in other
tests, call await frame_storage.set_frame_count(room.id, 1) to mark the room
provider-backed, then POST a trajectory file to
f"/v1/rooms/{room.id}/trajectory" with authentication and assert the
response.status_code is 423 and that
ProblemDetail.model_validate(response.json()).type == ReadOnlyRoom.type_uri()
(this verifies the RequireWritableDep guard triggers for provider-backed rooms);
name the test something like test_upload_provider_backed_readonly and mirror the
upload payload used elsewhere.
tests/test_isosurface.py (1)

377-673: Add a provider-backed isosurface regression here.

Lines 377-673 only cover frames that are already materialized in frame_storage. After the FrameStorage swap, the storage-miss branch for mounted rooms is still untested, which is the path that can quietly regress back into a 404.

Based on learnings: In ZnDraw, isosurface/volumetric cube data can be stored directly in frames or generated on demand by provider-backed rooms, so this route must keep the provider-dispatch/materialization flow instead of treating a miss as a hard 404.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_isosurface.py` around lines 377 - 673, Add a provider-backed
isosurface regression test that exercises the storage-miss → provider
materialization path: create a room configured as a mounted/provider-backed room
(using the same helper used elsewhere, e.g., create_test_room with
provider/mounted flags or the provider registration helpers), do NOT
pre-populate frame_storage for that room, ensure the test registers a simple
provider stub that returns cube data when the provider dispatch is invoked, then
call the isosurface endpoint (as in test_isosurface_basic) for frame index 0
with the provider-only cube_key and assert a 200 plus non-empty "vertices" and
"faces" in the msgpack response; this verifies the code paths around provider
dispatch/materialize (the mounted-room branch) instead of treating a storage
miss as a 404.
tests/test_frames_provider_dispatch.py (1)

28-28: RawFrame import now conflicts with how payloads are typed in this test file.

On Line 28, RawFrame is imported, but the local provider-cache payloads are dict-like maps (dict[bytes, bytes]), not the RawFrame wrapper object. Tightening these annotations will avoid misleading type hints.

💡 Suggested cleanup
-from zndraw.storage import FrameStorage, RawFrame
+from zndraw.storage import FrameStorage
# In the payload-building spots, prefer:
frame_data: dict[bytes, bytes] = {b"arrays.positions": positions_packed}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_frames_provider_dispatch.py` at line 28, The test imports RawFrame
from zndraw.storage but the provider-cache payloads in tests are plain
dict[bytes, bytes], not RawFrame instances; update the type annotations and any
variable declarations that currently use RawFrame to use dict[bytes, bytes] (or
a more generic Mapping[bytes, bytes]) so payload-building sites (where
frame_data is constructed) reflect the actual structure, and ensure references
to FrameStorage remain unchanged if they expect wrapper objects; search for
RawFrame usages in this file and replace the type with dict[bytes, bytes] (or
Mapping) and adjust any docstrings or comments accordingly.
tests/test_routes_frames.py (1)

32-32: raw_frame_to_dict is annotated as RawFrame but treated as a mapping.

The helper calls .items() and decodes key/value pairs like a dict[bytes, bytes]. Updating the annotation/import will keep typing aligned with runtime behavior.

💡 Suggested fix
-from zndraw.storage import FrameStorage, RawFrame
+from zndraw.storage import FrameStorage
@@
-def raw_frame_to_dict(frame: RawFrame) -> dict[str, Any]:
+def raw_frame_to_dict(frame: dict[bytes, bytes]) -> dict[str, Any]:

Also applies to: 46-52

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_routes_frames.py` at line 32, The helper raw_frame_to_dict is
currently annotated/typed as RawFrame but the function treats it like a mapping
(calling .items() and decoding key/value bytes); update the type annotation and
any imports so raw_frame_to_dict accepts a Mapping[bytes, bytes] (or dict[bytes,
bytes]) instead of RawFrame and adjust the import statements if needed (e.g.,
import Mapping from typing) so the annotation matches runtime behavior; also
apply the same change for the other helper usages around lines where
raw_frame_to_dict is used (the block referenced in the review).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/test_constraints.py`:
- Around line 138-140: The lmdb_storage fixture constructs FrameStorage with a
bare filesystem path which the backend selector rejects; update the lmdb_storage
fixture to pass a schemed URI (use the file:// scheme) and an absolute path when
creating FrameStorage (refer to the lmdb_storage fixture and the FrameStorage
constructor) so the storage backend selector receives a valid URI like
file://<absolute-path> instead of a plain path.

---

Outside diff comments:
In `@src/zndraw/routes/trajectory.py`:
- Around line 33-39: The endpoint's OpenAPI problem responses omit the new
read-only error branch: add RoomReadOnly to the import list (alongside
InvalidPayload, NotAuthenticated, RoomLocked, RoomNotFound, problem_responses)
and include RoomReadOnly in the problem_responses(...) call used on the
upload_trajectory handler (the decorator that now also uses RequireWritableDep)
so the handler's Problem JSON response for read-only rooms is documented.
- Around line 156-189: Currently only the first selected frame is validated
which can let later frames trigger an out-of-range error during streaming;
before yielding any data in _generate(), iterate all frames in index_list (using
storage[room_id][...] and decode(...) as done now) to compute n_atoms per frame
and collect any invalid atom indices relative to each frame, and if any invalids
are found raise InvalidPayload.exception (same as existing) before starting the
response so the request fails fast rather than producing a truncated download;
keep references to atom_indices, index_list, storage, decode, and _generate when
locating where to add this pre-check.

In `@tests/test_routes_frames.py`:
- Around line 151-335: Many of the test functions (e.g.,
test_list_frames_empty_room, test_list_frames_with_data,
test_list_frames_with_range, test_list_frames_with_indices,
test_list_frames_with_keys_filter, test_list_frames_with_indices_and_keys)
repeat the same arrange/act/assert pattern with only inputs and expected outputs
varying; replace these repetitive test functions with
pytest.mark.parametrize-driven tests by grouping common setup (user, token, room
creation and frame_storage population) into a single test function per scenario
type and drive different inputs (storage payload, query params like
start/stop/indices/keys, and expected frame dicts/counts) via parametrize
tuples; ensure each parametrized case is a top-level test function (not a class
method), preserve helper calls (_create_user, _create_room, make_raw_frame,
raw_frame_to_dict, decode_msgpack_response, _auth_header) inside the test, and
convert the other similar ranges mentioned (lines noted in the review: 343-403,
494-752, 847-935, 965-1024) in the same manner to remove duplication and improve
maintainability.

---

Nitpick comments:
In `@tests/test_frames_provider_dispatch.py`:
- Line 28: The test imports RawFrame from zndraw.storage but the provider-cache
payloads in tests are plain dict[bytes, bytes], not RawFrame instances; update
the type annotations and any variable declarations that currently use RawFrame
to use dict[bytes, bytes] (or a more generic Mapping[bytes, bytes]) so
payload-building sites (where frame_data is constructed) reflect the actual
structure, and ensure references to FrameStorage remain unchanged if they expect
wrapper objects; search for RawFrame usages in this file and replace the type
with dict[bytes, bytes] (or Mapping) and adjust any docstrings or comments
accordingly.

In `@tests/test_isosurface.py`:
- Around line 377-673: Add a provider-backed isosurface regression test that
exercises the storage-miss → provider materialization path: create a room
configured as a mounted/provider-backed room (using the same helper used
elsewhere, e.g., create_test_room with provider/mounted flags or the provider
registration helpers), do NOT pre-populate frame_storage for that room, ensure
the test registers a simple provider stub that returns cube data when the
provider dispatch is invoked, then call the isosurface endpoint (as in
test_isosurface_basic) for frame index 0 with the provider-only cube_key and
assert a 200 plus non-empty "vertices" and "faces" in the msgpack response; this
verifies the code paths around provider dispatch/materialize (the mounted-room
branch) instead of treating a storage miss as a 404.

In `@tests/test_routes_frames.py`:
- Line 32: The helper raw_frame_to_dict is currently annotated/typed as RawFrame
but the function treats it like a mapping (calling .items() and decoding
key/value bytes); update the type annotation and any imports so
raw_frame_to_dict accepts a Mapping[bytes, bytes] (or dict[bytes, bytes])
instead of RawFrame and adjust the import statements if needed (e.g., import
Mapping from typing) so the annotation matches runtime behavior; also apply the
same change for the other helper usages around lines where raw_frame_to_dict is
used (the block referenced in the review).

In `@tests/test_trajectory.py`:
- Around line 421-651: Add a test that exercises the provider-backed (read-only)
upload guard: create user and room as in other tests, call await
frame_storage.set_frame_count(room.id, 1) to mark the room provider-backed, then
POST a trajectory file to f"/v1/rooms/{room.id}/trajectory" with authentication
and assert the response.status_code is 423 and that
ProblemDetail.model_validate(response.json()).type == ReadOnlyRoom.type_uri()
(this verifies the RequireWritableDep guard triggers for provider-backed rooms);
name the test something like test_upload_provider_backed_readonly and mirror the
upload payload used elsewhere.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 504c787c-9708-4276-87d6-e6998412537d

📥 Commits

Reviewing files that changed from the base of the PR and between cec848e and 195e112.

📒 Files selected for processing (12)
  • src/zndraw/routes/trajectory.py
  • tests/conftest.py
  • tests/test_constraints.py
  • tests/test_frame_storage.py
  • tests/test_frames_provider_dispatch.py
  • tests/test_isosurface.py
  • tests/test_lifespan.py
  • tests/test_result_backends.py
  • tests/test_routes_frames.py
  • tests/test_routes_step.py
  • tests/test_storage_router.py
  • tests/test_trajectory.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_frame_storage.py

Comment thread tests/test_constraints.py
Comment on lines +138 to +140
async def lmdb_storage(tmp_path, redis_client):
"""Create a fresh FrameStorage with LMDB backend."""
storage = FrameStorage(str(tmp_path / "test.lmdb"), redis_client)
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Mar 18, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

LMDB fixture passes an unsupported URI format to FrameStorage.

On Line 140, FrameStorage(str(tmp_path / "test.lmdb"), redis_client) uses a bare path. The storage backend selector expects a schemed URI (file://... / sqlite://... / redis://...), so this can raise ValueError once lmdb_storage[room_id] is used.

🔧 Suggested fix
-    storage = FrameStorage(str(tmp_path / "test.lmdb"), redis_client)
+    storage = FrameStorage(f"file://{tmp_path / 'test.lmdb'}", redis_client)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_constraints.py` around lines 138 - 140, The lmdb_storage fixture
constructs FrameStorage with a bare filesystem path which the backend selector
rejects; update the lmdb_storage fixture to pass a schemed URI (use the file://
scheme) and an absolute path when creating FrameStorage (refer to the
lmdb_storage fixture and the FrameStorage constructor) so the storage backend
selector receives a valid URI like file://<absolute-path> instead of a plain
path.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The asebytes registry handles bare LMDB paths — this is how LMDB has always been configured in this project. The test passes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@PythonFZ, thanks for the clarification! Understood — the asebytes registry accepts bare filesystem paths for LMDB backends, so no URI scheme is needed. I'll keep this in mind for future reviews.


✏️ Learnings added
Learnt from: PythonFZ
Repo: zincware/ZnDraw PR: 877
File: tests/test_constraints.py:138-140
Timestamp: 2026-03-19T10:02:26.990Z
Learning: In the ZnDraw project (zincware/ZnDraw), `FrameStorage` (and previously `AsebytesStorage`) can be initialized with bare LMDB filesystem paths (e.g., `str(tmp_path / "test.lmdb")`) without a URI scheme. The `asebytes` registry handles bare paths for LMDB backends — this is the established convention in this project. Do not flag bare LMDB paths as invalid URIs.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: PythonFZ
Repo: zincware/ZnDraw PR: 877
File: src/zndraw/storage/frame_storage.py:20-31
Timestamp: 2026-03-19T10:02:10.740Z
Learning: In `src/zndraw/storage/frame_storage.py`, the `to_raw_frame` function unconditionally base64-decodes all `str` values. This is intentional: values arriving through the frontend JSON API path are always base64-encoded binary blobs (msgpack-numpy data). Keys use the `b64:` marker to distinguish plain string keys from encoded bytes keys, but values are always binary — no plain string values exist in the ZnDraw frame protocol. This behavior is preserved from the original `AsebytesStorage` implementation.

Add RoomReadOnly to problem_responses decorator so the OpenAPI contract
documents the 409 response from RequireWritableDep. Add integration
test verifying provider-backed rooms reject trajectory uploads.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/zndraw/routes/trajectory.py (2)

159-168: ⚠️ Potential issue | 🟠 Major

Validate selection against every exported frame before streaming.

This only checks the first requested frame. If a later frame is shorter, atoms[atom_indices] can still fail after the response has started, so the client gets a truncated 200 instead of a clean InvalidPayload. Please pre-validate the full index_list in batches before constructing StreamingResponse, and add a regression test with mixed atom counts.

Also applies to: 186-187

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/zndraw/routes/trajectory.py` around lines 159 - 168, The current
validation in trajectory.py only checks atom_indices against the first frame and
can miss later frames with fewer atoms; update the code that builds the
streaming response to pre-validate every frame referenced by index_list (use
storage[room_id][idx] and decode() in batches to avoid loading everything at
once) and compute n_atoms per frame to collect any invalid_atoms across all
frames, then raise InvalidPayload.exception if any are out of range before
creating the StreamingResponse; apply the same full-frame validation logic to
the other selection check around the StreamingResponse creation (the block
referenced at lines ~186-187) and add a regression test that streams an
index_list with mixed atom counts to ensure selection validation fails cleanly.

238-311: ⚠️ Potential issue | 🟠 Major

Handle non-UTF8 uploads as a 400, not a 500.

content.decode("utf-8") runs before the try, so invalid bytes bypass InvalidPayload.exception() and surface as an internal error.

💡 Proposed fix
-    buf = io.StringIO(content.decode("utf-8"))
-    try:
-        atoms_list = ase.io.read(buf, index=":", format=fmt)
+    try:
+        buf = io.StringIO(content.decode("utf-8"))
+        atoms_list = ase.io.read(buf, index=":", format=fmt)
+    except UnicodeDecodeError as exc:
+        raise InvalidPayload.exception(
+            "Trajectory file must be valid UTF-8 text"
+        ) from exc
     except Exception as exc:
         raise InvalidPayload.exception(
             f"Failed to parse trajectory file: {exc}"
         ) from exc
As per coding guidelines "Use RFC 9457 Problem JSON (application/problem+json) for all 4xx/5xx REST API responses".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/zndraw/routes/trajectory.py` around lines 238 - 311, The handler
upload_trajectory decodes the uploaded bytes with content.decode("utf-8")
outside the try block so invalid UTF-8 raises a UnicodeDecodeError that becomes
a 500; move the decoding into the existing try that calls ase.io.read (or add a
small try around the decode) and catch UnicodeDecodeError (and other parsing
errors) to raise InvalidPayload.exception with a clear 400-level message (e.g.,
"Uploaded file is not valid UTF-8" or "Failed to parse trajectory file: ...");
update references to buf (io.StringIO) so it is created after successful
decoding and keep the ase.io.read(...) call inside the same error-handling block
to ensure non-UTF8 uploads return a 400 not a 500.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/zndraw/routes/trajectory.py`:
- Around line 159-168: The current validation in trajectory.py only checks
atom_indices against the first frame and can miss later frames with fewer atoms;
update the code that builds the streaming response to pre-validate every frame
referenced by index_list (use storage[room_id][idx] and decode() in batches to
avoid loading everything at once) and compute n_atoms per frame to collect any
invalid_atoms across all frames, then raise InvalidPayload.exception if any are
out of range before creating the StreamingResponse; apply the same full-frame
validation logic to the other selection check around the StreamingResponse
creation (the block referenced at lines ~186-187) and add a regression test that
streams an index_list with mixed atom counts to ensure selection validation
fails cleanly.
- Around line 238-311: The handler upload_trajectory decodes the uploaded bytes
with content.decode("utf-8") outside the try block so invalid UTF-8 raises a
UnicodeDecodeError that becomes a 500; move the decoding into the existing try
that calls ase.io.read (or add a small try around the decode) and catch
UnicodeDecodeError (and other parsing errors) to raise InvalidPayload.exception
with a clear 400-level message (e.g., "Uploaded file is not valid UTF-8" or
"Failed to parse trajectory file: ..."); update references to buf (io.StringIO)
so it is created after successful decoding and keep the ase.io.read(...) call
inside the same error-handling block to ensure non-UTF8 uploads return a 400 not
a 500.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ed83f563-e22c-47a1-9134-abe3f130dfa0

📥 Commits

Reviewing files that changed from the base of the PR and between 195e112 and f7a5042.

📒 Files selected for processing (2)
  • src/zndraw/routes/trajectory.py
  • tests/test_trajectory.py

Non-UTF8 file uploads now return 422 InvalidPayload instead of 500.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@PythonFZ PythonFZ merged commit 366b01c into main Mar 19, 2026
6 checks passed
@PythonFZ PythonFZ deleted the feat/storage-refactor branch March 19, 2026 13:36
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