Skip to content

Optimize large S3 dataset listings - #416

Open
robinskil wants to merge 26 commits into
mainfrom
features/optimize-large-listings-s3
Open

robinskil wants to merge 26 commits into
mainfrom
features/optimize-large-listings-s3

Conversation

@robinskil

@robinskil robinskil commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Problem

Beacon lists the full datasets store for each browse. A recursive walk sends one sequential request per 1000 objects. A bucket with 2853217 objects needs 2854 requests.

The listing also ran inside a synchronous table function. It held a worker thread during query planning.

Change

Split the walk into shards. Read three directory levels first. Walk each shard in parallel, 16 at a time. Set the page size to 5000 keys.

Stream the listing. A plan node emits rows as pages arrive, so LIMIT stops the walk.

Add browse_datasets. It reads one directory level. It shares the provider with list_datasets. A new is_directory column marks sub-directory rows. GET /api/browse-datasets runs SQL.

Detect a Zarr store from its own path: a *.zarr directory that holds zarr.json.

Result

Operation Before After
Full walk 79.9 s 19.4 s
Folder view 79.9 s 14 ms

Measurements use a real SeaweedFS bucket. Each run returns all objects.

Notes

Shards interleave. Results no longer arrive in sorted order.

read_zarr now needs the .zarr suffix.

Listings do not filter by grant rules, as before.

The crawler follows in #417.

`ObjectStore::list` sends no `max-keys`, so a server applies its own
default of 1000 per page. A recursive walk of a large bucket becomes a
long chain of strictly sequential requests, and the listing is pure
wait rather than work.

Measured against a SeaweedFS bucket of 2 853 217 objects: the walk cost
2854 requests, and the same walk with max-keys=65535 cost 44.

`BigPageList` wraps a `PaginatedListStore` and re-expresses `list` over
`list_paginated` with an explicit page size. The stream stays lazy, so a
caller that stops early still stops paying. Every other method
delegates. AWS caps max-keys at 1000 and ignores a larger value, so this
is inert there.

Also: `s3_data_lake_deprecated` read `BEACON_S3_DATASETS`, the new name,
which left the back-compat path dead. A 1.x deployment carrying only
`BEACON_S3_DATA_LAKE=true` fell back to the local datasets directory and
reported an empty store.
A page chain is strictly sequential: each page needs the continuation
token of the one before it, so a walk costs one round trip per page
however fast the server is.

`list` now descends `list_with_delimiter` three levels to find shards,
then walks each shard as its own page chain, 16 at a time. Discovery
costs one request per directory seen and finished in under a second.
Objects at the intermediate levels are emitted too, so nothing is
missed. A prefix with no sub-directories falls back to the sequential
walk, as does a store that cannot answer a delimiter listing.

Measured against a SeaweedFS bucket of 2 853 217 objects, warm and
back to back. Every run returned the full count:

  sequential, no max-keys (before)   79.9s   2854 requests
  sequential, max-keys=5000          64.3s    571 requests
  2-level shards, 16 ways            33.8s
  3-level shards, 16 ways            19.4s
  3-level shards, 32 ways            17.7s

DEFAULT_MAX_KEYS is 5000, not the 65535 SeaweedFS will parse. Larger
pages measured worse and they broke: at 65535 with 8 to 16 shards in
flight, SeaweedFS failed whole responses with a body error and the walk
silently returned 2 288 498 of 2 853 217 objects.

Shards interleave, so results are no longer incidentally sorted.
`ObjectStore::list` does not guarantee order.
`list_datasets` matched `if let Ok(entry)` over the listing stream and
dropped everything else. A transient object-store failure part-way
through a walk therefore produced a short dataset list that looked
complete: a timeout on object 2 000 000 of 2 850 000 reported success
and lost the rest without a word. The error now propagates, naming how
far the walk got.

The page-size note blamed SeaweedFS for failing large responses. It is
not a server limit. `ClientOptions` gives a request 30 seconds by
default; one page of 65535 keys off a cold filer took 6.5s on its own,
and with 16 shards in flight some requests crossed 30s. Re-run warm, the
same walk returned all 2 853 217 objects with no errors, at both the
default timeout and a raised one. 5000 stays the default because it
holds whatever the filer's cache is doing; `AWS_TIMEOUT` is the knob to
raise first for anyone who wants larger pages.
The datasets page enumerated the whole store to draw a folder. It asked
for 100 000 rows, grouped them into folders in the browser, and asked
`/api/total-datasets` for a count that also walked everything. Both ran
on mount and again after every upload.

