Skip to content

refactor(server): migrate aiofiles to object storage use obstore , drop S3 native versioning - #244

Open
JonnyTran wants to merge 4 commits into
mainfrom
refactor/obstore-drop-s3-versioning
Open

refactor(server): migrate aiofiles to object storage use obstore , drop S3 native versioning#244
JonnyTran wants to merge 4 commits into
mainfrom
refactor/obstore-drop-s3-versioning

Conversation

@JonnyTran

Copy link
Copy Markdown
Member

Replaces aiofiles + the app-wide aioboto3 contract with a single obstore-backed storage
port, and removes S3 native object versioning from the server, frontend and SDK.

Net -1408 / +985 across 51 files.

Why

Two separate problems that turned out to be entangled.

aiofiles earned nothing. Its only user was LocalFileClient — ~430 lines subclassing
aiobotocore's generated client to fake S3 over a directory tree, with a .versions/ dir,
symlinks and sidecar .metadata.json. It was untested, derived paths with Path.with_suffix
(so any key containing a dot did not round-trip: pdf/a.b.pdf came back as pdf/a), and
silently lacked head_bucket, so bucket_exists was always false in local dev.

S3 native versioning was not merely unused — it was lying to users.

  • GET /api/v1/file/... accepted version_id and never forwarded it to S3. Asking for an old
    version silently returned the current one.
  • The handler never emitted the Version-Id / Is-Latest / Version-Tag headers, because
    FileObjectResponse.http_headers had no caller. The frontend read exactly those headers, so
    is_latest was permanently null and the "Fetch latest schema" button could never render.
  • Deletion was modelled with delete markers that three separate call sites reconstructed from
    the same etag == "" and size == 0 heuristic. The CLI integration test for
    upload/download/delete has been skipped ever since with "buckets with versioning enabled
    still list deleted files"
    — un-skipped here.
  • SchemaVersion.object_version_id was written best-effort and never read.

Schema versioning is untouched and unaffected: a version's identity is (dataset_id, version),
allocated under a row lock so each version lands on its own key, and its integrity is checksum.
Neither ever depended on the object store.

What changed

aiofiles gone entirely
aioboto3 confined to contexts/buckets.py (3 functions, 4 call sites) — obstore binds to an existing bucket and has no admin API, so bucket lifecycle cannot move
GET /file streams via result.stream() instead of loading whole files into RAM
local dev real LocalStore instead of a hand-rolled mock

Things worth a reviewer's attention

  • obstore raises the builtin FileNotFoundError for a missing key, not
    obstore.exceptions.NotFoundError (which exists, but is not what gets raised). Catching the
    latter would turn every 404 into a 500.
  • An HTTP range is inclusive of its end; obstore's is exclusive. The range handler passes
    (start, end + 1). Range: bytes=0-0 must return one byte.
  • LocalStore rejects put attributes outright, so local mode recovers the content type
    from the key. The keys we mint (pdf/, thumbnails/) carry no extension, hence the prefix
    map — without it a local-dev PDF is served as octet-stream and the viewer breaks.
  • ObjectMetadata.metadata no longer strips an x-amz-meta- prefix. obstore returns user
    attributes under their own keys, so that stripping discarded every one of them. Caught only by
    the MinIO run, not by any unit test.
  • The ES table field sets dynamic: false, which overrides the index-level
    dynamic: strict for that subtree. A legacy response still carrying the removed version keys
    is ignored rather than rejected, so no reindex is needed.

Legacy buckets

Removing put_bucket_versioning does not disable versioning on buckets that already have it.
Those keep their noncurrent versions and delete markers, and a key hidden behind a delete marker
does not appear in a current-object listing at all — so the drain removed nothing and
DeleteBucket failed with BucketNotEmpty.

Deletion now pages list_object_versions and batch-deletes every version and delete marker.
suspend_versioning stops existing buckets minting new ones; it runs when create lands on a
pre-existing bucket and is exposed as a bucket_versioning workspace-doctor check. That also
closes the leak opened by dropping the expire-noncurrent-layout-versions lifecycle rule —
layout JSON is rewritten on every commit, so a still-versioned bucket grew without bound.

Breaking

Pre-1.0, so removed outright rather than deprecated:

  • include_version and version_id query params (422 on old SDKs)
  • is_latest / version_id / version_tag on ObjectMetadata
  • --versions / --version-id CLI flags
  • schema_versions.object_version_id (migration a1c9f4e2b703)

Verification

Check Result
Server unit suite 1951 passed; 3 failures pre-existing on the base commit (JWT/security)
New test_files_store.py 19 passed against a real LocalStore — nothing previously exercised a backend end to end
New test_buckets.py 11 passed — drain, batching, delete markers, NotImplemented fallback, suspension
MinIO end-to-end attributes round-trip, listing, ranges, and presign 200 -> 403 after expiry
MinIO legacy bucket seeded 3 versions + 1 delete marker: reproduced BucketNotEmpty, confirmed the fix drains and deletes
Frontend 907 passed
SDK file/CLI tests 40 passed; 11 other failures pre-existing
Alembic upgrade -> downgrade -1 -> upgrade clean, column confirmed dropped
Search engine 134 passed

