A team shares one store by serving it: vc_object_server owns the database
on the storage host, and clients use the same checked-request, history,
geometry, and query APIs over HTTP without opening the file. In C++, an
ObjectStoreSession holds any mix of local and hosted attachments behind one
interface — reads through a StoreReader, writes as typed requests — and
adds change events, surface drafts, and crash-durable saves. Python exposes
the same session, plus a direct HostedStore client.
vc_object_server /data/project.vcobj --port 8750 # on the storage host
ssh -N -L 8750:127.0.0.1:8750 storage-host # on each clientThe server opens the store as its service owner (exclusive write lock): it
will not start while a standalone writer has the store open, and standalone
writable opens fail while it runs; read-only inspection still works. There
is no authentication — keep the default loopback listen address behind an
SSH tunnel, or listen (--listen) only on a trusted private network.
--read-workers, --max-payload-mib, --max-staged-mib, and --staging-dir
bound its queues and staged uploads; a full queue answers 503 with
Retry-After, which clients retry. A link_objects request
(graph edges)
runs its detectors on a read worker and queues only their commit to the
writer; it answers when the detection is done, so --request-deadline
(default 300 s) and the client's link timeout (HostedStoreOptions::linkTimeoutS,
Python link_timeout_s, default 300 s) must both exceed the longest link,
and --strip-cache DIR keeps the fiber strips between such requests. A
retry of a link request in flight (same UUID) waits for the running
detection instead of starting another. GET /v1/service reports connection,
queue, staging, link-request, and WAL-checkpoint state. In VC3D: Stores → Attach Hosted
Store...
With vc_object_server running on the storage host, use File → Stores →
Attach Hosted Store... and paste ssh://dl.ash2txt.org:8750 or
sftp://dl.ash2txt.org:8750. Both mean the database service listening on the
remote host's loopback port 8750. The SSH login port comes from ~/.ssh/config
(normally 22); an optional user@ selects the login name. These service
addresses do not accept a .vcobj file path. Select the attached store under
Show Fibers From, Show Surfaces From, etc. as needed.
VC3D manages a local loopback-only tunnel, uses the existing SSH key/agent and
host-trust/password prompts, and saves the pasted address in the project.
Reopening the project reconnects; detaching releases the connection. After a
network failure that terminates SSH, reopen the project to reconnect. No
manual ssh -N -L command is needed. HostedStore and
ObjectStoreSession::attachLocation accept these addresses in C++ and Python too.
The transport regression can use a closed real store, copied into a disposable
directory. It starts a local sshd with temporary keys and configuration, tests
both schemes and a write through the service, and checks listener cleanup.
--gui also checks GUI-thread authentication and persisted-project reopening.
It requires installed OpenSSH ssh, sshd, and ssh-keygen; installs nothing:
cmake --build volume-cartographer/build --target VC3D vc_object_server_app test_object_store_ssh -j 4
python3 vc_object_store/tests/run_ssh_test.py --build volume-cartographer/build \
--store /path/to/closed-current-schema.vcobj --guiPython HostedStore(endpoint) mirrors the local store's forms: requests are
canonical request JSON (dict or text) with payloads keyed by SHA-256
(submit(request, payloads), receipt), queries are the wire form
({"selection": {...}, "fields": [...]}), and numbered references are
{store, generation, object, version} dicts (objects_at_versions,
relationships_at_versions, record_selection). Selected rows carry
version and version_generation; with_status=True also returns the
server snapshot's status. put_artifact, artifact_length, read_artifact,
and fetch_artifact(file, destination) (verified, atomic local file) cover
artifacts. Conflicts raise RuntimeError, malformed
requests ValueError, and select_all_objects / select_all_edges can
raise StoreBusy (see reading).
C++ clients use vc::objects::HostedStore
(client/HostedStore.hpp)
with the same types as the local API: status, submit, receipt,
changesSince, currentObjectRevision, currentVersion,
objectAtVersion, objectsAtVersions, geometryRevision,
readGeometryArray (read in ranges past the server's response limit),
putArtifact, artifactLength, readArtifact, fetchArtifact,
selectObjects/selectEdges (one page, limit required),
selectAllObjects/selectAllEdges, objectHistory, relationshipsAt,
recordSelection, geometryRegion, nearestSurfaces. Conflicts throw the
same TransactionConflict. Transport failures, 429, and 5xx are retried with
the same request UUID, so a lost reply is answered by the saved receipt;
when retries run out the outcome is unknown — recover it with receipt() or
by resubmitting the identical request, never under a new UUID. A
link_objects request is sent with linkTimeoutS (default 300 s, the
server's default --request-deadline) in place of stallTimeoutS, since
its reply waits for the detection; a retry of one joins the detection the
server is already running. If a request's
payloads have expired from server staging and no receipt answers it, the
server names the missing hashes; upload them and resubmit identically.
Most tools never touch the client classes directly: a store location is
a file path or an http(s):// endpoint, and the Python helpers
(surfaces.read_surface, surfaces.create_surface,
documents.attach_document, locations.attached(location)) work on either.
locations.open_store(location, author=, read_only=, link_timeout_s=, cache_directory=) yields the ObjectStore itself for a file and a
HostedStoreHandle for an endpoint: the same reading and request methods
(status, submit and receipts, the change feed, current and numbered records,
relationships at versions, geometry descriptors, arrays and annotations,
object and edge selections, pinned overlap correspondences) over one session
attachment, with cache_directory as its persistent cache, so one code path
reads, links and writes a store wherever it is.
vc::objects::ObjectStoreSession session;
vc::objects::OpenOptions options;
options.author = "reviewer";
const auto attachment = session.attach("objects.vcobj", options);
auto request = session.newRequest(attachment);
session.readStore(attachment, [&](const vc::objects::StoreReader& reader, const auto& cancel) {
if (cancel.load()) return;
request.expectedObjects[objectUuid] = reader.currentObject(objectUuid)->version;
});
request.operations.push_back(vc::objects::SetMetaOp{objectUuid, "review_note", R"("checked")"});
session.submit(attachment, request); // TransactionConflict when the object moved on- A session holds any number of attachments (a project file beside a hosted
team store), each with its own access mode, writer, and events; a store is
attached at most once.
attach,attachHosted(endpoint, options),attachLocation(path-or-endpoint)add one;replace(attachment, ...)swaps one store for another (validating the new database first, draining the old one's accepted writes, cancelling its readers);detachremoves one;attachments()lists them in attach order. Keep all work using a reader inside its callback, and do not reenter the session from a callback. Include the attachment generation in asynchronous requests and caches — even reattaching the same store invalidates old requests. - Requests are already committed when
submit()returns;finalize()waits for accepted writes on every attachment. The session also offerseditCurrent(attachment, uuids, operations)(read current, build, submit),copy_objectbetween attachments, ingestion into any writable attachment, and artifact publish/read per attachment (see writing). GeometrySnapshot(reader)builds the usual geometry views — transformed ones included — over a local read transaction or a hosted attachment. A local reader answers from one snapshot; a hosted reader answers per call from the attachment cache or the host (numbered versions and geometry revisions are exact; current heads may come from different commits). The reader and its views must not outlive the callback. Region and proximity queries stay session operations, combining attachments and drafts in attachment order.- Catalog geometry (Open Data surfaces) is fetched on read by the fetcher
registered for its meta key (
setGeometryFetcher, objects/CatalogStore.hpp), in any writable attachment. - Python mirrors this:
ObjectStoreSessionwithattach_hosted(itscache_directory,disk_cache_bytesandmin_free_bytesare the persistent cache below),hosted,attach_location,location,status,submit,receipt,current_object,objects_at_versions,relationships_at_versions,changes_since,select_objects,select_edges,read_surface_grid,read_geometry_arrays,geometry_revision,read_geometry_array,geometry_point_annotations,overlap_correspondences_at,create_surface,attach_document,attached_document,point_collections,artifacts,publish_artifacts,materialize_artifacts, and the draft calls below.
takeEvents() polls local and external commits of every attachment — call it
from the application's event loop. Each event is tagged by attachment; each
change carries its record's object or edge kind (kept for deletions), so a
consumer filters by kind without lookups. For hosted attachments a background
check every pollInterval reads the store's status and, when the commit
sequence moved, the change feed, delivering Committed events whose
journalPosition is the commit sequence. When the feed position is lost
(another history generation, a restored store), the event sets revalidate:
reread whatever is shown. refresh() checks now; freshness() reports the
last successful check and last failure. The attachment caches descriptors,
arrays by revision, records at numbered versions, and current heads until the
feed reports them changed, all within cacheBytes — cached data stays
readable while the host is unreachable, but listings and spatial queries
always need the host.
Underneath, every publishing transaction appends to one ordered feed:
status() (storeUuid, generation, commitSequence, feedStart) and
changes_since(cursor, max_changes) return whole commits (continuing inside
a commit only when it alone exceeds the page). A cursor from another
generation, the future, or before feedStart comes back invalid — revalidate
cached heads and rerun queries.
- Transforms resolve on the host. Selections never carry transforms:
volume targets use the store's recorded registrations, so a client catalog
cache cannot change a shared query; a request setting explicit transforms
is refused. Record shared registrations with
recordCatalogTransforms— see transforms. - Region queries run on the host, are not cancellable, and have their budgets clamped to server limits; the answer reports the budget it stopped at instead of claiming completeness.
- Listings page by UUID cursor without holding a server snapshot;
selectAllObjectscatches up through the change feed so the result is exact as of one commit sequence, and throwsStoreBusywhen writes never go quiet (length-ordered and unbound-volume listings page by offset and restart on any commit). - Large arrays are read in requested byte ranges; rectangular surface reads fetch only their row ranges rather than whole arrays.
- Session
selectObjectswith a limit gives each attachment up tolimitrecords not hidden by an earlier attachment, reading further pages as needed — a short result means there are no more.
Session surface drafts (see editing) live in
memory. useDraftDirectory(directory) makes them survive a crash:
checkpointDrafts()writes every draft (grid, coordinates, attachment store, andbase— the committed version it edits) and every pending submission, each changed draft to a new file, then replacescheckpoint.jsonatomically and syncs it. A crash or failed write (draftCheckpointError()) leaves the previous checkpoint complete; the application schedules checkpoints, separately from its saves. CalluseDraftDirectoryonce, before making drafts: it installs the last checkpoint's drafts and pending submissions (later edits are lost). A directory serves one session at a time.submitDurably(attachment, request, drafts)is a checked save that survives a crash: the exact request and payloads are checkpointed before sending. Success clears each listed draft that did not change after the call (a changed one stays, rebased onto the committed version). A conflict keeps the drafts and is recorded (draftState().conflict); nothing is resent — reapply by setting the draft again with the newbase. An unreachable host leaves the submission pending (pendingSubmissions(), also after restart) untilresolvePending()finds its receipt or resends the identical request.- The
DurableSaveform names each draft with the revision whose grid the request holds (a save copied on one thread while editing continues on another) andkeepDraftskeeps drafts open after success, moved to the committed version and marked saved (markDraftSaveddoes the same for a draft holding its base's committed geometry). Checkpoints skip saved drafts, so opening large surfaces writes nothing. A pending save recovered after a restart clears its drafts on success — the editor that kept them is gone.draftContents()returns a draft's grid and attachment for restoration.
JSON unless noted; every query answer carries the status of the single read
snapshot it came from. Payloads are staged by hash (PUT /v1/payloads/{sha256},
bytes, Content-Length required; 400 hash/length mismatch, 411 no length,
413 too large, 507 staging full) and a transaction names them by hash.
| Request | Answer |
|---|---|
GET /v1/status |
store, generation, commit sequence, feed start |
GET /v1/service |
connections, queue depths, staging use, WAL checkpoints |
POST /v1/transactions (request JSON) |
receipt; 409 conflict; 422 missing payload hashes; 400 invalid. A request with a link_objects operation is prepared on a read worker first |
GET /v1/receipts/{request} |
saved receipt or 404 |
GET /v1/changes?store&generation&sequence&offset&max |
feed page |
GET /v1/objects/{uuid} |
current revision + snapshot status |
GET /v1/objects/{uuid}/versions/{n}?store&generation |
that numbered revision or 404 |
GET /v1/objects/{uuid}/history |
every recorded revision, or 404 |
GET /v1/geometry/{revision} |
geometry descriptor (immutable) |
GET /v1/geometry/{revision}/arrays/{name}?offset&count |
array bytes; 413 above the response limit |
GET /v1/geometry/{revision}/annotations/{name} |
per-point annotations (exact JSON, immutable) |
PUT /v1/artifacts/{sha256} (bytes) |
kept durably by the store |
GET /v1/artifacts/{sha256}/info, GET /v1/artifacts/{sha256}?offset&count |
{sha256, length} or 404; artifact bytes |
POST /v1/query/objects, /query/edges (selection, fields) |
one page; limit required, at most maxQueryResults |
POST /v1/query/versions (versions) |
one revision per reference, in order, null where not recorded |
POST /v1/query/relationships (versions) |
selected relationships with applicable |
POST /v1/query/graph (traversal, object, edge_kinds, ...) |
object UUIDs reached from object |
POST /v1/query/region (slice, options) |
region runs, stop reason, stats |
POST /v1/query/nearest_surfaces |
surface hits |
POST /v1/query/count, /query/tags |
object count of a kind; distinct sorted tags |
ServerOptions sets the queues, body/payload/staging/response limits,
staging lifetime and directory, per-request deadlines, WAL checkpoint
scheduling, query page size, and region budgets; staged uploads stream to
disk and reserve their declared length up front, so a slow disk slows the
sender instead of filling memory.
HostedSessionOptions::cacheDirectory opts an attachment into persistent caching.
diskCacheBytes and minFreeBytes use the same vc_reader disk budget as volume
caches; leaving the directory empty preserves memory-only behavior. VC3D supplies
its configured remote-cache directory and limits automatically.
Numbered records, geometry descriptors, positional annotations, selected relationship revisions, geometry blocks, and attached-file blocks persist across restarts. Store UUID and history generation identify revision entries; SSH forward ports are never part of their identity. Geometry and artifacts use shared 1 MiB blocks for range reads and downloads. Atomic envelopes include SHA-256 checksums; materialized attached files are also checked against their manifests. Cache write failures leave interactive network reads usable. Heads, spatial queries, and freshness checks still require the service; this is not offline editing.
vc::objects::HostedSessionOptions options;
options.cacheDirectory = cacheRoot;
options.diskCacheBytes = int64_t(20) << 30;
options.minFreeBytes = int64_t(2) << 30;
auto attachment = session.attachHosted(endpoint, options);
auto job = session.prefetchObjects(attachment); // returns immediately
const auto progress = job->snapshot(); // thread-safe copy
// job->cancel();One sequential prefetch runs per attachment, yielding between bounded transfers to
interactive payload reads. It captures the current non-retired objects at numbered
versions, their associated relationships, and store-held payloads. It does not fetch
external CT datasets or materialize catalog placeholders. captured identifies the
selection's store generation and commit sequence. Progress reports discovery,
loading and coverage checking, object counts, payload bytes downloaded, errors,
and cancellation. complete means the required entries remained in the disk cache
when coverage was checked; later use of the shared budget can evict them.
Jobs belong to the session, independently of UI rows. Detaching cancels a job without waiting for its current network transfer. Completed blocks survive cancellation. Re-running skips valid blocks, including after a restart. Missing payloads, write failures, low free space, and eviction report incomplete coverage. VC3D's Project tab exposes this as Download Objects, Cancel, and, after new commits, Update Cache. Local store rows display Local.