Repository navigation
feat(dgw): keep agent tunnel data in a shared gateway.db - #2039
Conversation
Gateway features that need to store a little data now share one database, gateway.db, with tables named after the feature, instead of one file per feature. The job queue keeps its own file. The new gateway-db crate opens the file, runs one migration list and hands out a connection. The agent tunnel tables get an agent_tunnel_ prefix. gateway.db is opened on every start, and the agent tunnel CA is loaded, not generated, once the database is bound to a CA. On the first start after 2026.3, agent_tunnel.db is copied to gateway.db and then removed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Let maintainers know that an action is required on their side
|
A cloned libsql connection shares one SQLite handle, so two features using clones would end up inside each other's transactions. GatewayDb now hands each feature a connection of its own, and migrations re-read the schema version under the write lock so two connections can open at once. The CA is bound in a transaction, and a database that has Agents but no CA row is refused instead of being tied to a new CA. Gateway warns when an old agent_tunnel.db is left next to gateway.db. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Legacy data is deleted before the copied database is durably synchronized, risking corruption during power loss.
Review effort: Balanced
Findings: 1
What changed in this PR
Moves Agent tunnel persistence into the shared gateway.db, centralizing schema migrations while retaining a dedicated connection per feature.
Changes:
- Adds
gateway-dbwith migration and legacy-database adoption support. - Migrates Agent tunnel tables and queries to prefixed shared tables.
- Updates Gateway startup and tests for shared database connections.
| File | Description |
|---|---|
Cargo.lock |
Registers new dependencies. |
crates/gateway-db/Cargo.toml |
Defines the database crate. |
crates/gateway-db/src/lib.rs |
Implements connections, migrations, and legacy adoption. |
crates/gateway-db/migrations/01_agent_tunnel.sql |
Creates the legacy-compatible schema. |
crates/gateway-db/migrations/02_prefix_agent_tunnel_tables.sql |
Prefixes Agent tunnel tables. |
crates/agent-tunnel-libsql/Cargo.toml |
Adds test dependencies. |
crates/agent-tunnel-libsql/src/lib.rs |
Uses shared connections and prefixed tables. |
devolutions-gateway/Cargo.toml |
Adds the database dependency. |
devolutions-gateway/src/service.rs |
Opens the shared database and selects the CA. |
testsuite/Cargo.toml |
Adds the test dependency. |
testsuite/tests/agent_tunnel/common.rs |
Uses an in-memory shared database. |
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The agent_tunnel_* tables are created by gateway-db's migrations, so the code reading them now lives next to them as gateway_db::agent_tunnel. The agent-tunnel-libsql crate is removed.
…ld one VACUUM INTO does not flush the copy it writes, so a power loss after the old files were removed could leave only a partial gateway.db. The copy is now flushed, and the rename waits until it is on disk (directory fsync on Unix, MOVEFILE_WRITE_THROUGH on Windows) before the old files go.
Benoît Cortier (CBenoit)
left a comment
There was a problem hiding this comment.
Thanks, Irving!
gateway.db, the agent_tunnel_ table prefixes, and moving the old agent_tunnel.db, all of that sounds good to me, let’s keep it.
One thing to change before merging: please keep the crate split we agreed on in Slack: move crates/gateway-db/src/agent_tunnel.rs back into agent-tunnel-libsql. LibSqlAgentAuthorizationStore::open already takes a Connection, so it doesn't need much from gateway-db.
Two reasons:
- Separation of concerns:
agent-tunneldefines the operations andagent-tunnel-libsqlimplements them with libsql. That's the same layout asjob-queue/job-queue-libsqlandtraffic-audit/traffic-audit-libsql.gateway-dbshould only own the shared file. - Compilation speed: if
gateway-dbholds every feature's store, it depends on every feature crate. It can't start building until all of them are built, and a change to any one of them rebuilds every store. With separate adapters, the stores build in parallel and a change only rebuilds the affected adapter.
Stemming from that, I want to introduce a dependency rule: gateway-db must never depend on crates that implement feature behavior, such as agent-tunnel. Adapters may depend on gateway-db if they need to, but never the other way around. Please add this to the invariants in crates/gateway-db/INTENT.md and/or in a local crates/gateway-db/AGENTS.md file.
About schema versions and the 2026.3.0 .db file: once the store is back in its own crate, one PRAGMA user_version for the whole file stops working, because no single crate owns every migration. Let's drop it:
gateway-dbdoesn't read or writePRAGMA user_version. It opens the file, applies the PRAGMAs, and hands out a connection per feature.agent-tunnel-libsqlkeeps its own migrations and tracks its own schema version within its tables, for example as a row inagent_tunnel_metadata. Its first migration creates the prefixed tables directly.- The upgrade from 2026.3.0 is based on the file name: only 2026.3.0 wrote
agent_tunnel.db, and it only ever had one schema. Whenagent_tunnel.dbexists andgateway.dbdoesn't, copy it, convert the copy (add the table prefixes, write the agent tunnel's version row, setuser_versionto 0), then flush it and rename it into place as you do today.gateway.dbthen only ever exists in the new layout, and the store needs no code for old databases.
Things to watch in the conversion:
- Check the old file's
user_versionfirst. 1 means convert. 0 means a first start that failed before creating the tables, so there's nothing to import. Refuse anything else. - Write to the copy with
journal_mode = DELETE(or checkpoint and close the connection), so the changes are in the file itself before the rename and not in a-walfile left behind. - When redoing an interrupted move, also delete the copy's
-journal,-waland-shmfiles. A leftover journal would be replayed into the new copy. - This code writes agent tunnel tables, so it belongs in
agent-tunnel-libsql. Call it fromservice.rsbeforeGatewayDb::open, because oncegateway.dbexists, the old file is ignored. It can use the flush and rename helpers fromgateway-db.
Each feature's version then covers only its own prefixed tables, which is what we said in Slack: the table prefix is the compatibility boundary.
Last, I would like to record the long-term idea about how we will deal with migrations in the futer, please, add a not in the INTENT.md file (but do not implement in this PR):
Later, `gateway-db` could take migrations from each adapter.
An adapter would pass its prefix and its list of migrations, and `gateway-db` would run them and track a version per prefix.
gateway-db now only owns the shared file: it opens gateway.db, applies the PRAGMAs and hands a connection to each feature. It no longer depends on agent-tunnel, and no longer reads or writes PRAGMA user_version, since no single crate owns every table. agent-tunnel-libsql runs its own migrations again, creates the prefixed agent_tunnel_* tables directly, and records its schema version as the schema_version row of agent_tunnel_metadata. The 2026.3 upgrade moves to agent-tunnel-libsql and runs from service.rs before gateway.db is opened. It checks the old file's user_version (1 converts, 0 removes the empty file, anything else is refused), converts a copy with journal_mode = DELETE, adds the table prefixes and the version row, clears user_version, then flushes and renames it into place. Redoing an interrupted move also removes the copy's -journal, -wal and -shm files. A local AGENTS.md tells agents to keep gateway-db free of feature crates.
The conversion now checks that the copy really switched to a rollback journal before renaming it, a 2026.3 file with tables but no schema version is refused instead of removed, and missing-file checks report I/O errors. The warning for a leftover agent_tunnel.db no longer claims its Agents were not imported, since a crash right after the rename leaves the file behind with its Agents already moved. The migration and the conversion record the schema version the same way, and an up-to-date migration ends its transaction explicitly. New tests cover Agents still in the 2026.3 WAL and the renamed file being in rollback journal mode with no journals left behind.
|
Updated as instructed
|
Only 2026.3.0 wrote agent_tunnel.db; 2026.3.1 will use a different format. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Benoît Cortier (CBenoit)
left a comment
There was a problem hiding this comment.
Thank you,
The PR description states that the agent tunnel tests were not re-run after the split, and the real binary wasn’t run. Can you verify that before merging, please? Run for fresh install, upgrade from 2026.3.0 and restart.
The current behavior is: the import runs on every start, even with the agent tunnel off.
The side effect: an unreadable or unexpected agent_tunnel.db now stops the Gateway even when the tunnel is disabled.
I think we could legitimately remove this when the agent tunnel is disabled, how does that sound? Would you prefer the current logic?
Approving now for velocity
e8ac5ef
into
master
|
Ran it for real on a lab Windows Gateway with the CI MSI: headless 2026.3.0 install with enrolled Agents, upgrade to this build, restart, then a fresh install. pr2039-gateway-db-e2e.mp4Found one problem on the way: the first start after the upgrade can crash because of a libsql double close, tracked in #2070. Update: closed #2071. It's wrong either way: skipping or tolerating the import while the tunnel is off can leave the old data behind for good. So we keep the current logic: if the import fails, the Gateway doesn't start, the data stays intact, and it gets fixed by hand if needed. Note Human-tuned, LLM-assisted content. |
libsql 0.9.30 closes a local connection twice when it's dropped ([tursodatabase/libsql#2251](tursodatabase/libsql#2251)). Since #2039 Gateway drops connections on startup, so some upgrades crash with `0xc0000005`. libsql 0.10.0 ships the upstream fix ([tursodatabase/libsql#2261](tursodatabase/libsql#2261)), so this moves Gateway to it. 0.10.0 also updates the bundled SQLite from 3.45.1 to 3.47.0. The database file format doesn't change, so existing databases are unaffected. Fixes #2070 Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Benoît CORTIER <git.divisible626@passmail.com>