Nothing about a folder view needs the subtree, and the gap is not a
constant factor. Against a SeaweedFS bucket of 2 853 217 objects the
recursive walk took 79.9s and one delimiter request took 14 ms.

`GET /api/browse-datasets?prefix=` returns one level: sub-folder names
and the datasets sitting directly in it. `ListingFactory::browse_datasets`
does the delimiter listing and classifies only what is at that level;
`Runtime::browse_datasets` exposes it without going through SQL, since
there is no pattern to plan and no rows to page.

The page now browses per folder. Search is the one view that genuinely
needs every path, so it is the one view that pays for them, and only
once something has been typed. The total badge counts the current
folder rather than walking the store.

A directory-shaped dataset (Zarr, Atlas) keeps its marker inside its
directory, so at this level it reads as a folder; descending shows the
marker. Classifying it in place would cost one request per sub-folder,
which is the recursive cost this removes. Folder item counts are gone
for the same reason.

Authorization matches `list_datasets`: authenticated, no per-path grant
filtering. `GRANT ... ON PATH` still governs reading a dataset. Moving
both listings onto granted paths is a separate change and has to move
them together.
Neither listing consulted grants. `scan_targets` resolves a scan to a
table or to its file paths by downcasting the provider, and returns no
targets for anything it does not recognise -- "unintrospectable sources
are exempt". `list_datasets` is a table function returning a `MemTable`,
so it matched nothing and no grant was ever checked. `browse_datasets`
does not go through a plan at all, so it inherited the same hole.

`GRANT ... ON PATH` still gated reading the bytes, because a `read_*`
resolves to a `FastObjectTable` and that does downcast. What leaked was
the shape of the store: every path, format, size and mtime. With
`BEACON_AUTH_ANONYMOUS_ENABLED` defaulting to true, to anyone who could
reach the API.

Both listings now filter. A dataset survives when `Select` is allowed on
its path -- the same evaluator the read path uses, so a listing cannot
name a file the caller would then be refused. A folder survives when
some grant could match a path inside it: `RoleProvider::prefix_is_reachable`
walks the pattern segment by segment, so a grant on `argo/floats/**`
keeps `argo` navigable while leaving `argo/gliders` hidden.

Deny is asymmetric on purpose. Hiding a directory takes a deny covering
the whole subtree; a deny over part of it leaves the directory visible
and refuses the individual files, or granting `data/**` and denying
`data/*.csv` would strand the parquet beside it.

Gated on `BEACON_AUTH_ENFORCE`, as reads are, and never applied to the
super-user. A deployment that has not opted into grants sees what it saw
before.
Read authorization asks whether a caller may read one path, per scan, and
refuses the statement when the answer is no. A listing asks the same
question about every candidate and about the directories above them, and
answers by dropping rows. Same rule, different consequence -- so it was
written twice, once on `Runtime` and once at the call site.

`PathVisibility` is that rule. Two states rather than a context plus a
flag: a caller not subject to grants at all, because enforcement is off
or they are the super-user, is a different thing from one whose grants
happen to allow everything, and a holder should not have to remember
which it has.

It carries no storage types, so it serves an object listing, a directory
browse and a planner target alike. An object listing passes
`meta.location.as_ref()`.

Replaces `Runtime::{listing_is_filtered, may_read_path,
prefix_is_reachable}` with one `path_visibility` accessor.
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.61439% with 144 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.27%. Comparing base (294e328) to head (8c75f2d).

Files with missing lines Patch % Lines
...acon-db/beacon-datafusion-ext/src/big_page_list.rs 85.57% 46 Missing ⚠️
beacon-server/beacon-server/src/server/catalog.rs 54.54% 20 Missing ⚠️
...n-db/beacon-functions/src/listing/list_datasets.rs 69.84% 19 Missing ⚠️
...on-db/beacon-datafusion-ext/src/listing_factory.rs 90.90% 17 Missing ⚠️
...db/beacon-functions/src/listing/browse_datasets.rs 51.51% 16 Missing ⚠️
beacon-db/beacon-functions/src/listing/provider.rs 89.07% 13 Missing ⚠️
beacon-db/beacon-functions/src/listing/exec.rs 93.10% 8 Missing ⚠️
beacon-db/beacon-functions/src/listing/args.rs 80.00% 3 Missing ⚠️
.../beacon-datafusion-ext/src/listing_url_resolver.rs 95.23% 1 Missing ⚠️
beacon-server/beacon-server/src/server/mod.rs 0.00% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #416      +/-   ##
==========================================
+ Coverage   83.23%   83.27%   +0.03%     
==========================================
  Files         373      378       +5     
  Lines       62526    63354     +828     