Follow-up, not in this PR

extralit-hf-space/extralit_ocr/jobs.py imports get_s3_client, which this rename breaks. It is
a separate repo (submodule at detached HEAD), so the two-line fix
(get_s3_client -> get_storage) needs its own PR there and cannot land atomically with this one.

Object versioning was enabled on every workspace bucket but was never a working
feature, and in several places it actively misled callers:

- `GET /api/v1/file/...` accepted `version_id` and never forwarded it to S3, so
  asking for an old version silently returned the current one.
- The handler never emitted the `Version-Id`/`Is-Latest`/`Version-Tag` headers,
  because `FileObjectResponse.http_headers` had no caller. The frontend read
  exactly those headers, so `is_latest` was permanently null and the "Fetch
  latest schema" button could never render.
- Deletion was modelled with delete markers that three separate call sites
  reconstructed from the same `etag == "" and size == 0` heuristic; the CLI
  integration test for upload/download/delete has been skipped ever since with
  "buckets with versioning enabled still list deleted files". It is un-skipped
  here.
- `delete_bucket` drained with `list_objects_v2` and issued unversioned deletes,
  so noncurrent versions survived and `DeleteBucket` 500'd with BucketNotEmpty.
- `SchemaVersion.object_version_id` was written best-effort and never read.

Schema versioning is unaffected: a version's identity is `(dataset_id, version)`,
allocated under a row lock so each version lands on its own key, and its
integrity is `checksum`. Neither depended on the object store.

`ListObjectsResponse.objects` becomes `list` rather than `Iterable` — the
deleted `assign_version_id` validator was the only thing coercing it, and both
`__len__` and `__getitem__` already assumed a list.

Breaking: `include_version` and `version_id` query params are gone (422 on old
SDKs), as are `is_latest`/`version_id`/`version_tag` on `ObjectMetadata` and the
`--versions`/`--version-id` CLI flags.
Replaces the aioboto3/botocore dict-shape contract and the hand-rolled local
backend with one obstore port. `contexts/files.py` now talks to `S3Store` in
production and `LocalStore` in local dev, behind an `ObjectStorage` that resolves
a bucket name to a store (obstore binds a store to one bucket at construction,
so the per-call `bucket` argument becomes a lookup).

What this deletes:

- `aiofiles`, entirely. Its only user was `LocalFileClient`, ~430 lines
  subclassing aiobotocore's generated client to fake S3 over a directory tree.
  It was untested, and derived paths with `Path.with_suffix`, so any key
  containing a dot did not round-trip (`pdf/a.b.pdf` came back as `pdf/a`). It
  also silently lacked `head_bucket`, so `bucket_exists` was always false
  locally.
- The read-whole-file-into-RAM path in `GET /file`. `head_object` + a full
  `get_object` + a 10MB chunking branch collapse into one `get_async` and
  `StreamingResponse(result.stream())`.

`aioboto3` survives only in the new `contexts/buckets.py`: obstore binds to an
existing bucket and has no admin API, so CreateBucket/DeleteBucket/HeadBucket
cannot move. That is 3 functions and 4 call sites rather than the app-wide
contract it was.

Details worth knowing:

- obstore raises the builtin `FileNotFoundError` for a missing key, not
  `obstore.exceptions.NotFoundError`.
- An HTTP range is inclusive of its end and obstore's is exclusive, so the range
  handler passes `(start, end + 1)`. `Range: bytes=0-0` must return one byte.
- `LocalStore` rejects `put` attributes outright, so local mode recovers the
  content type from the key. The keys we mint (`pdf/`, `thumbnails/`) carry no
  extension, hence the prefix map -- without it a local-dev PDF is served as
  octet-stream and the viewer breaks.
- `ObjectMetadata.metadata` no longer strips an `x-amz-meta-` prefix. obstore
  returns user attributes under their own keys, so that stripping discarded
  every one of them. Verified against MinIO.

Also drops the ES mapping properties for the removed version fields. The `table`
field object sets `dynamic: false`, which overrides the index-level
`dynamic: strict`, so a legacy response still carrying those keys is ignored
rather than rejected -- no reindex needed.

