Remote tensor server as a source type¶
Scope: biopb-tensor-server (adapter + config/reconcile).
A config entry whose url is grpc://host:port is a source like any other, of
type tensor-server: the local server mirrors that upstream's catalog and
re-serves its data from its own local segment cache, fetching upstream only on
a miss. Any number of upstreams may sit in one config alongside ordinary
local/cloud sources, all behind the one cache.
This buys two things. A read that already landed in the local file cache is a
local chunk_locate mmap hit shared by every reader on the box, instead of a
private in-RAM copy per worker; and the upstream only ever sees a request for a
chunk this server doesn't already have.
Config surface¶
{
"sources": [
{ "url": "grpc://lab-store.internal:8815", "alias": "lab", "credentials_profile": "lab-store" },
{ "url": "grpc://archive.internal:8815", "alias": "arc", "credentials_profile": "archive" },
{ "url": "/data/scratch/" }
],
"credentials": { "profiles": [
{ "name": "lab-store", "storage_type": "biopb-tensor", "token": "…bearer…" },
{ "name": "archive", "storage_type": "biopb-tensor", "token": "…bearer…" }
]}
}
type is "tensor-server", auto-detected from the grpc:///grpc+tls:///
grpcs:// scheme, so it is usually left out.
url = grpc://host:portmirrors every source on the upstream (the network analogue ofurl = "/data/"directory discovery).url = grpc://host:port/<upstream_source_id>mirrors a single upstream source. The path is the upstreamsource_id, slash-free by thearray_idspec, so the first/after the authority cleanly splits endpoint from source; every expanded concrete source carries this shape.alias(optional, slash-free) namespaces this upstream's mirroredsource_ids. Optional for a lone upstream, required once a collision is possible.
Upstream auth rides credentials_profile, a storage_type="biopb-tensor"
profile:
| Key | Meaning |
|---|---|
token |
Bearer token for the upstream. Beats the BIOPB_UPSTREAM_TENSOR_TOKEN env fallback (single-upstream convenience only). |
tls_fingerprint |
Expected SHA-256 of the upstream's cert, as cert init prints it. Verified on every connect. |
tls_ca_file |
Path to a PEM to trust — a private CA, or the upstream's own leaf. |
TLS trust is optional; unset means TOFU pinning, which already works with zero
config. Configuring an anchor buys the one thing TOFU cannot: rejecting an
impostor at first contact. If both keys are set, the CA wins and a warning is
logged, so an operator is never left believing an unenforced fingerprint is
protecting them. An unreadable tls_ca_file raises rather than silently
degrading to TOFU — see Misconfiguration is not unreachability below.
resolve_upstream_credentials() (adapters/remote_tensor.py) produces one
frozen, hashable UpstreamCredentials from the source + profile, and every
dial site — the adapter's pooled client, the reconciler's bulk catalog fetch,
and the bare-host expansion — uses it, so a grpcs:// upstream's configured CA
is honored everywhere it is dialed, not just on the adapter's own connection.
The adapter — a passthrough that understands nothing¶
RemoteTensorAdapter (adapters/remote_tensor.py) fronts one source on one
upstream, bound to (upstream_location, upstream_source_id, local_source_id).
It is format- and chunking-agnostic: it decodes no pixels, derives no chunk
grid of its own, and treats the upstream's chunk_id as opaque. The only thing
it does beyond passthrough is rewrite array_ids between its local
(namespaced) space and the upstream's.
Dispatch needs no changes to support this: the server already routes do_get
by the local source_id prefix on the chunk_id to the registered adapter, so
each mirrored source's own RemoteTensorAdapter is picked automatically and
rewrites to its own upstream. Multiple upstreams and local sources coexist in
one flat source_id-keyed registry with no multiplexing layer.
The local server's chunk-read path (TensorAdapter.resolve_chunk_data wrapping
get_data in the segment cache, keyed by chunk_id) is shared by every
adapter, so the proxy inherits the persistent file cache, eviction, crash
recovery and the chunk_locate mmap fast path unchanged — it adds no caching
code of its own.
- Catalog surface (
list_tensor_descriptors,get_metadata,get_tensor_descriptor) mirrors the upstream witharray_idrewritten local-ward, and degrades to an empty placeholder rather than raising when the upstream is unreachable — see Unreachable upstream below. - Read planning (
plan_flight_info) forwards the wholeGetFlightInfoto the upstream and localizes the response: only the upstream knows the grid, the pyramid, and the physical scale for a given (possibly scaled) read, so the proxy re-derives none of it and instead relays the caller's field mask and hints upstream verbatim. On an upstream failure it falls back to the inherited local planner — never worse than treating the mirror as an ordinary, ungridded source. - Chunk reads (
resolve_chunk_data) peel a proxy envelope off the served chunk_id, forward the inner — the upstream's own chunk_id, carried byte-for-byte, never decoded — to the upstream'sdo_get, and cache the result under the envelope's own key. Forwarding the scaled inner means the upstream does any downsampling, so only the small result crosses the network. - Writes are not forwarded. The proxy is read-only:
add_tensorand other write verbs are refused on a mirrored source, exactly as on the wire.
Identifier policy¶
Local ids are namespaced so multiple upstreams and local sources can share one
flat, source_id-keyed catalog:
local source_id = <alias>__<upstream_source_id> (slash-free)
local array_id = <alias>__<upstream_source_id>[/<field>]
__ is a cosmetic separator — nothing parses it back apart; each adapter
already stores its (alias, upstream_source_id) explicitly. The array_id
spec still holds: the prefix is slash-free, so source_id =
array_id.split("/", 1)[0] recovers it whole. Namespacing costs exactly one
array_id rewrite; everything after it in a chunk_id is untouched, so the
cache and the mmap fast path are unaffected. The namespace is flat rather than
nested per-upstream deliberately: it lets the rest of the stack (registry
dispatch, the metadata DB, list_flights, precache, do_get routing) work
unchanged. A lone upstream with no alias keeps its ids verbatim; a second
upstream, or a colliding local id, requires one.
The endpoint is deliberately not in the id¶
A local id is built from (alias, upstream_source_id) alone — no host, port
or scheme. Moving an upstream (new port, new host, grpc:// → grpcs://)
therefore changes only that source's url: its source_id, its array_ids,
and the route inside every chunk_id are untouched, so the persistent segment
cache stays warm and ROI annotations stay attached. Contrast a local source,
whose id hashes its path — there, moving the file re-keys everything.
The alias is what makes this possible: two upstreams offering the same
upstream_source_id must be told apart somehow, and the obvious discriminator,
host:port, would fold the volatile half of the address into the identity and
re-key the whole mirror on a move. The alias is a stable, human-chosen
stand-in for the endpoint instead.
An alias is part of the data's identity, not a display label. Renaming one
re-keys every source mirrored from that upstream: cached chunks orphan (their
route changed) and ROI annotations detach from their source_id, going
invisible to a roi read and ageing toward prune_unseen_days. Two
corollaries follow: a lone upstream with no alias keeps verbatim ids, so
adding an alias later is itself a rename — set one from the start if a
second upstream is ever likely — and a retired alias should never be reused
for a different upstream, since a coinciding upstream_source_id would
re-attach old rows to new data.
Catalog mirroring, expansion & refresh¶
A tensor-server source expands like a directory. The single-source form
registers under its namespaced local id; the bare-host form connects,
enumerates the upstream's source ids, and yields one concrete single-source
entry per upstream source — each then registers a RemoteTensorAdapter under
its namespaced source_id and is treated like any other source from there.
The upstream's own scratch source is never mirrored: it is a temp store
whose tensors have a deadline set by that server's policy, not a catalog worth
carrying.
Enumeration and seeding are one bulk query. fetch_upstream_catalog reads
every upstream source's id, tensors, metadata, is_resolved and indexed_at
in a single server-side query, which is not truncated (unlike
list_sources()), so mirroring costs one upstream RPC regardless of catalog
size and a re-list can safely remove sources that disappeared. An upstream
with no SQL catalog falls back to id-only enumeration, and removals are then
skipped — a truncated or degraded list must never be treated as a complete
one, or a re-list would drop sources it simply failed to see.
Cache staleness is versioned, not open. The upstream's indexed_at
becomes this mirror's content_version, folded into every chunk_id's proxy
envelope (b"iat:<ts>"). A chunk_id minted against a since-superseded
content_version is rejected before any I/O (check_chunk_version) rather
than served stale, so an upstream that re-registers a source invalidates the
proxy's cached chunks for it instead of leaking through them.
Refresh via monitor=true. For a bare-host upstream, monitor=true
generalizes the filesystem rescan into a periodic re-list: each upstream has
its own adaptive cadence, re-listing every rescan tick (default 30s) while
changing or failing, with the period doubling per unchanged re-list up to
about an hour. Any change or connectivity failure resets it back to
every-tick, so a new or recovered upstream is mirrored within about one tick.
Misconfiguration is not unreachability. A bad credentials_profile
(unreadable or empty tls_ca_file) fails identically on every re-list, so it
backs the upstream straight off to the slow cadence instead of burning the
fast one retrying a typo, and is reported once as a config error naming the
back-off. Fixing it is reported too, and resets the upstream to the fast
cadence — an operator who edits the config needs to see that the edit took,
not just silence.
Unreachable upstream. A proxy "resolve" is a cheap reconnect, not a cloud
download, so recovery is transparent — there is no unresolved-source consent
step. The catalog surface degrades to a placeholder (list_tensor_descriptors
/ get_metadata return empty, so registration's metadata-DB sync succeeds
with a row of no tensors) while the serve surface stays live and raises a
retryable error on a miss, dropping the dead upstream connection so the next
call reconnects. Already-cached chunks keep serving through an outage.
Connections to each upstream are pooled process-wide by (endpoint,
credentials), so N sources mirrored from one upstream share one connection
rather than opening N.
Local-tensor-server config editing¶
The server reads its config once at startup, so reconfiguring sources
(including proxied remotes) is: edit ~/.config/biopb/biopb.json — through
GET/PUT /api/config on the tensor sidecar, or by hand — then restart the
data plane and reconnect. GET /api/admin/status reports scan progress
(full_scan_in_progress, last_full_scan_finished_at, climbing
source_count) so a client can wait out the post-restart discovery scan
instead of guessing when it's done.
Known limits¶
- Chained proxies (a proxy mirroring another proxy) stack aliases
(
a__b__source) and work, but a misconfigured cycle has no depth cap yet. - A bare-host upstream that is down at boot can't be expanded — no
per-source ids are knowable until it answers — so its sources appear only
once
monitor=truere-lists it successfully; the server still boots rather than failing outright when the only configured source is an unreachable monitored upstream.