==========================================
+ Hits        52044    52757     +713     
- Misses      10482    10597     +115     
Files with missing lines Coverage Δ
beacon-db/beacon-core/src/crawler/engine.rs 87.35% <ø> (ø)
beacon-db/beacon-core/src/file_stats.rs 95.33% <100.00%> (ø)
beacon-db/beacon-core/src/runtime_builder.rs 92.22% <100.00%> (-0.02%) ⬇️
beacon-db/beacon-datafusion-ext/src/format_ext.rs 95.88% <100.00%> (+0.19%) ⬆️
...beacon-datafusion-ext/src/object_store_registry.rs 79.89% <100.00%> (ø)
beacon-db/beacon-db-py/src/connection.rs 0.00% <ø> (ø)
...le-formats/beacon-arrow-zarr/src/datafusion/mod.rs 92.53% <100.00%> (-0.04%) ⬇️
.../beacon-file-formats/beacon-arrow-zarr/src/util.rs 93.65% <100.00%> (+0.68%) ⬆️
beacon-db/beacon-functions/src/file_formats/mod.rs 100.00% <100.00%> (ø)
beacon-db/beacon-functions/src/lib.rs 100.00% <ø> (ø)
... and 13 more

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

`TableFunctionImpl::call` is a synchronous trait method, and DataFusion
53 has no async form of it. `list_datasets` did the whole walk inside
it, so it reached the object store through `block_in_place` and
`block_on` and held a worker thread for the duration. It also ran during
logical planning, so a statement paid for the listing before anything
decided to read it.

`TableProvider::scan` is async. `call` now reads its arguments and
returns a `DatasetsTable` that has touched nothing; the walk happens in
`scan`. The runtime handle and the `Weak<SessionContext>` existed only
to drive `block_on`, so both are gone.

`browse_datasets([prefix])` joins it as a second table function over the
same provider, with a delimiter instead of a glob. `/api/browse-datasets`
now runs `SELECT * FROM browse_datasets(...)`, so both listings reach
the store the same way the engine reaches everything else. Folders are
not rows, so that half still comes from the runtime.

`scan` takes DataFusion's `limit` push-down and bounds the rows with it.
It does not yet bound the walk: a glob listing classifies objects into
datasets in one pass and the two do not correspond one to one, so
stopping early needs the classifier to work in chunks.

The four existing `list_datasets` tests pass unchanged, which is what
says the refactor kept its contract.
Both listings now reach the store through one provider and two table
functions. `list_datasets` globs and descends. `browse_datasets` stops at
one directory level and reports the sub-directories as rows, flagged by a
new `is_directory` column, so a single query describes a level.
`/api/browse-datasets` runs `SELECT * FROM browse_datasets(...)` and only
splits the rows.

Two names rather than one function with a depth argument. A depth
argument changes what the *first* argument means, glob or directory, and
an argument that retypes another argument is a bad seam. It cannot be
inferred from the glob either: DataFusion matches listing globs with the
default `MatchOptions`, where `require_literal_separator` is false, so
`sub/*` already matches `sub/deep/c.csv`. Reading it as one level would
silently change what existing patterns return.

Grant filtering is removed from both listings, restoring what
`list_datasets` did before: any caller who reaches the API sees every
path. That is a known gap, deferred deliberately. `PathVisibility` and
`prefix_is_reachable` stay, tested and unused, because the filter belongs
inside the provider rather than at the transport, and that needs the
caller on the session at scan time.

`Runtime::browse_folders` and the `file_formats` field it needed are gone
with it.
#415 fixed the same `BEACON_S3_DATA_LAKE` variable this branch did, and
fixed it better: the one-line change plus a deprecation warning and a
test. Its version is kept whole. The conflict was only the comment this
branch had added beside it, which the warning and the test now say more
usefully.
This branch is the listing performance work and the browse feature. The
grant filtering was a second subject that grew out of a question asked
while reviewing it, and it does not belong in the same change.

