Repository navigation
Store backends (SQLite + PostgreSQL) and remote storage (MinIO first): first integration phase #506
Description
Design doc: core/docs/issues/506/README.md (Store-first model, workflows, live path, layout, runtime calls).
LayerFS 2.0 areas A (Store backends) and R (remote storage), tracked on the 2.0 board. This issue covers the first integration phase of both, worked in one worktree by two sessions.
Owner direction (2026-10-29)
- Build PostgreSQL as a second Store backend next to SQLite. Each Workspace overlay stays its own local SQLite file.
- A PostgreSQL Store's
synchronous_commitis a setting, defaultoff, applied withSET LOCALin each write transaction. The SQLite Store stays Disposable only. - A remote holds the whole SQLite Store file. Push uploads it; pull downloads it and replaces the local Store. Atomic compare-and-swap on the remote is not required.
- The first remote provider is MinIO only. Other providers come later.
a. Persistence: SQLite and PostgreSQL behind one seam
Code: engine/crates/layerfs-persistence (and the open path in layerfs-store-service).
- Move the SQLite code under
backend/sqlite/, grouped by role:session/,lane/,objects/,metadata/,history/,storage/,acquisition/,lifecycle/. Today 12 of 17 files insrc/history/andstorage/{publication,merge_records,acquisition}call rusqlite directly. No behaviour change. backend/sqlite/snapshot.rs: one consistent copy of a live Store to a file (SQLite backup API). Push uses it. Land this early; part b depends on it.backend/postgresql/behind a Cargo feature, with the same role folders: TLS connection, profile (synchronous_commit), schema (configurable schema name), lane (SELECT … FOR UPDATEon the head row withlock_timeout; no SERIALIZABLE, no retry), objects (pack slices asbyteawithSTORAGE EXTERNAL,COPY BINARYbefore the lane, ranged reads withsubstring), metadata, history, storage.postgresql/import.rs: copy a SQLite Store into PostgreSQL. Init stays SQLite only, so a PostgreSQL Store gets its data from an Init or a pulled Store.- Later in this area, not this phase: the host SQLite cache that PostgreSQL compatibility for the global storage #420 found necessary (
postgresql/cache/), measured against SQLite.
Prior work: #420 and its study on branch issue420-postgresql.
b. Remote storage: MinIO first
Code: a new crate engine/crates/layerfs-remote-storage, plus layerfs-store-service/src/remote/.
- Crate core:
driver.rs(limits, upload_file, download_file, stat, delete),limits.rs(max file, max single request, piece size),pieces.rs(split a file larger than a provider allows into pieces plus a sha256 manifest),error.rs. local_folder/for tests.s3_api/: revive the retired one-attempt SigV4 client (da0fa3a120^:core/crates/layerfs-s3) and add multipart upload (create, parts, complete, abort) and ranged download. MinIO runs plain HTTP locally, so TLS can wait for R2 and AWS.minio/: the first provider.layerfs-store-service/src/remote/: named remotes (a credential reference, never the secret), push (snapshot → size check against the remote's limits → upload), pull (download to a temporary file → sha256 check → the existing sealed-Store install).
Design adjustments (2026-10-29, after reviewing the LayerFS Connect Storage page)
These keep the engine seam, the row-lock lane and whole-file remotes. They change how projects and remotes are organized around them.
- One Store holds many projects; a home holds many Stores (owner, 2026-10-29). A project is a named
layer_stackinside a Store; the format already scopes Branches, Layers and Commits by stack, and packs are Store-wide. Stores can be local SQLite files (one volume each), PostgreSQL schemas behind many named connections, or SQLite Stores pulled from remotes. Remotes are defined once per home and hold Store files at<remote prefix>/<store>/store.sqlite, plus piece manifests. Push, pull and clone work on a whole Store, so on all of its projects together. - Push and pull state lives outside the Store. Last push and pull (Store, remote, time, sha256, size) go in a small file in the home, keyed by Store, because a pull replaces the Store file.
- The home lists its Stores (
stores.json): each Store's kind, location (volume, or PostgreSQL connection and schema) and remote binding. Each PostgreSQL Store is one schema holding all of its projects. On PostgreSQL the lane locks the head row being published, so projects in one Store don't queue behind each other. On SQLite they share the Store's single writer. - Pull refuses while anything has the project's Store open: a daemon, a mounted Workspace, or an SDK or worker handle. On macOS the worker keeps a Store open for a quiet minute after use. Download to a temporary file on the Store's filesystem, so the swap is one rename.
- Push stages its snapshot beside the Store, inside the Docker volume on macOS. It checks for one Store-sized copy of free space first.
- The worker container must reach MinIO and PostgreSQL. On macOS, Store calls run in the home's Linux worker container, so endpoints go through
host.docker.internalor a shared Docker network, never the host'slocalhost. Credentials reach the worker asLAYERFS_REMOTE_*settings when it starts. LayerFS stores a credential reference, never the secret itself. - No HTTP Store server. Connect's worker connects to PostgreSQL directly. D12/D14 (cloud sandboxes over HTTP) stay open, and the rule against a Store server stands.
c. Store-first API layer and Connect wiring
Session c owns the LayerFS side:
- Init a project into an existing live Store (a new layer stack through the writer lane), and list and remove a Store's projects.
runtime.store.*and the other runtime calls in section 6 of the doc.- The binding, and forwarding through the worker.
The coordinating session owns Connect. The details below were written before the Store-first change; where they differ, the doc wins.
LayerFS, on this branch. Neither a nor b covers this layer.
layerfs-apiand thelayerfs-nodebinding:- Remote calls:
remote.add,remote.list,remote.remove,remote.status. project.push(remote)andproject.pull(remote), run as background jobs like Init (start_*andget_*).- Open a Store by
{ postgresql: { url, schema, synchronous_commit } }. project.import_to_postgresql.
- Remote calls:
@layerfs/runtime:runtime.remote.*.project.listandproject.get_statereport each project's Store location and its last push and pull.- The macOS and Windows worker forwards the new calls.
- Rebuild the worker's sealed Linux build with the
postgresqlandminiofeatures on.
LayerFS Connect (Ephemeral-AI-Lab/layerfs-connect, on main):
- Host:
- New
host/storage.jsholds remotes, push and pull jobs, and the PostgreSQL connection. - New routes in
host/server.js:GET /api/storage,POSTandDELETE /api/storage/remotes[/:name],POST /api/projects/:id/pushand/pull,GET /api/sync-jobs/:id,POST /api/storage/postgresql,POST /api/projects/:id/import-postgresql. - Secrets stay in Connect: the Keychain, or an owner-only settings file. They never go into the LayerFS home.
- New
- Types:
StorageView,RemoteInfoandSyncJobinsrc/shared/host-api.ts. - UI:
- "Add storage" opens a MinIO form: endpoint, bucket, prefix, access key, secret.
- The MinIO row becomes live and opens
remote-detail.tsx: the bucket, which projects have a copy, last push and pull, and Push and Pull with progress. - The "Local only" card shows "Copied to MinIO" once a copy exists, and "Sync with..." is enabled.
- The PostgreSQL row opens
postgresql-detail.tsx: the connection, its projects, and "Import a project". - The PostgreSQL row says "Live, direct connection" instead of "Live over HTTP".
- The Projects table gains Storage and Copies columns.
- Google Drive, S3, R2 and Folder stay "Not available yet".
docs/ui/layerfs-storage.mdis updated to match.
- Which LayerFS code Connect runs: Connect links
@layerfs/*from theconnect-mainworktree. To try this branch before it merges, setLAYERFS_CONNECT_APIto this worktree'sapi/. - Feature detection: until the API layer lands, Connect checks whether the new runtime calls exist and keeps the current "Not available yet" state when they don't.
Ownership in the shared worktree
- Session a owns
engine/crates/layerfs-persistence/and the open path inlayerfs-store-service/src/store/. - Session b owns
engine/crates/layerfs-remote-storage/andlayerfs-store-service/src/remote/. - Root
Cargo.toml,Cargo.lockandlayerfs-store-service/src/lib.rs: edit only your own lines, stage only your own hunks, and commit them promptly. - Each session commits only the files it changed. No rebase, reset, stash or branch switch.
Done when (this phase)
- One project runs on SQLite and on PostgreSQL with identical object, root and Commit ids.
- One Store holds two projects; a second Store and a PostgreSQL Store exist side by side.
- A SQLite Store pushes to MinIO and is cloned into a fresh Store with identical history.
- LayerFS Connect's Storage page lists every Store with its projects and drives push, pull, clone and import.
- A project pushes its Store to MinIO and pulls it into a fresh location with identical history.
- Every commit records production LOC per the repository rule. Deleted and moved tests are listed.
Related board items: layerfs-connect #11 (A), #27 (A1), #28–#31 (A2–A5), #12 (R), #37 (R2), #38 (R3), #39 (R4), #40 (R5), #67 (D1), #16 (D4). Related PR: #495 (row bundles for incremental push).
Activity
yifanxuaaa commentedon Oct 29, 2026
Updated the description with Design adjustments (home-level remotes, push and pull state outside the Store, where each project's Store is recorded, pull exclusivity, snapshot staging space, worker networking, no HTTP Store server) and a new part c. API layer and Connect wiring. Session b was sent items 1–6 directly. Progress so far: 514a79f (snapshot) and a9903e1 (SQLite code grouped under backend/sqlite).
yifanxuaaa commentedon Oct 29, 2026
Area R (remote storage, MinIO first): done for this phase — session b
Draft PR #507 (shared with area A). Commits
5586d8f6af,b55e317c6e,9c6ebef636. Description:core/docs/architecture/86-remote-store-files.md.What was built
engine/crates/layerfs-remote-storage(no first-party deps; onlysha2, already locked):driver.rs(limits,upload_fileof a file or span,download_filewhole/ranged into a writer,stat,delete),limits.rs,pieces.rs,error.rs,credentials.rs; featureslocal_folder,s3_api,minio.s3_api/: the retired one-attempt SigV4 client revived, plus streamed bodies, canonical query strings, multipart (create, equal parts 5 MiB–5 GiB, ≤10,000 parts, complete; one abort on any failure) and ranged GET.minio/: 50 TiB object, 5 TiB single PUT, 64 MiB default parts;LAYERFS_REMOTE_*settings or a credential reference.layerfs-store-service/src/remote/:settings.rs(named remotes, credential reference only),push.rs(OpenedStore::snapshotfrom session a → limits check → upload with sha256),pull.rs(download tostore.sqlite.pulling→ sha256 → opens with the project's binding → rename over the local Store).Checks
layerfs-remote-storage --test folder_pieces(5)layerfs-remote-storage --test minio --include-ignored(7) against pinned MinIOlayerfs-store-service --test remote_settings(2)layerfs-api --test remote_store:folder_round_trip+minio_round_trip-D warningson the touched packagesMinIO image:
cgr.dev/chainguard/minio@sha256:4692462f35d97d7e82c30371d82f057703c5d9489bcae726010594c812f2d285(RELEASE.2026-09-22T19-25-18Z). The community repo is archived; the image is used only for tests (tests/minio_service.sh).The MinIO tests cover:
Uncertain, part sent once, then aborted;PartsLeftand listed bypending_uploads.The Store round trip pushes a 12 MiB+ Store (multipart) with three Branches and four Commits, then pulls it into a fresh volume. Branches, Commit lists and file reads are identical. Also checked:
Decisions and deviations
-wal/-shmsidecars. On Linux they exist exactly while the Store is open, and pull only runs on Linux behind the kernel guard. There's no SQLite code in store-service. The check doesn't stop a new opener from arriving before the rename: the caller stops the project's daemons and SDK processes first.install.rs: kernel guard on the volume, run as the daemons' user (uid 0).installer_userbecamepub(crate). It doesn't callinstall_localitself, because that checks for an initial Branch with no Commit, and a pulled Store has history.Connection: close) rather than keep-alive. A failure then can't leak into the next request, and an abort after an uncertain part gets a fresh connection. Socket I/O has per-operation timeouts instead of one deadline per request, so multi-GB parts work.Remotes::load/save). There are no SDK methods orlayerfscommand for remotes yet.#[ignore]d and run explicitly againsttests/minio_service.sh.tools/worktree.py cargoruns Linux tests with--network none, so I ran the Linux MinIO round trip by starting the launcher-built test binary in a container on MinIO's Docker network. Same target volume and build image.production_loc.py's engine list.Not in this phase
Open item for area A
After
a9903e1286, the boundary guard reportsbackend/sqlite/lifecycle/file_control.rsas unsafe outside the audited path. Session a was told.Production LOC (R commits): 102997 → 104696 (+1699, new crate) → 105080 (+384, store-service
remote/) → 105080 (0).yifanxuaaa commentedon Oct 29, 2026
Owner direction (2026-10-29): one Store holds many projects, and a home holds many Stores (local SQLite Stores, PostgreSQL connections, remote sources). Design doc committed as 69e2909: core/docs/issues/506/README.md. Push, pull and clone now work per Store, and each PostgreSQL Store is one schema. Part c (Store-first API layer) goes to a third session. Progress so far: 514a79f snapshot, a9903e1 SQLite code grouped by role, 5586d8f remote drivers (folder, MinIO), b55e317 push/pull, 9c6ebef remote docs.
feat(remote): Store-named remotes per home, transfer state, open and …
yifanxuaaa commentedon Oct 29, 2026
Area R: Store-first adjustment (
6ad83a6027) — session bFollowing the parent session's design adjustment and the owner's correction: push and pull now work on a Store, with every project in it. A home can hold many Stores.
<prefix>/<store>/store.sqliteHOME/remotes(Remotes::home_path);store_object(name)givesNAME/store.sqlite, plus pieces[A-Za-z0-9._-], at most 128 characters, not starting with a dotremote::state:HOME/remote-state/NAMEholds remote, direction, time, sha256 and sizepushandpulldon't take the home, so the caller saves aTransferafter each completes. This keeps them usable without a homestore.sqlite.pullingbeside the Store, so the swap is one rename-wal/-shm, including the macOS worker, which runs in a Linux container. Nothing stops a new opener between the check and the renamepush(opened, store, driver, name)stagesstore.sqlite.pushingbeside the Store.PushFailure::Spacenames the directory, bytes needed and bytes freeSTORE_HEADROOM_BYTES), so a push can't starve the live Store. No automated test (it needs a full filesystem)_), IPv4 or bracketed IPv6, optional trailing/.minio_service.shalso prints the in-network endpointhttp://lfs506-minio:9000)Checks:
minio(7, against MinIO),folder_pieces(5), store-serviceremote_settings(3).remote_storefolder and MinIO round trips, now with two Stores on one remote, push/pull state recorded, and an open Store refused before any download.-D warningson Linux and macOS.Production LOC: 105080 → 105282 (+202).
feat(remote): clone a Store into a new one; push records the binding …
yifanxuaaa commentedon Oct 29, 2026
Area R: clone (
7247d277cd) — session bThis adds clone for the Store-first API layer (session c), following the design doc (
core/docs/issues/506/README.md§3, §6).remote::clone_store(driver, store_name, destination, cursor_key)pulls a Store into a new Store.cursor_keyis the new Store's own authority. This works because opening a Store checks only the binding.layerfs-bindingmetadata (hex). The binding isn't secret; the cursor key never leaves its holder. A pull now refuses a remote copy with another Store's binding before downloading anything.Expected::{Any, Absent, Sha256}, taken from the home's last transfer, must match the remote copy; otherwise push refuses withRemoteMoved. It's checked before the snapshot and again just before the upload.Checks: on Linux, the folder and MinIO round trips now include a clone with identical history, a refused clone over an existing Store, refused pushes after the remote moved (nothing uploaded), and a refused pull of another binding. Clippy
-D warningspasses on Linux and macOS.Production LOC: 105282 → 105436 (+154).
feat(persistence): PostgreSQL Store engine behind one seam (#506 A2)
feat(persistence): import a SQLite Store into PostgreSQL; identical i…
fix(persistence): keep entry files declarative; guard the moved FFI p…
yifanxuaaa commentedon Oct 29, 2026
Area A (persistence): fix record, session a
Branch
issue506-store-backends-remote, PR #507. Six commits, all on top of b's work:514a79fdcda9903e1286backend/sqlite/, no behaviour changec94749e591postgresql)ee7cd91365f84f1d25f53092604cb0The gap between
a9903e1286andc94749e591is b's remote commits. LOC is fromtools/production_loc.py, first parent vs. commit tree. No tests were deleted or moved.Layout at
f84f1d25f5engine/crates/layerfs-persistence/src/backend/only, usingtools/production_loc.py --files:backend/ [6815] admission.rs 118 (moved from sqlite/lane/) mod.rs 11 records.rs 158 + seam.rs 35 (new: Engine, Mechanics, HistoryStatements) + postgresql/ [1560] (new, #506 A2/A4) + import.rs 399 + mechanics.rs 51 + mod.rs 9 + schema.rs 88 + history/ [13] + mod.rs 1 + statements.rs 12 + lane/ [56] + lane.rs 54 + mod.rs 2 + metadata/ [169] + mod.rs 2 + reads.rs 123 + writes.rs 44 + objects/ [208] + mod.rs 2 + publish.rs 95 + read.rs 111 + session/ [468] + connection.rs 91 + mod.rs 5 + profile.rs 59 + query.rs 31 + rows.rs 62 + transaction.rs 220 + storage/ [99] + mod.rs 1 + provider.rs 98 sqlite/ [4933] (A1: grouped by role) + mechanics.rs 51 (new: SQLite behind the seam) mod.rs 10 schema.rs 295 + snapshot.rs 100 (new, #506) acquisition/ [1527] accounting.rs 74 cleanup.rs 190 entry_windows.rs 236 metadata.rs 41 mod.rs 11 provider.rs 246 (from src/storage/acquisition/) reads.rs 364 root_windows.rs 56 statements.rs 86 writes.rs 223 lane/ [124] lane.rs 42 mod.rs 3 writer_admission.rs 79 lifecycle/ [311] file_control.rs 30 headroom.rs 13 mod.rs 9 reclamation.rs 48 seal.rs 78 seal_allocation.rs 133 metadata/ [316] allocation.rs 19 locations.rs 129 mod.rs 5 policy.rs 18 signatures.rs 77 values.rs 68 objects/ [561] mod.rs 7 publish.rs 135 read.rs 65 selection.rs 83 unit_io.rs 70 unit_layout.rs 37 units_publish.rs 34 units_read.rs 130 session/ [1165] connection.rs 207 mod.rs 8 prepared.rs 166 profile.rs 104 query.rs 206 rows.rs 57 statement_work.rs 33 thread_work.rs 84 transaction.rs 300 storage/ [473] (merge_records from src/storage/) merge_records.rs 472 mod.rs 1What is in place
Handles::snapshot,StorageProvider::snapshotand store-serviceOpenedStore::snapshot.backup.step(-1).MechanicsandHistoryStatements(backend/seam.rs).hint_insert_bindingandstatistics_totals.postgres =0.19.14, TLS throughpostgres-native-tls/native-tls; all optional behindpostgresql).BackendSelection::Postgres(PostgresSelection { url, schema, synchronous_commit, lock_timeout_ms }).synchronous_commitcan be off (default), local, remote_write, on or remote_apply.SET LOCALin the same round trip asBEGIN.Busy. A lost COMMIT reply isUnknownand quarantines the session.byteaSTORAGE EXTERNAL, and ranged reads usesubstring.StoreService::open_postgres. The bridge hasProviderKind::Postgres(wire 2), which an install manifest refuses.PostgresHandles::import(sqlite_config, selection, binding, cursor).layerfs-persistence/tests/postgresql_identical_ids.rs).postgres@sha256:639ab7ceb90e13123085b741fb31ef493fba25463002f6da665352e7b534b652.Busyafterlock_timeout.postgresql_open.rsopens the imported Store throughStoreService::open_postgres.LAYERFS_TEST_POSTGRES_URLis set; otherwise they report as skipped.Deviations (my calls, under the owner's full authority)
store_lanerowFOR UPDATE. This matches SQLite (one writer per Store), so projects in one PostgreSQL Store still queue behind each other. README §2.2 asks for per-head locking.FOR UPDATEon head reads, plus a review of name checks, the scope-allocator CAS and lineage writes. These currently rely on the single writer.byteavalue (EXTERNAL, so TOAST slices it andsubstringreads only the range). Splitting into slice rows can follow if a pack goes over PostgreSQL's 1 GB value limit.mergegivesBackendUnavailable.schema_version, and the SQLite versions 7–12 do not apply there.StoreService::open_postgresskips the Linux kernel/filesystem guard that SQLite opening keeps, because the Store is not a local file.Handles::open_read_onlycannot open a sealed or snapshot Store (see finding 1). The import does not write to it; it reads from one consistent read transaction.src/history/andsrc/storage/publication.rshold no SQL, so they stay shared instead of moving underbackend/sqlite/. Writer admission moved up tobackend/admission.rs, where both engines use it.Findings
Handles::open_read_onlycannot open a closed WAL Store whose-shmis gone (a sealed Init output or a snapshot): it fails withUncertain. Opening read-write once fixes it. This matters for any read-only consumer of pulled or cloned files; session b's clone opens writable.$1::bigint + $2::bigint). Otherwise the shared history SQL fails withoperator is not unique. The changed statements carry the casts.Checks (macOS, at
f84f1d25f5)cargo +1.85.1 fmt --all --check. Atf84f1d25f5it missedtests/postgresql_open.rs, which CI reported;3092604cb0formats it.clippy -D warningson persistence, bridge and store-service, with and withoutpostgresql.740fd07615. An earlieruseless_conversionreport came from an in-between edit of b's that never landed.cargo check --workspace.tools/check_product_boundary.pyPASS, plus its 34 self-tests.Every test command ran under a 120 s wall limit. SQLite tests used the Disposable profile only.
Gaps and next
postgresqlfeature on Linux, native-tls included.PostgresHandles::binding, andremove_layerstackagainst PostgreSQL 17, in the Linux runtime image on Docker Desktop, not a bare Linux host.backend/postgresql/cache/, PostgreSQL compatibility for the global storage #420) is next. Without it a Commit takes several round trips.wasm-bindgenfamily bump inCargo.lock, throughgetrandom/js-sys. It is harmless on native targets; noted for review.feat(project): add a project to a live Store; list a Store's projects (…
style(store-service): rustfmt the PostgreSQL open test (#506)
feat(persistence): remove one project from a Store; read a PostgreSQL…
yifanxuaaa commentedon Oct 29, 2026
Area A: the persistence API that session c builds on (at
740fd07615)Everything PostgreSQL is behind the Cargo feature
postgresql(persistence, and store-service's feature that forwards to it).Select and open a PostgreSQL Store. A Store is one schema holding all of its projects.
PostgresSelection::new(url, schema)..with_synchronous_commit(SynchronousCommit::{Off (default), Local, RemoteWrite, On, RemoteApply}).lock_timeout_ms(default 30000).BackendSelection::Postgres(..).PostgresHandles::open_writable(&selection, binding, cursor_key)andopen_read_only(..).PostgresHandles { storage: Arc<PostgresStorage>, history: HistoryProvider }.PostgresHandles::binding(&selection) -> Result<Vec<u8>, PersistenceError>: the binding key a LayerFS schema records, for attaching a schema this home did not import.StoreService::open_postgres(..), the same open path as SQLite.OpenedStore::snapshotthere is SQLite only.Import.
PostgresHandles::import(sqlite_config, &selection, binding, cursor_key) -> Result<ImportReceipt { tables, pack_bytes }, _>.Remove a project.
HistoryCatalog::remove_layerstack(stack) -> HistoryResult<()>, on both engines.Missing(LayerStack). Other providers refuse it asUnsupported.Snapshot.
Handles::snapshot(dest)andOpenedStore::snapshot(dest) -> SealedStore, SQLite only.None of these signatures is planned to change. As recorded in the fix record above, the PostgreSQL lane still locks per Store, not per head row.
8 remaining items
feat(remote): Cloudflare R2 over HTTPS; TLS for the S3 API driver (#506…
yifanxuaaa commentedon Oct 29, 2026
Cloudflare R2 over HTTPS:
809c86fe9bThe S3 API driver now does HTTPS, and
cloudflare_r2/is the second remote provider.tlsfeature on remote-storage, store-service and layerfs-node. It is off by default, likepostgresql. It uses thenative-tlsalready pinned for PostgreSQL, so it adds no new package. The server's certificate is checked against the system's roots, and the TLS handshake happens before the request is sent. A refused certificate is thereforeUnavailable, with nothing sent.{ provider: 'r2', account, jurisdiction?, bucket, prefix? }. It is reached athttps://ACCOUNT[.eu|.fedramp].r2.cloudflarestorage.com, path-style, with regionauto. Its limits are 4.995 TiB per object and 4.995 GiB per single PUT; larger files use multipart.--features layerfs-node/postgresql,layerfs-node/tls.Checked:
tls.remote_settingspasses 4/4.minio.rspasses 8/8 over plain HTTP. It also passes 8/8 against a TLS MinIO started with the newMINIO_TLS_DIRoption. The four fault-proxy tests read plain HTTP, so in the TLS run they say they were skipped. A new direct multipart test covers multipart over TLS.curlgets the same.NOT_RUN: a real R2 account. This needs an account ID plus an R2 API token's S3 access key and secret, with Object Read & Write.
yifanxuaaa commentedon Oct 29, 2026
#506 d: Sandboxes on PostgreSQL Stores (plan)
Session d, branch
issue506-pg-sandboxfromissue506-store-backends-remote(809c86fe9b); merges back there (draft PR #507).Goal. A Sandbox's daemon serves a project whose Store is a PostgreSQL schema, so Workspaces mount, Exec runs and Commits publish into that schema, the same as on SQLite. Then Connect can run scenarios and live sessions on PostgreSQL projects.
Already in place.
layerfs-store-service'sopen_store_observedopens a PostgreSQL Store when itsPersistenceConfig.backendisPostgres(selection). The SDK's in-process Store calls already use it. The daemon only ever passesPersistenceConfig::sqlite(&setup.store), and the Docker layer always mounts a Store volume.1. How the daemon learns its Store is PostgreSQL
DaemonSetupgainspostgresql: Option<DaemonPostgres>, with{ url, schema, synchronous_commit, binding, cursor_key }. It cannot be set together withexisting_store. The config's magic goes fromLFSD\x05to\x06. The daemon and the binding come from one bundle, so no old reader exists.At startup the daemon builds
PostgresSelection::new(url, schema).with_synchronous_commit(..)and opens it through the sameopen_store_observed. The default 30 s lanelock_timeoutstays. The daemon then startsControlReady, as an existing-Store daemon does, with no install.store.record, the same ones the SDK's Store calls use.synchronous_commitis the Store record's value:offby default, or whateverattach_postgresqlset.layerfs-daemongets apostgresqlfeature that forwards tolayerfs-store-service/postgresql. The bundle builds with--features layerfs-node/postgresql,layerfs-daemon/postgresql. A daemon built without it refuses at startup withBackendUnavailable.2. Keeping the URL away from agent processes
The URL, password included, travels only inside the daemon's private config file. That file is uploaded through the Engine's archive API to
/layerfs-local/config/daemon.setup. The file is mode 0600, owned by the daemon's uid, in a 0700 directory. The daemon checks those modes before it reads the file.The URL is never placed in container env, argv, labels, the Sandbox record or a log:
Debugredacts it.So an agent process in the Sandbox cannot read the file,
/proc/<daemon>/{environ,mem,fd}, or raw packets. The host never writes the URL to disk.sandbox.createtakes it per call from the caller'spostgresql: { url }, the same field every Store call already carries fromLAYERFS_STORE_POSTGRESQL.3. Network reach
The daemon dials the host and port in the URL from the Sandbox container's default bridge network. On Docker Desktop (macOS, Windows, and the E2E's
linuxmode),host.docker.internalreaches a server published on the host's loopback. That is the same URL the worker and the client already use.On a Linux Docker Engine the URL must name an address that containers can reach, not
127.0.0.1. This phase adds noExtraHostsmapping and no second "daemon URL". It is documented and recorded as an open item.4. Overlay
Each Workspace overlay stays its own local SQLite file: MEMORY/OFF/EXCLUSIVE, copied from the template at mount and deleted at unmount, per the owner model. Only the global Store moves to PostgreSQL.
A PostgreSQL Sandbox mounts no Store volume.
SandboxRequest.store_volumebecomesOption<String>, andNonemeans the container has no mounts at all. The topology check on reopen then expects zero mounts, and the Sandbox record stores no volume.5. Demand-loaded reads and Commit round trips
This phase takes the cost and adds no host cache. The daemon's existing 64 MiB shared immutable-object cache covers PostgreSQL reads too, so repeat reads across Workspaces in one Sandbox stay local. A cold read or a Commit pays the round trips #420 measured (about 873 per Commit without a host cache). On a loopback or Docker Desktop link that is tens to hundreds of milliseconds per Commit.
The publish step itself is the existing per-Store
FOR UPDATElane: heavy work happens before it, and only the publish holds it. The host cache stays §8's open item (#420). The E2E prints the Commit's wall time as a diagnostic only, not as a gate or a measurement.Inherited limit: merges are refused on PostgreSQL in this phase. A Commit whose Branch moved since mount, and so needs a merge, fails instead of merging. This is the same limit the SDK path has today.
6. Runtime and binding calls
sandbox.create(binding). For a project of a PostgreSQL Store, the call reads the Store record and the call'spostgresql.url, and checks that the project is in the schema. It then starts the container with no volume andsetup.postgresqlset. The container name is<prefix><store>.<project>-<n>, labelledlayerfs.store.deploy.volumeis not needed. Withoutpostgresql.urlthe call is refusedInvalidInput.known(). No longer refuses PostgreSQL projects.get_state(name, since, { store, url })andsandbox.reclaim(name, { store, url })take the URL per call. Without it they are refusedInvalidInput.LAYERFS_STORE_POSTGRESQL, and closed after it. It is not cached for a minute, so the runtime never keeps the URL.@layerfs/agentwithLAYERFS_STOREandLAYERFS_STORE_POSTGRESQLcansandbox.create,workspace.mount/exec/commit/unmount, andsandbox.close, with deployment settings as for SQLite.Done when
api/runtime/test/verify/stores_real.mjsgains a step on the imported PostgreSQL project. It runssandbox.create, mountsfeature, runs one command, Commits, and unmounts. It then reads the Commit back through the PostgreSQL Store (branch.list_commitsandfs.read). The step runs onlinuxand, if time allows, onmacos.Every commit carries its production LOC line.
Owner decisions needed
None found. The plan stays inside the owner model:
postgresandnative-tlsare already pinned);feat(daemon): Sandboxes on PostgreSQL Stores (#506 d)
yifanxuaaa commentedon Oct 29, 2026
#506 d: Sandboxes on PostgreSQL Stores — done
Commit
757b91b48eonissue506-store-backends-remote(fast-forward from809c86fe9b), draft PR #507. Built as the plan says. No owner decision was needed.What works now. A Sandbox's daemon serves a project of a PostgreSQL Store.
sandbox.create,workspace.mount,exec,commit,unmountandsandbox.closeall behave as on SQLite. A Commit publishes into the schema, and the SDK reads it back.How it is built
DaemonSetupgainspostgresql: Option<DaemonPostgres>, holding the URL, schema,synchronous_commit, binding and cursor key. It cannot be set together withexisting_store, and the config magic is nowLFSD\x06. The URL lives only in the daemon's private config file (0600, daemon uid, 0700 directory, non-dumpable daemon).Debugredacts it.open_store_observedand startsControlReady, with no install and no Store-file checks.postgresql/<version>. The status wire allows one printable word, and PostgreSQL'sserver_versioncontains spaces; this was the first E2E failure.layerfs-daemon/postgresql.SandboxRequest.store_volumeis nowOption.Nonemounts nothing, and the reopen topology check then expects zero mounts.sandbox.createon a PostgreSQL Store reads the home's Store record and the call'spostgresql.url. It checks that the project is in the schema and makes its home entry (owner-only).LAYERFS_VOLUMEis needed.get_state(name, since, { store, url })andsandbox.reclaim(name, { store, url })take the URL per call. Without it they are refusedInvalidInput.Checks
stores_real.mjs linuxfeature, exec, Commit, read back (Commit count +1, file content). The step also asserts that/proc/mountshas no/layerfs-storeand that no command output holds the password. Commit wall time 158 ms (diagnostic only).stores_real.mjs macos(worker)-D warnings, both features,--all-targetsdaemon_setup_records), sandbox (incl. no-volumeengine_protocolcase), node, apisandbox_operations: all pass.native_admissionwas not run (owner-excluded).callsstorescase gains the PostgreSQLget_stateURL check.check_product_boundary.pyThe bundle was
build-linux-d20dfdc3a53f4071ab7bbcfbed1405da, built with--features layerfs-node/postgresql,layerfs-daemon/postgresql, runtime imagesha256:afa1ef9c…, PostgreSQLpostgres:17-bookworm(sha256:639ab7ce…). Load average was 4.5–4.9.Not run: the daemon's FUSE mount-lane tests (
layerfs-daemon, the API mount targets). The E2E covers the daemon on both Store kinds.Production LOC: 109,626 → 109,881 (delta +255). By scope:
The reference is absent (retired R9c; last counted 65,417 at tree 498dd1917812). Counted with
tools/production_loc.pyongit archiveof the parent and of the commit.For Connect
{ url }toruntime.project.get_stateandruntime.sandbox.reclaimfor PostgreSQL projects.LAYERFS_STORE_POSTGRESQL, asopen()already does.Open (in §8):
host-gatewaymapping is added.feat(persistence): merges on PostgreSQL Stores (#506 d)
perf(persistence): fewer PostgreSQL round trips per Commit and read (#…
docs(506): PostgreSQL round trips and reaching a server container (#506…
yifanxuaaa commentedon Oct 29, 2026
#506 d: Connect scenarios on a PostgreSQL Store — all 11 pass
Commits on
issue506-store-backends-remote(fast-forward from757b91b48e, draft PR #507):50335fd5d4feat(persistence): merges on PostgreSQL Stores. A merge's working records live inpg_temptables of the writer connection. Production LOC 109,881 → 110,184 (+303).207553a84aperf(persistence): fewer PostgreSQL round trips. Production LOC 110,184 → 110,381 (+197).a73d07c90adocs(506): doc §6 note. Production LOC 110,381 → 110,381 (delta 0).The reference is absent (retired R9c; last counted 65,417 at tree 498dd1917812).
Why depot and crew ran past 15 minutes
I profiled a copy of the Store with
pg_stat_statements. The time was round trips, about 0.75 ms each through Docker Desktop'shost.docker.internal. The causes:binding_hintinserts and 9,532file_digestlookups.BEGIN+SET LOCAL+COMMITaround every immutable read.One Commit issued 39,555 statements, and one
fs.applytook 95.8 s.Fixes
unnest) statements on PostgreSQL: up to 1,024 rows per statement, and one statement per 64-content digest window. When a digest repeats, the later root wins, as it would one row at a time. SQLite keeps its per-row statements.host.docker.internal.Result: the worst
fs.applywent from 95.8 s to 0.4 s. One Commit went from 39,555 statements to about 3,300.Runs (Connect test host, macOS worker, one sample each)
Setup for these runs:
build-linux-f0296dc4de7f4cb3bd47a51dbcad5b73at207553a84a, built withlayerfs-node/postgresql,layerfs-daemon/postgresql.synchronous_commit=on.SQLite vs PostgreSQL with the debug bundle. I ran
depoton PostgreSQL and on an empty SQLite project side by side, with the same debug bundle and host. Both reached exactly 53/98 steps at 702 s, and I stopped both. So with the debug bundle on this loaded host,depotmisses 15 minutes on either engine; it is not a PostgreSQL limit.Checks
hints_and_digests_in_arrays_read_back_as_on_sqlite: 1,510 hints, 1,100 digests across the 1,024-row boundary, and a missing root. It passes, as do the existing PostgreSQL tests.layerfs-persistencecrate tests pass, all 40 binaries.-D warningsis clean for persistence and store-service withpostgresql.storage.test.jspasses 13/13, including a new route test.Open
feat(remote,persistence): progress, listing and heads for Connect Sto…
feat(store-service,binding): heads, copy checks, listing, progress, P…
feat(runtime): Store sizes, heads, copy checks, listing, progress and…
style(remote): rustfmt the MinIO listing test (#506)