ProxySQL Time-Travel SQL
Query historical row state from any MySQL client by routing _flashback / _diff / _snapshot queries through ProxySQL to dbtrail
dbtrail-shim is a MySQL wire-protocol server that turns time-travel queries into reads against a local index DB populated from your MySQL binlog. Pair it with ProxySQL routing rules and any MySQL client — mysql, DBeaver, Metabase, an ORM, a BI tool — gets time-travel SQL with zero client integration.
SELECT * FROM _flashback.orders AS OF '2026-04-27 09:00:00' WHERE id = 42;
SELECT * FROM _diff.orders BETWEEN '2026-04-27 09:00' AND '2026-04-27 10:00' WHERE id = 42;
SELECT * FROM _snapshot.orders AT GTID '3E11FA47-71CA-11E1-9E33-C80AA9429562:42' WHERE id = 1;Beta — invite only
ProxySQL time-travel is in beta. The wire-protocol server, parser, schema cache, and safety guardrails are unit-tested, but end-to-end coverage against a live agent and production MySQL clients is still in progress. Expect rough edges, breaking changes between releases, and limitations called out at the bottom of this page. Do not depend on it for incident-time recovery yet — use the dashboard, MCP, or the /api/v1/recover endpoint for those flows. The bintrail CLI carrying the shim subcommand is not yet on the public release page; email support@dbtrail.com for a beta build and we will track your pilot setup.
Requires a local bintrail index (Pro plan or above)
The shim reads from a local bintrail_index MySQL schema that is populated by bintrail stream from your binlog. Both bintrail stream and the shim must run on a host with network access to MySQL — typically the same host as ProxySQL. This works on Local Agents deployments only — in Cloud mode the index runs inside dbtrail's VPC and is not reachable from your network. See the Local Agents guide for setup. Local Agents are available on Pro, Premium, and Enterprise.
When to use it
- Forensics over SQL. Investigators write
SELECT … FROM _flashback.t AS OF '<ts>' WHERE id = ?instead of learning dbtrail's CLI/API. - Pre-flight recovery. A "what did this row look like 5 minutes ago?" check becomes a one-liner against your existing
mysqlshell. - BI and ad-hoc tools. Metabase, DBeaver, JetBrains DataGrip — anything that speaks MySQL — can query historical state without a plugin.
- Composable with live SQL. With a follow-up release, you'll be able to JOIN a flashback row against a live lookup table; the beta keeps virtual-schema queries isolated for safety.
How it works
ProxySQL's mysql_query_rules route any reference to a virtual schema (_flashback, _diff, _snapshot) to the shim's hostgroup. Every other query flows untouched to your production MySQL backend.
The shim parses the time-travel clause, extracts the primary key from your WHERE predicate, and reads change events from the local bintrail_index DB to reconstruct row state. Results come back over the wire as a normal MySQL result set, so the client never knows it talked to anything other than MySQL.
SQL dialect
| Form | Backed by |
|---|---|
… FROM _flashback.t AS OF '<ts>' WHERE id = ? 1, 2 | row reconstruction at <ts> |
… FROM _flashback.t FOR SYSTEM_TIME AS OF '<ts>' WHERE id = ? 1, 2 | same (ANSI alias) |
… FROM _flashback.t WHERE id = ? (no AS OF) 1, 2 | current row state |
… FROM _diff.t BETWEEN '<ts1>' AND '<ts2>' WHERE id = ? 1, 2 | every change in [ts1, ts2] |
… FROM _snapshot.t AT GTID '<gtid>' WHERE id = ? 1, 2 | row state at that GTID |
1 The beta parser only recognizes a primary-key column literally named id. Tables with customer_id, order_id, uuid, etc. are not supported yet — see Limitations.
2 Single equality (= <literal>) is the only predicate shape served end-to-end. IN (...) and BETWEEN <a> AND <b> are accepted by the safety check but the row resolver currently handles single equality only.
AS OF NOW() - INTERVAL N MINUTE works — the shim evaluates the expression before reading the index.
_diff.<t> returns one row per change event with synthetic metadata columns (_event_id, _event_timestamp, _event_type, _gtid) followed by before_<col> / after_<col> pairs for every column in the table.
Primary key predicate is required
Every time-travel query must include WHERE <pk> = <value> against the table's primary key. Without a PK predicate the shim would have to reconstruct every row in the table at the target time — minutes-to-hours of work. The shim looks up the PK column from INFORMATION_SCHEMA.COLUMNS and matches the WHERE clause via AST, so customer_id, uuid, etc. all work — anything that's the table's actual primary key. Composite PKs are not yet supported.
Setup
You will install bintrail, initialize a local index, run a streamer that populates it from your MySQL binlog, drop in a shim config file, add a hostgroup and a few mysql_query_rules to ProxySQL, and restart nothing. The shim listens on :3308 by default.
Three processes are involved, not one
The shim only reads from the local index DB. Two other things have to happen first or the index stays empty and every _flashback query returns nothing:
bintrail init— creates thebintrail_index.binlog_eventsand related tables. One-time, idempotent.bintrail stream— tails the MySQL binlog and writes change events into the local index. Must run continuously.bintrail shim— the MySQL wire-protocol server ProxySQL routes virtual-schema queries to.
bintrail agent (the WS client that ships metadata to the SaaS dashboard) is not what populates the local index — it does not write to index_dsn. Skipping bintrail stream is the single most common reason a setup looks correct but every time-travel query returns empty.
1. Get a dbtrail API key
From the dashboard, Settings → API Keys → Create. The agent (if you run one) sends this as Authorization: Bearer …. See API Keys for scope and rotation details. The shim itself does not need an API key — it reads the local index directly.
2. Install the shim
The shim is bundled in the bintrail CLI as a subcommand — there is no separate binary to download. Install bintrail on the same host as ProxySQL and confirm the subcommand is available:
bintrail shim --versionIf you do not have bintrail yet, the dbtrail team will share a signed linux/arm64 build during beta — email support@dbtrail.com. Public release builds land on the dbtrail GitHub releases page once the feature exits beta.
3. Initialize the local index
Pick a MySQL instance to hold the index (commonly the same MySQL that serves your application — see the Local Agents guide for sizing) and run bintrail init once against it:
bintrail init --index-dsn 'bt_index:****@tcp(127.0.0.1:3306)/bintrail_index'This creates the binlog_events, schema_snapshots, index_state, and supporting tables that both the streamer and the shim depend on. Without it, both bintrail stream and bintrail agent in BYOS mode crash on startup with:
schema migration: add binlog_events.connection_id column:
Error 1146 (42S02): Table 'bintrail_index.binlog_events' doesn't existbintrail init is idempotent — safe to re-run after upgrades.
4. Start the binlog streamer
bintrail stream is the process that tails your MySQL binlog and writes change events into bintrail_index. It must be running before any _flashback query will return data.
bintrail stream \
--source-dsn 'bintrail:****@tcp(127.0.0.1:3306)/' \
--index-dsn 'bt_index:****@tcp(127.0.0.1:3306)/bintrail_index' \
--server-id 201 \
--start-file mysql-bin.000001 \
--start-pos 4 \
--schemas shopNotes:
--server-idmust be different from any other replica (including abintrail agentrunning on the same host — both processes connect to MySQL as replicas, and a duplicate ID will get one of them disconnected).--schemasfilters the binlog to the schemas you actually care about and keeps the streamer from trying to indexbintrail_indexitself. Without it you will see aFK cascade constraint foundwarning at startup (see Troubleshooting).--start-file/--start-pospoint at the first binlog position to read. For a brand-new setup,mysql-bin.000001/4is the beginning of the first binlog file; for a running database use the values fromSHOW MASTER STATUSat the moment you start.
Run it under systemd or your supervisor of choice — it stays in the foreground and must restart on crash.
Optional: also run bintrail agent
If you want the SaaS dashboard, forensic search, and recover to see this server, also run bintrail agent (with a different --server-id from the streamer). The agent is what powers the dashboard; the streamer is what powers the local index the shim reads. For a ProxySQL-only setup the agent is optional — bintrail stream + bintrail shim is enough.
5. Write shim.yaml
listen: 0.0.0.0:3308
# Operator safety knobs. Defaults are applied when fields are omitted.
limits:
# Cap on `WHERE pk IN (...)` list size. Each value becomes one
# index lookup. Default 1000.
max_pk_batch: 1000
# Cap on the BETWEEN span for _diff queries. Default 24h.
# Investigators can override per-connection by setting
# @dbtrail_allow_wide_diff = ON.
max_diff_window: 24h
tenants:
- mysql_user: acme_app
mysql_password: "cleartext-password" # MUST be cleartext — see below
source_dsn: "app_ro:****@tcp(127.0.0.1:3306)/acme_prod"
server_id: 11111111-2222-3333-4444-555555555555 # dbtrail SaaS server UUID — NOT a uint32The shim reads only these fields per tenant: mysql_user, mysql_password, source_dsn, and server_id. One tenants[] entry per MySQL user that should be allowed to issue time-travel queries.
Two different `server_id` fields — don't confuse them
This guide uses server_id (and --server-id) in two places with different meanings. Cross-checking these saves a confused-looking tenants[] lookup at startup:
bintrail stream --server-id 201(step 4 above) — the MySQL replication server-id. It's auint32(0–4,294,967,295) used by the MySQL replication protocol to identify this process as a unique replica. Pick any unused number; must differ from your source'sserver-idand from any other replica (includingbintrail agentif running).tenants[].server_idinshim.yaml(above) — the dbtrail SaaS server UUID that was assigned when you registered this MySQL host in the dashboard. The shim uses it to scope index lookups to your server. Find it on the server detail page in the dashboard URL (e.g./app/servers/11111111-2222-...).
source_dsn must include the schema in its `/<db>` path
The shim derives the default schema (what _flashback resolves against) from the /<db> suffix on source_dsn. In the example above, that's /acme_prod. If you omit it (e.g. tcp(127.0.0.1:3306)/), _flashback.<table> queries can't resolve to a real schema and will fail.
Legacy fields — two different failure modes
Older versions of this guide listed agent_url, agent_token, index_dsn, and logical_schema under tenants[]. They behave differently today, and the difference matters because one set keeps the shim running while the other prevents it from starting:
agent_url/agent_token— struct-tagged upstream, soyaml.UnmarshalStrictparses them cleanly and the shim ignores the values at runtime. Safe to leave in but adds noise; remove them for hygiene.index_dsn/logical_schema— no struct tag in the shim'sLoadTenantConfigs.UnmarshalStrictwill REJECT either as an unknown field and the shim will fail to start. Delete them before launching.index_dsnis now a CLI flag (--index-dsn); the schema comes fromsource_dsn's/<db>path (see the callout above).
mysql_password must be cleartext
ProxySQL's mysql_users.password accepts either cleartext or a *HEX… hash. The shim does not — tenants[].mysql_password in shim.yaml must be the cleartext password. If your ops process pre-hashes passwords for ProxySQL, you still need to store the cleartext in shim.yaml. Either lock down the file (chmod 0600, root-owned) or use a secret-management story your team is comfortable with.
What source_dsn is for
source_dsn is read-only and is used to look up column metadata for virtual tables from INFORMATION_SCHEMA.COLUMNS. A scoped read-only user against the live source schema is the safest choice. If you omit it, the shim falls back to reading column metadata from the index DB's schema_snapshots, which is correct for shared-tier setups but loses fidelity for dropped tables.
6. Start the shim
bintrail shim \
--shim-config /etc/bintrail/shim.yaml \
--index-dsn 'bt_index:****@tcp(127.0.0.1:3306)/bintrail_index'
# → logs a "shim listening" line on 0.0.0.0:3308 and stays in foregroundBoth flags are required. The flag is --shim-config (older drafts of this guide said --config, which the CLI rejects with unknown flag: --config). --index-dsn must match what you passed to bintrail init and bintrail stream.
7. Configure ProxySQL
Apply this against your ProxySQL admin interface (default port 6032). The snippet assumes you do not yet have a production hostgroup configured — if you already do, skip the first INSERT and reuse your existing hostgroup ID in default_hostgroup below.
-- Register your production MySQL backend (skip if you already have one).
-- Replace `mysql-prod` and the port with your real values.
INSERT INTO mysql_servers (hostgroup_id, hostname, port)
VALUES (10, 'mysql-prod', 3306);
-- Register the shim as a separate backend in hostgroup 99.
INSERT INTO mysql_servers (hostgroup_id, hostname, port)
VALUES (99, '127.0.0.1', 3308);
-- Register every user that should be allowed time-travel queries.
-- The username here MUST match a `mysql_user` entry in shim.yaml.
-- ProxySQL accepts either cleartext or a *HEX… hash for password here;
-- shim.yaml needs the cleartext regardless (see "mysql_password must be cleartext").
-- On MySQL 8.0+ / Percona 8.4 backends, this password needs special handling —
-- see step 7.5 below for the two options.
INSERT INTO mysql_users (username, password, default_hostgroup, active)
VALUES ('acme_app', 'plaintext-or-hashed', 10, 1);
-- Route virtual-schema queries to the shim. Earlier rule_id wins.
INSERT INTO mysql_query_rules
(rule_id, active, match_pattern, destination_hostgroup, apply)
VALUES
(10, 1, '(?is)\b(?:from|join|update|into)\s+`?_flashback`?\.', 99, 1),
(11, 1, '(?is)\b(?:from|join)\s+`?_diff`?\.', 99, 1),
(12, 1, '(?is)\b(?:from|join)\s+`?_snapshot`?\.', 99, 1);
LOAD MYSQL SERVERS TO RUNTIME;
SAVE MYSQL SERVERS TO DISK;
LOAD MYSQL USERS TO RUNTIME;
SAVE MYSQL USERS TO DISK;
LOAD MYSQL QUERY RULES TO RUNTIME;
SAVE MYSQL QUERY RULES TO DISK;mysql-multiplexing must stay on
ProxySQL's default. With multiplexing off, ProxySQL pins one backend connection per client and the shim only ever sees the first user that connected — every subsequent query gets resolved against the wrong tenant. If you've disabled multiplexing for unrelated reasons, the shim is not safe to run.
7.5. Auth plugin compatibility (MySQL 8.0+ / Percona 8.4)
The ProxySQL setup above hides a subtle handshake. ProxySQL authenticates your client against mysql_users, then opens a backend connection to your MySQL server as the same user. That backend handshake uses whatever auth plugin MySQL is configured for the user — and MySQL 8.0 and newer default to caching_sha2_password, which ProxySQL can only complete if it has the cleartext password for the backend user.
Follow the inline SQL above (or the SQL that bintrail proxysql-config generates) verbatim on a MySQL 8.x backend and you'll hit a silent failure on the first connect:
$ mysql -h 127.0.0.1 -P 6033 -u bintrail -p<PW> mydb
ERROR 1045 (28000): Access denied for user 'bintrail'@'localhost' (using password: YES)ProxySQL is using a SHA1 hash (the mysql_native_password format) where it needs the cleartext for the backend handshake. See dbtrail/bintrail#310. Which path you take depends on the MySQL version you're connecting to:
| MySQL version | Default auth plugin | What to do |
|---|---|---|
| MySQL 5.7 / Percona 5.7 | mysql_native_password | Nothing — works as-is. |
| MySQL 8.0 / Percona 8.0 | caching_sha2_password | Option A (switch to mysql_native_password) or Option B (cleartext override). |
| MySQL 8.4 / Percona 8.4 LTS | caching_sha2_password | Option B only — mysql_native_password was removed in 8.4. |
Option A: switch the bintrail user to mysql_native_password (MySQL 8.0 only)
mysql_native_password is deprecated in 8.0 but still supported. Some 8.0 installs ship without the plugin loaded — INSTALL it first, then ALTER. Run this against MySQL directly (not through ProxySQL), as a privileged user:
INSTALL PLUGIN mysql_native_password SONAME 'auth_native_password.so';
-- ERROR 1125 (plugin already installed) is fine — ignore it.
ALTER USER 'bintrail'@'%' IDENTIFIED WITH mysql_native_password BY '<password>';
ALTER USER 'bintrail'@'localhost' IDENTIFIED WITH mysql_native_password BY '<password>';
FLUSH PRIVILEGES;After this, the SHA1 hash that bintrail proxysql-config (or the inline SQL above) stored in mysql_users works end-to-end — no further ProxySQL changes needed.
Option B: keep caching_sha2, store the cleartext password in ProxySQL
Run this against the ProxySQL admin interface (port 6032), after the inline SQL or bintrail proxysql-config output has been loaded:
DELETE FROM mysql_users WHERE username = 'bintrail';
-- default_hostgroup must match what you used when you first registered the user:
-- * 10 if you followed the inline SQL in step 7 above
-- * whatever `bintrail proxysql-config` generated (commonly 990) if you used that path
INSERT INTO mysql_users (username, password, default_hostgroup, active, use_ssl)
VALUES ('bintrail', '<cleartext-password>', 10, 1, 0);
LOAD MYSQL USERS TO RUNTIME;
SAVE MYSQL USERS TO DISK;ProxySQL now holds the cleartext password and can complete the caching_sha2 backend handshake. The exposure is the same as the cleartext tenants[].mysql_password in shim.yaml — lock down the ProxySQL admin interface (and whichever bootstrap store seeded it) the same way you'd lock down the shim config.
Don't forget the shim itself
Once ProxySQL is talking to MySQL over caching_sha2, ProxySQL also reuses caching_sha2 when it opens its backend connection to the shim for any _flashback / _diff / _snapshot query. The shim's listener defaults to advertising mysql_native_password, so the handshake fails again — this time with 1045 Access denied from the shim, not from MySQL.
Add --auth-method=caching_sha2_password to the bintrail shim command from step 6 so the shim's listener advertises the matching plugin:
bintrail shim \
--shim-config /etc/bintrail/shim.yaml \
--index-dsn 'bt_index:****@tcp(127.0.0.1:3306)/bintrail_index' \
--auth-method caching_sha2_passwordSee dbtrail/bintrail#286. If you took Option A and left the user on mysql_native_password, leave this flag off — the default listener auth method is correct for that path.
8. Test
Connect through ProxySQL with one of the users you registered:
mysql -h <proxysql-host> -u acme_app -pSELECT * FROM _flashback.orders AS OF '2026-04-27 09:00' WHERE id = 42;If the row existed at that timestamp, you get its full state back, plus a synthetic _state column. See the DELETE-event handling note below for what the result looks like when the row was deleted before <ts>.
If you also need the time of the deletion or the deleter's identity, use SELECT * FROM _diff.orders BETWEEN '<earlier>' AND '<later>' WHERE id = 42 and look for an event with _event_type = 'delete' — _diff carries the full event metadata.
Authentication
There are two authentication hops, and they use different mechanisms — by design.
ProxySQL → shim. ProxySQL authenticates your client against mysql_users, then opens a backend connection to the shim hostgroup as the same username. The shim then re-authenticates against shim.yaml's tenants[].mysql_password (which must be cleartext — see the callout above) and binds the matching tenant to the session.
Shim → MySQL (read-only). The shim's only outbound connection is to the local MySQL index, plus an optional source_dsn read-only connection for INFORMATION_SCHEMA.COLUMNS lookups. There is no outbound HTTP call.
The implication for operators: every user authorized to issue time-travel queries must appear in both mysql_users (ProxySQL) and tenants[] (shim). A user listed in ProxySQL but missing from shim.yaml fails fast with unknown tenant for MySQL user "<name>" rather than crashing the connection.
Errors you might see
The shim translates upstream failures into specific MySQL error codes so your client sees something meaningful:
| What happened | MySQL error |
|---|---|
| Local index DB unreachable | 2002 (HY000): Can't connect to dbtrail index: <details> |
| Missing PK predicate, mixed JOIN, wide _diff, … | 1064 (HY000): <description> |
| Anything else (internal error) | 1815 (HY000): dbtrail-shim: <details> |
The connection stays open after any of these — your next query on the same connection can succeed if the issue was transient.
The shim itself does not impose a per-query timeout. ProxySQL is the source of truth via mysql-default_query_timeout and mysql-connect_timeout_server, so timeout policy lives in one place.
Troubleshooting
Table 'bintrail_index.binlog_events' doesn't existonbintrail streamorbintrail agentstartup. You skipped step 3 — runbintrail init --index-dsn '<dsn>'against the index DSN.unknown flag: --configfrombintrail shim. The flag is--shim-config, not--config.--index-dsn is requiredfrombintrail shim. The shim does not read the index DSN from yaml — you must pass it on the CLI._flashbackqueries always return empty.bintrail streamis not running, or it has a different--index-dsnthan the one you gave the shim. CheckSELECT COUNT(*) FROM bintrail_index.binlog_eventsdirectly — if it's 0, the streamer never wrote anything.FK cascade constraint found ... schema=bintrail_indexwarning atbintrail streamstartup. Benign — these are the index DB's own internal FKs (onaccess_rules), not your schema's. With a--schemas <your_schema>filter that excludesbintrail_index, the warning goes away.- Duplicate
server-idin MySQL replica list.bintrail streamandbintrail agentboth connect to MySQL as replicas — give each a unique--server-id, and make sure neither conflicts with other replicas you operate.
Limitations during beta
- Hint-comment form
/*+ DBTRAIL_AT='…' */is not currently supported. The shim rejects these withERROR 1235: this server only handles _flashback / _snapshot / _diff virtual-schema queries. Tracked at #1401. Until that ships, use… FROM _flashback.<t> AS OF '<ts>' WHERE …instead. - DELETE events return the pre-delete row state, not a tombstone. A
SELECT … FROM _flashback.<t> AS OF '<ts>' WHERE id = <id>against a row that was deleted at or before<ts>currently returns the row's last known state — there is no_state = 'deleted'marker. Older versions of this guide claimed the result was empty; that is also wrong. Tracked at #1400. Until that ships, cross-reference_diff.<t> BETWEEN <a> AND <b> WHERE id = <id>for an event with_event_type = 'delete'to determine whether the row was actually present at the target time. - Composite primary keys are not yet supported. Tables whose PK spans more than one column reject up front with
"composite primary keys are not yet supported". Workaround: use_diff.<t>and filter client-side, or split the query. - Dropped tables degraded for time-travel. Once a table is fully dropped from the source, the shim falls back to the bintrail index DB's
schema_snapshotsfor column metadata. That fallback doesn't carry primary-key information, so time-travel queries against dropped tables reject with"table has no primary key"._diffqueries that don't require PK lookup are unaffected. - Single equality predicate today:
WHERE id = <literal>.IN (...)andBETWEENare recognized by the safety check but the row resolver only handles single equality. - No prepared statements — the parser only handles literal queries. Most ORMs that emit
?placeholders will be rejected. - No mixed JOINs between a virtual schema and a real table (
_flashback.orders f JOIN customers c …). The shim rejects these explicitly. Federated planning is on the roadmap. - No INSERT/UPDATE/DELETE against virtual tables — recover is always read-only. This matches dbtrail's overall guarantee that the system never executes writes against your database.
- No multi-statement queries — one statement per round-trip.
_diffwindow cap of 24h by default. Investigators can raise it vialimits.max_diff_windowinshim.yamlor per-connection withSET @dbtrail_allow_wide_diff = ON(acceptsON|OFF|1|0; the override is cleared onCOM_RESET_CONNECTION).- Schema drift — if a column was added after the target timestamp, the reconstructed row will have NULL for that column.
If you hit a limitation that's blocking your use case, open an issue or email support — beta is the right time for us to hear about it.
Why a shim and not a ProxySQL plugin
ProxySQL has a Lua-like rewrite engine but no way to call out to a remote service mid-query. Routing to a backend that speaks MySQL is the well-trodden path — the same shape Vitess, PlanetScale, and other MySQL-protocol gateways use. The shim is a small Go binary and adds one extra hop on the time-travel path only; production traffic flows directly to your real MySQL through ProxySQL's normal routing.