`beacon-auth` returns to exactly what `main` holds: `PathVisibility`,
`prefix_is_reachable` and their tests are gone, along with
`Runtime::path_visibility`. Listings do not consult grants, which is what
they did before any of this.

That gap is real and unclosed. Narrowing a listing to the caller belongs
inside the provider rather than at the transport, because a caller who
can reach SQL can otherwise read the unfiltered function directly, and
reaching the provider needs the caller on the session at scan time. It
wants its own change.
Both listing functions lived in `file_formats/list_datasets.rs`, which
made `browse_datasets` read as churn in a file that had no reason to
change, and put a cross-format concern under `file_formats`.

`beacon-functions/src/listing/` now holds the whole subject: `provider`
for the table both functions return, `list_datasets` and
`browse_datasets` for the two argument readers, `args` for the literal
parsing they share.

Also fixes where the sharded listing is applied. `BigPageList` only
wrapped stores built by `object_store_registry`, which serves ad-hoc
`s3://` URLs. The datasets store is not one of those: beacon-server
builds it and `register_object_stores` registers it verbatim, so it kept
the one-request-per-1000-objects walk and the sharding never reached the
data anyone actually queries. It is wrapped in `build_datasets_store`
now, beside the builder that makes it.
Zarr v3 gives every group *and every array* a `zarr.json`, so a marker
alone does not say which it is. Discovery told them apart by comparing
markers to each other: collect every one, then keep those with no
ancestor directory that also holds one. Correct, but it needs the whole
listing in hand before it can classify any of it, which is the one thing
that stops a listing from streaming.

The directory already carries the answer. A store is a `*.zarr` directory
holding `zarr.json` beside its arrays:

    gridded-example.zarr/zarr.json       the store
    gridded-example.zarr/lat/zarr.json   an array inside it

`is_zarr_store_root` reads that off one object, so discovery can classify
a listing as it arrives.

This does require the suffix. A store in a directory named anything else
is no longer discovered, where the ancestor rule found it. Every fixture
in the tree already follows the convention, and `read_zarr` on an
explicit path is unaffected: `top_level_zarr_meta_v3` still serves the
paths where the caller has named the store, and a listing scoped to one
store is small enough to compare.

Atlas has the same shape and the same fix available. It is excluded from
the workspace, so it is left alone.
Discovery named a store by its directory; an explicit `read_zarr` path
still named it by comparing markers to each other. Two rules meant a
directory could be a store for one caller and not the other, which is a
worse answer than either rule alone.

`is_zarr_store_root` is now the only one. `infer_schema` and the scan
that partitions file groups both use it, and the ancestor walk
`top_level_zarr_meta_v3` is gone along with the five tests that pinned
its behaviour.

The error a path that is not a store now gets says what one looks like,
because the rule is stricter than it was and "no zarr.json found" would
not explain a directory that plainly has one.
A listing collected every object, classified them all, built one record
batch and handed it to a `MemTable`. Nothing reached the caller until the
walk finished, and a listing of 2 853 217 objects held about a gigabyte
of `ObjectMeta` to do it.

Every format decides per object now, so nothing has to be held.
`classify_object` asks a format about one object; the default asks
`discover_datasets` about a listing of one, so no format crate changes.
The size and timestamp come off the object there, which is what the
separate enrichment pass over the whole listing used to do.

`stream_datasets` walks and classifies as pages arrive. It takes a
resolved store and URL rather than a session, so the stream is `'static`
and can be rebuilt: `resolve_listing` does the half that needs a session,
during `scan`, and the plan does the rest per execute.

`DatasetsExec` turns that into batches of 8192 rows. First rows arrive in
the time of the first page instead of the whole walk, memory is bounded
by one batch, and `LIMIT` now stops the walk rather than the output --
the source is lazy, so the node stopping drops the pages behind it.
`EXPLAIN` names what it will list.

A browse is one request whose rows are known once it answers, so `scan`
awaits it and the node replays them.
`stream_datasets` sat in `listing_factory` and never touched the factory.
It took a store, a URL and the file formats, and returned classified
datasets -- so a module whose job is resolving paths and walking a store
had to know what a Zarr marker is.

Walking a store and deciding what a file *is* are different jobs, and
only the second one needs formats. `stream_objects` does the first and
returns `ObjectMeta`. The provider does the second, in the crate where
the formats already live, with `filter_map` over the stream.

`classify` also states a rule that was implicit in the old loop: the
first format to claim an object wins, in registration order, so a file
two formats both read produces one row rather than two.

