ScanDAL
Scan, write and evaluate Tables through Spiral's hosted ScanDAL service, from any client that speaks HTTP and Arrow.
The ScanDAL service runs Table scans, writes, blob transfer and SPQL evaluation for your application, next to the data, so your application needs no native library to read or write a Table. It is the hosted form of ScanDAL, the same operations that embedded ScanDAL runs inside your process, and it returns the same results. These HTTP operations are part of Connect; the embedded implementation is libspiral.
An example
To read the front-camera rows of frames@main, the Rust SDK resolves
frames@main to an exact version, gets
storage access for it from the
Tables service, and sends both with the scan to
https://scandal.spiraldb.dev. ScanDAL plans the scan into splits, one for
each file that the filter does not rule out, and streams each split back as
Arrow rows. Select the service with scandal_url on the
client builder; the rest of your code does not
change.
Every request carries a ScanDAL capability: your Spiral access token, narrowed to ScanDAL and your stack's API, so that ScanDAL can read the blobs your rows address. Listing the functions the service can run needs nothing else:
export SCANDAL_TOKEN="$(curl -s https://auth.spiraldb.com/oauth/token \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token="$SPIRAL_TOKEN" \
-d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
-d resource=https://scandal.spiraldb.dev \
-d resource=https://api.spiraldb.dev | jq -r .access_token)"
curl https://scandal.spiraldb.dev/functions \
-H "Authorization: Bearer $SCANDAL_TOKEN"Why each request stands alone
A request names an exact Table version and carries the storage access it needs, so ScanDAL looks up no names and needs no state from an earlier request. Your application, or the SDK on its behalf, makes those control-plane decisions first, as it does for embedded ScanDAL. Because no request depends on an earlier one, any instance can serve it: a client reads splits in parallel, and resumes an interrupted split elsewhere by sending it again from the last row it received.
While your storage access remains valid, ScanDAL can reuse Table and file metadata for requests with the same access. This reuse is optional: each request can also run on an instance that has read none of that metadata.
The capability and the storage access answer different questions. The capability tells ScanDAL who the work is for, so the service refuses a request without a valid one before it does any work. The storage access decides which data the request can reach: SPFS checks it on every object request, and ScanDAL holds no storage credential of its own. A capability expires with the token it narrows and names only ScanDAL and your stack's API, so ScanDAL cannot use it to reach another service as you.
Rows can also address blobs in other resources by spiral:// address, such as
a file under a Location. The request
carries no storage access for those. When a read meets one, ScanDAL presents
your capability to your stack's storage-binding route for that resource, and
the stack checks that you may read that resource, whichever Table addressed it.
A capability requested for ScanDAL alone still reads the Table, but fails on
the first spiral:// blob.
An Application acting for a user gets a
capability the same way. Its sign-in requests ScanDAL as a resource beside
the stack's API, which every Application may, and it narrows the user's token
to both. The capability keeps the token's scopes and names the Application as
its client. ScanDAL does not check the scopes:
the Tables service applies them when it issues storage access, so the
Application reaches only the data the user let it read or write.
Source field identities
A scan plan exposes sourceFields, a map from top-level stored names to stable field identities.
The map follows the exact Table version and remains independent of the output schema.
An absent map means that the host does not expose stable identities.
Reading legacy keyed tables
Studio uses ScanDAL to browse legacy keyed tables.
The client resolves a snapshot and obtains storage access from the Tables API,
then sends both to POST /legacy-tables/read. The response is an Arrow IPC
stream with up to 10,000 rows plus one row indicating that more results exist.
Use a zero-row limit to read the schema. The scan engine validates filter
operations against column types. Ordered comparisons can filter text keys;
for example, a greater-than filter can select keys after the last returned key.
Filters and an offset select the window; refreshing in Studio resolves a new snapshot.
Use filter to combine field references, typed literals, comparisons, null checks
and synchronous SPQL calls with AND, OR and NOT. ScanDAL returns InvalidArgument
for asynchronous calls and other expression forms. ScanDAL applies this expression
together with filters before the row limit and offset. Set columns to read
only the top-level fields your application needs, in the requested order.
Predicates can reference fields that you omit from columns. Omit columns
to read every field.
At most two legacy reads execute at once and two more wait for a slot.
A waiting read has one second to acquire a slot, then a separate 60-second
execution budget. Waiting does not occupy the server capacity used by other
operations. If the queue is full or no slot becomes available within one second,
the request returns Unavailable and can be retried with backoff. Invalid
storage bindings are rejected before waiting. Blocking session setup and
image preparation retain their slots until they finish, even if the request
times out. ScanDAL processes image presentations one row at a time. Each decoded
image must fit within 64 MiB of pixel data, including when requesting a
thumbnail. The rendered image buffers must fit in the remaining response
budget. The serialized Arrow IPC response is limited to 64 MiB, reduced when
its host sets a smaller memory budget. Large source images within the pixel
budget can still produce small thumbnails. ScanDAL compacts string and binary
views so unselected values in shared buffers do not count toward the response limit.
Exceeding either budget fails the read; narrow the filter before retrying.
These limits do not cap all temporary memory used to decode stored data or
prepare previews. Loading media from
URI references is not supported by this operation.
Legacy previews require support for read snapshots in the Tables API and legacy reads in ScanDAL. A client update alone does not enable this operation.
Hosted ScanDAL reads cloud storage. Reading a local file requires ScanDAL
running on your machine, through desktop Studio or a paired
spiral connect process. This operation is read-only and preserves the legacy table's format.
Similarity search
Your application can search an exact Vector Index through POST /vector-indexes/search.
The request carries the index artifact and storage access for both Resources.
The artifact origin supplies the exact source Table version.
The manifest must match the requested index, version, source, and embedding field identities.
An artifact without these identities requires a refresh.
query supplies the vector, and limit selects up to 1,000 results.
probes selects the number of partitions, and rerank caps candidates at 100,000.
ScanDAL applies the source filter and excludeRowIds before the final top-k selection.
A filter can produce fewer results than limit because the candidate budget stays fixed.
The Arrow response contains scandal.row_id, scandal.distance, and the one-based scandal.rank.
Requested source columns follow these fields.
Omission of columns returns only these three fields.
Final distances use float64 source coordinates, and candidate selection uses float32 coordinates.
Equal distances sort by stable row ID.
Dot distance is the negative inner product, so every metric sorts smaller distances first.
The schema metadata key scandal.vector_search contains the exact identities and effective candidate limits as JSON.
Its exhaustive flag means that every indexed row fits within both limits.
A shorter result proves exhaustion of the filtered population only when this flag is true.
One request returns one bounded result set, without a continuation token.
Search shares the service admission and memory budgets with scans. Each search retains at most 64 MiB of serialized index payloads. Exact refinement prefetches at most two source splits under the request memory budget. Final metadata reads remain sequential.
Each host also reserves up to 128 MiB for reusable data from exact index versions. This data includes parsed manifests and prepared code planes. Every cache hit requires an object read with the current storage credentials. A strong object validator permits a one-byte read if its value still matches. Other hits require a complete read and checksum verification. Active requests retain their own memory costs after cache eviction.
The operation reads index payloads only after metadata and source validation.
It reads requested source columns only for final results.
A budget failure returns BudgetExceeded.
An HTTP failure after the response starts truncates the Arrow stream, without its end marker.
Stream cancellation releases the request reservations.
Top-k and aggregates
A facet list or a dashboard tile needs a few numbers or the ten newest frames,
not every row of frames. Add an emit to the scan, and ScanDAL returns
those in place of the rows, so the rows never leave the service. This emit
counts the frames of each camera and finds each camera's first frame:
{
"type": "aggregate",
"aggregate": {
"groups": ["camera"],
"aggregates": [
{ "name": "frames", "function": { "type": "count", "count": {} } },
{ "name": "first_seen", "function": { "type": "min", "min": { "column": "captured_at" } } }
],
"maxGroups": 20
}
}Each split returns one row for each camera it read, with frames,
first_seen, scandal.rows (the rows the camera covers) and
scandal.other. These are partial results, and merging every split's rows
gives the answer: counts and sums add, minimums and maximums compare, and
histograms add bin by bin. A sum of no values is zero, and an integer sum
that overflows is null. When a split has no filter and no deleted rows,
counts, and minimums and maximums of numbers, come from statistics the Table
keeps for each file, so the split reads no rows. The Rust and TypeScript SDKs read the splits in
parallel and merge them for you when you set emit on a scan. A split's
results are complete only when its stream ends, so a client reads a failed
split again from its start.
Aggregates keep at most maxGroups groups, 1,000 unless you set it. Rows of
further groups fold into one row whose scandal.other is true, so the
result stays small however many distinct values a column holds. While the
Table has no more distinct groups than maxGroups, the result is exact. A
merged result lists groups by their rows, most first.
A top-k emit returns the k rows that sort first by its keys, with every
output column. For the ten newest frames, the emit is
{ "type": "topK", "topK": { "keys": [{ "column": "captured_at", "descending": true }], "k": 10 } }.
Each split reads only the key columns to choose its winners, then reads the
other columns of those ten rows, so a wide image column is read for ten
frames, not for all of them. Once a split holds ten frames, it skips frames,
and whole stretches of the file whose statistics show them, that are older
than its tenth newest, so a top-k over a sorted column reads little more
than its winners. k is at most 10,000. To sort a whole Table,
use your own query engine.
The row ID column can supply a stable tie breaker for a top-k.
Aggregates do not return per-row identities.
The Python client uses top_k on a scan.
page = frames.scan(
["frame_id", "camera", "captured_at"],
row_id_column=spiral.ROW_ID_COLUMN,
top_k=spiral.TopK(
50,
[
spiral.SortKey("captured_at", descending=True),
spiral.SortKey(spiral.ROW_ID_COLUMN),
],
),
).to_table()Reading rows by position
A sampler that draws rows at random, such as a data loader's shuffled epoch,
reads them with POST /scans/take. The request carries the scan, the storage
access, and takes: each a split of the scan's plan and positions within it.
Position p is the p-th row of the split, in stored order, that the Table
version keeps after deleted rows. These takes read three rows of one split and
one of another:
{
"takes": [
{ "split": { "fragment": 3, "rows": { "start": 0, "end": 4096 } }, "positions": [812, 17, 812] },
{ "split": { "fragment": 7, "rows": { "start": 0, "end": 4096 } }, "positions": [2048] }
]
}ScanDAL returns the rows in request order, the row at position 812 twice, as an Arrow
stream framed like a split read: scandal.next_row counts the positions
delivered so far, so a client resumes by sending the positions after them.
It reads each split's rows in one pass, so positions that lie close together
in storage share their reads. A request holds at most 65,536 positions from
at most 256 splits, and its scan has no filter, limit, or emit. A
position past the rows of its split fails the request before any rows are
sent. The Rust SDK counts
each split's rows and sends the takes for you.
How much one request may use
Each request may hold 512 MiB of memory: the batch it is sending and the
state of its emit, such as a top-k's winners or an aggregate's groups. A
request that needs more fails alone with BudgetExceeded, and the other
requests on the same instance carry on. Narrow it with a smaller k or
maxGroups, or fewer or narrower columns.
An instance runs as many requests at once as their budgets fit in its memory.
When it is full, it refuses a request with Unavailable, HTTP status 503,
before doing any work, and its Retry-After header says how many seconds to
wait. The SDKs wait and send the request again.
Uploading large blobs
A video or a point cloud can be larger than any one request should carry, so ScanDAL uploads it in parts that any instance accepts. Each part is a request of its own: a failed part is sent again by itself, and parts of one upload can go to different instances at once. The upload belongs to one write of a Table, and the blob it stores becomes part of the Table only when a row of that write references it and the write commits.
POST /blobs/uploads/starttakes the Table, the write, and the write's storage access, and returns an upload: the Table, the write, the blob's object ID, the storage provider's upload ID, and the part size, 64 MiB unless the request sets a power of two from 8 MiB to 1 GiB.POST /blobs/uploads/partstores one part. Thescandal-requestheader carries the upload and the part's number, counted from 1, and the body carries its bytes. Every part but the last holds exactly the part size. The response holds the part's storage tag and the BLAKE3 hash of its share of the payload.POST /blobs/uploads/completetakes every part's response, in order, and returns the blob. Its digest is the BLAKE3 digest of the whole payload, which ScanDAL merges from the parts' hashes without reading a byte again.- Append a row whose blob column carries the blob, in the same write.
The upload keeps no state in ScanDAL: the storage provider holds its parts,
and your client holds the upload and the part responses. POST /blobs/uploads/abort discards an unfinished upload's parts; aborting removes
no stored object. Each part counts against the request's memory budget, so a
part is at most 512 MiB. The Rust and TypeScript SDKs run these steps for you.
Packing small blobs
Many small payloads, such as camera frames, are cheaper to read from a few
large objects than from one object each. POST /blobs/pack takes the Table,
an optional write, and an Arrow stream whose spql.Blob columns hold inline
bytes. It stores those payloads as a write stores the inline bytes of the
rows it appends: in packs of up to 64 MiB under the Table's blobs/
directory, or the write's, with a payload of 4 MiB or more as an object of its
own and equal payloads sharing one range. It returns the same rows with each
payload replaced by its address, offset, size and BLAKE3 digest, once every
pack is stored. Append rows that carry those blobs, in the same write. Each
Arrow batch of the request holds at most 64 MiB; upload a larger payload in
parts instead. The Rust SDK's blob pack sends this request for you.
Calling ScanDAL from a browser
Use the TypeScript SDK's connect()
to authenticate with a short-lived capability and establish a secure, HttpOnly,
partitioned cookie. Subsequent Table operations name resources and exact
versions; the service obtains scoped storage access as that caller. Hosted
writes obtain separate create-only access for their attempt and publication.
The Application registers each page's exact origin through
allowedCorsOrigins. The authorization
server embeds that configuration in the signed token. ScanDAL verifies the
signature and checks the request's Origin against that list before work. Origin
changes take effect when the token is renewed, bounded by its lifetime. OPTIONS
preflight permits origins without authentication and sets no cookie. Actual
responses expose credentials only to configured origins, with Vary: Origin.
Cookies are host-only and partitioned by the top-level site. They expire with the
capability, require an allowed Origin, and require x-spiral-request: 1 on
state-changing requests. An explicit Authorization header takes precedence.
The service keeps no session registry or affinity; every replica verifies the
same signed capability using the authorization server's published keys.
GET /files and HEAD /files serve resource-owned blobs, including packed slices,
to native images and video. A single HTTP Range is relative to the logical slice;
ETag and version pins constrain the upstream object. Set the element's
crossOrigin to use-credentials before setting its URL. Responses are private
and never cached. The HTTP reference covers the
cookie bootstrap, renewal, logout and byte-range protocol.
Storage bindings are optional on the existing operations. When omitted, the service obtains access as the authenticated caller, scoped to the read, write attempt or publication. Clients with explicit bindings and paired local Connect use the same routes.
Where it runs
Spiral runs the service in us-east4 at https://scandal.spiraldb.dev, next to
Spiral-managed storage in that region. Rows and blob bytes pass through the
service, but not through Spiral's control plane.
Availability
The service runs in us-east4 only; the other platform regions are planned. It reads and writes through SPFS or scoped cloud provider credentials. The Rust SDK uses the service only when you pass its endpoint.