tests/unit/contexts/test_files_store.py is new and drives a real `LocalStore`.
Nothing previously exercised a storage backend end to end; every other test
patches `contexts.files.*`.
Two HIGH findings from review of the obstore port (roborev #354, #355).

`contexts/imports.py` read `shared_resources["storage"]` directly. Removing
`create_s3_client()` from the lifespan left nothing to populate it eagerly, so a
bulk upload arriving before any other storage-dependent request raised "S3 client
not available" -- and an RQ worker, which never runs the lifespan at all, would
have failed every time. It now awaits `get_storage()`, which resolves and caches
on first use.

`contexts/buckets.delete` drained with obstore's `list()`, which returns current
objects only. Removing `put_bucket_versioning` does not disable versioning on
buckets that already have it, so a workspace created before this branch keeps its
noncurrent versions and delete markers and `DeleteBucket` fails with
`BucketNotEmpty`. Worse, a key hidden behind a delete marker does not appear in
the current-object listing at all, so the drain removed nothing. Deletion now
pages `list_object_versions` and batch-deletes every version and delete marker,
falling back to the old drain on a backend without version listing. The previous
commit message claimed this case was already fixed; it was only ever true of
buckets created after the change.

For the buckets that already exist, `suspend_versioning` stops them minting new
versions. It runs when `create` lands on a pre-existing bucket and is exposed as a
`bucket_versioning` workspace-doctor check, which is the operator-facing migration
path. This also closes the leak opened by dropping the
`expire-noncurrent-layout-versions` lifecycle rule: layout JSON is rewritten on
every commit, so a still-versioned bucket grew without bound and nothing reaped it.

Verified against MinIO on a bucket seeded with 3 versions and 1 delete marker: the
old path fails with BucketNotEmpty, the new path drains and deletes it, and
suspension leaves the bucket `Suspended`.
@JonnyTran
JonnyTran requested review from a team as code owners August 19, 2026 22:08
@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
extralit-frontend Ready Ready Preview Aug 20, 2026 1:01am

@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next review available in: 36 minutes

Limit details: You’ve used the included review currently available.

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?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b70887b1-44e5-4e4c-8d23-1157503fb011

📥 Commits

Reviewing files that changed from the base of the PR and between 6d473f2 and a86ae31.

⛔ Files ignored due to path filters (1)
  • extralit-server/uv.lock is excluded by !**/*.lock
📒 Files selected for processing (50)
  • extralit-frontend/components/base/base-render-table/RenderTable.vue
  • extralit-frontend/components/base/base-render-table/useSchemaTableViewModel.ts
  • extralit-frontend/v1/domain/entities/IAnswer.ts
  • extralit-frontend/v1/domain/entities/table/Schema.ts
  • extralit-frontend/v1/domain/usecases/get-extraction-schema-use-case.ts
  • extralit-frontend/v1/infrastructure/repositories/SchemaRepository.test.ts
  • extralit-frontend/v1/infrastructure/repositories/SchemaRepository.ts
  • extralit-server/pyproject.toml
  • extralit-server/src/extralit_server/_app.py
  • extralit-server/src/extralit_server/alembic/versions/a1c9f4e2b703_drop_schema_versions_object_version_id.py
  • extralit-server/src/extralit_server/api/handlers/v1/datasets/schema_versions.py
  • extralit-server/src/extralit_server/api/handlers/v1/documents.py
  • extralit-server/src/extralit_server/api/handlers/v1/files.py
  • extralit-server/src/extralit_server/api/handlers/v1/workspaces.py
  • extralit-server/src/extralit_server/api/schemas/v1/files.py
  • extralit-server/src/extralit_server/api/schemas/v1/schema_versions.py
  • extralit-server/src/extralit_server/cli/database/users/create.py
  • extralit-server/src/extralit_server/cli/database/users/create_default.py
  • extralit-server/src/extralit_server/contexts/buckets.py
  • extralit-server/src/extralit_server/contexts/files.py
  • extralit-server/src/extralit_server/contexts/imports.py
  • extralit-server/src/extralit_server/contexts/ocr/storage.py
  • extralit-server/src/extralit_server/contexts/schema_versions.py
  • extralit-server/src/extralit_server/helpers.py
  • extralit-server/src/extralit_server/jobs/document_jobs.py
  • extralit-server/src/extralit_server/jobs/ocr_jobs.py
  • extralit-server/src/extralit_server/jobs/preload.py
  • extralit-server/src/extralit_server/models/database.py
  • extralit-server/src/extralit_server/search_engine/commons.py
  • extralit-server/tests/factories.py
  • extralit-server/tests/unit/api/handlers/v1/datasets/test_schema_versions.py
  • extralit-server/tests/unit/api/handlers/v1/test_document_layout.py
  • extralit-server/tests/unit/api/handlers/v1/test_files.py
  • extralit-server/tests/unit/api/handlers/v1/workspaces/test_create_workspace.py
  • extralit-server/tests/unit/api/handlers/v1/workspaces/test_workspace_doctor.py
  • extralit-server/tests/unit/contexts/test_buckets.py
  • extralit-server/tests/unit/contexts/test_files_store.py
  • extralit-server/tests/unit/contexts/test_schema_versions.py
  • extralit-server/tests/unit/jobs/test_document_jobs.py
  • extralit-server/tests/unit/jobs/test_ocr_jobs.py
  • extralit/src/extralit/_api/_workspaces.py
  • extralit/src/extralit/_models/_files.py
  • extralit/src/extralit/cli/files/delete.py
  • extralit/src/extralit/cli/files/download.py
  • extralit/src/extralit/cli/files/list.py
  • extralit/src/extralit/cli/rich.py
  • extralit/src/extralit/workspaces/_resource.py
  • extralit/tests/integration/test_cli_commands.py
  • extralit/tests/unit/api/test_workspace_files_api.py
  • extralit/tests/unit/api/test_workspace_schemas_api.py

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.

@JonnyTran JonnyTran changed the title refactor(server): port object storage to obstore, drop S3 native versioning refactor(server): migrate aiofiles to object storage use obstore , drop S3 native versioning Aug 20, 2026
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