`beacon-datafusion-ext::listing_factory` no longer imports
`FileFormatFactoryExt` for the streaming path, and a raw object stream is
now available to anything else that wants one -- the file-statistics pass
hand-rolls `store.list(prefix)` today and could use this instead.
The factory had grown `resolve_listing`, a free `stream_objects` that
never touched it, and `browse_datasets` -- three entry points that each
resolved a path their own way, and one of which had to know what a Zarr
marker is.

`ListingFactory::listing` resolves once and returns an `ObjectListing`:
the store, and the URL selecting objects in it. Resolution is the only
half that needs a session, so the handle holds none and can be read as
many times as a caller wants. `stream()` walks it, `level()` reads one
directory. Both inherit the same path rules by construction -- the
configured store, a schemed path, a local directory, the glob -- rather
than repeating them.

`browse_datasets` is gone from the factory with `BrowseResult`. `level()`
returns objects, and the provider classifies them exactly as it does the
streamed ones, so there is one rule for what a file is instead of two
paths to it.

The file-statistics pass now resolves through the factory too. It called
`store.list(prefix)` directly with its own prefix handling, so a scan
prefix meant something subtly different there than in a query, and a glob
meant nothing at all.
**A store the caller names keeps the ancestor rule.** Unifying the Zarr
rules was wrong, and `cf_time_nan` and `statistics` say why: both write a
store into a temp directory and read it by path. That directory does not
end in `.zarr`, so the strict rule refused a store the caller had just
pointed at.

The two questions are not the same one. Discovery asks whether a marker
is a store root among many stores, and has to answer from one object to
stream. A read asks which marker is the root of the one store it was
given, and has a listing small enough to compare. `infer_schema`, the
scan that partitions file groups, and the schema cache go back to
`top_level_zarr_meta_v3`. `discover_datasets` keeps `is_zarr_store_root`.

**A URL naming one file is not a listing.** `client_endpoints_http`
caught it: `list_datasets('obs/a.parquet')` returned nothing. A store
lists a prefix at segment boundaries, so listing `obs/a.parquet` looks
for a directory of that name. `ObjectListing::stream` now asks for the
object with `head` when the URL is not a collection, and falls back to
listing on NotFound -- which is what `ListingTableUrl::list_prefixed_files`
does, and what the streaming path dropped.
Codecov called out the patch, correctly: `BigPageList` and `ObjectListing`
were new and untested. `InMemory` does not implement `PaginatedListStore`,
so a sharded walk had no fixture and I had leaned on an external probe
against a real bucket -- which tested a separate implementation of the
same idea, not this one.

A fake paginated store fixes that, and the first test it ran found a real
bug: **a sharded walk returned every object twice.** `discover_shards`
added each level's own objects to the ones gathered above, including the
level that found no children -- and that level is the one the shards are
taken from, so its walk returned them again. It now keeps a level's
objects only when it descends past that level.

The probe never saw this. It walked the same way but was its own code, so
it agreed with the design and not with the implementation.

Fifteen tests: the walk is whole, repeats nothing, matches the sequential
walk, sizes its pages, clamps a zero page or concurrency, and passes a
delimiter listing through; a listing streams a subtree, narrows by glob,
yields a single named file, is empty for a missing path, reads one level,
and reads twice.

One of them corrects me rather than the code. `*.csv` matches
`sub/b.csv`, because DataFusion matches listing globs with
`require_literal_separator` false. The test now says so, since that fact
is why one directory level needs its own function.
…-merge-2f7ba1

# Conflicts:
#	beacon-clients/beacon-web/src/pages/datasets.tsx
#	beacon-db/beacon-functions/src/lib.rs
`PaginatedListStore::list_paginated` adds no trailing delimiter, unlike
`ObjectStore::list`. A shard of `a/one` therefore also returned
`a/one.txt` from the level above and everything under a sibling
`a/one2/`, so both came back twice. The test store split on directories
and hid it.

Also: `browse_datasets()` with no arguments resolved an empty path,
which `std::path::absolute` refuses; `browse_datasets` was missing from
the Python table-function list; and the Zarr tests for
`top_level_zarr_meta_v3` went with a function that is still in use.
Document both listing functions and the new endpoint.
Discovery resolves the scan prefix through the listing factory now, so
the service's own copy of the URL went unread. Also make the listing
Row public: it is in the signature of a public plan node.
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