Moves the agent tunnel tables into
gateway.db, the shared database we talked about on #1958, so the next feature that needs a table doesn't add another.dbfile. The job queue and the traffic audit keep their own files.What changes
gateway-dbcrate. It only owns the file: it opensgateway.db, applies the PRAGMAs and hands each feature its own connection (GatewayDb::connect). It doesn't depend on any feature crate and never touchesPRAGMA user_version.agent-tunnel-libsqlstays the agent tunnel's adapter (same layout asjob-queue/job-queue-libsql). It runs its own migrations on the connection it gets: its first migration creates the prefixedagent_tunnel_*tables directly, and its schema version is theschema_versionrow ofagent_tunnel_metadata.agent_tunnel_libsql::import_2026_3_database, called fromservice.rsbeforeGatewayDb::open): whenagent_tunnel.dbexists andgateway.dbdoesn't, the old file'suser_versiondecides:1: copy it withVACUUM INTO, convert the copy (journal_mode = DELETE, add the table prefixes, write the version row,user_version = 0), flush it and rename it into place, then remove the old files.0: a first start that failed before creating the tables, so the empty file is removed.-journal,-waland-shmfirst.crates/gateway-db/AGENTS.mdtells agents to keepgateway-dbfree of feature crates.Behavioral changes (please confirm)
agent_tunnel_metadataholds a CA fingerprint. Before, this was decided by whetheragent_tunnel.dbexisted, which no longer works now that the file is shared. A DB that has Agent data (accepted, deleted or enrollment rows) but no CA row is refused.gateway.dbis created on every start, agent tunnel on or off. The agent tunnel tables are only created when the tunnel is on.agent_tunnel.db. If they upgrade again, the leftoveragent_tunnel.dbis ignored with a warning.Why this shape
gateway-dbonly owns the file: the stores build in parallel, and a change to one feature only rebuilds its adapter.user_version: no single crate owns every table, and the table prefix is the compatibility boundary.Connectionshares one SQLite handle, so features would land inside each other's transactions.Where to look hardest
crates/agent-tunnel-libsql/src/upgrade.rs: the 2026.3 conversionmigrateandbind_caincrates/agent-tunnel-libsql/src/lib.rs, and the CA load/generate check inservice.rsTested
agent-tunnel-libsql23/23 (incl. 7 upgrade tests: a 2026.3 file converted then authorizing its Agent, renamed in rollback journal mode with no journals left, Agents still in the 2026.3 WAL, an interrupted move with a stale journal, an existinggateway.dbkept, an empty 2026.3 file, version 0 with tables and an unknown version refused) andgateway-db3/3. The testsuite builds, but its agent tunnel tests weren't re-run after the split. The MSI was run on a lab Windows Gateway: fresh install, upgrade from 2026.3.0 with enrolled Agents, and restart. The import worked and the Agents reconnected without re-enrolling, but the first start after the upgrade can crash because of a libsql double close (#2070).On Windows, the libsql test binaries sometimes crash with
STATUS_ACCESS_VIOLATIONwhen tests open databases in parallel. It also happens before this change (2 of 40 runs on the oldgateway-db), and it's the same crash CI hit intests/traffic, likely #2070.🤖 Generated with Claude Code