XERJ implements a subset of the Elasticsearch snapshot API. A snapshot is a
recursive filesystem copy of one or more index directories into a repository
directory, plus a manifest.json at the snapshot root. A restore copies those
directories back and reopens the indices.
Restore is destructive. It deletes the live index directory before copying the snapshot back. Read Restore replaces index directories before you run one.
Everything below was read out of the source. The handlers are in
engine/crates/xerj-api/src/es_compat.rs, the engine work is
Engine::create_snapshot and Engine::restore_snapshot in
engine/crates/xerj-engine/src/engine.rs, the routes are in
engine/crates/xerj-api/src/router.rs, and the authorization rules are in
engine/crates/xerj-api/src/authz.rs.
| Method | Path | Handler |
|---|---|---|
PUT |
/_snapshot/{repo} |
register or replace a repository |
GET |
/_snapshot/{repo} |
read a repository config (* or _all returns all of them) |
DELETE |
/_snapshot/{repo} |
deregister a repository |
PUT |
/_snapshot/{repo}/{snapshot} |
take a snapshot |
GET |
/_snapshot/{repo}/{snapshot} |
read one snapshot's info |
POST |
/_snapshot/{repo}/{snapshot}/_restore |
restore |
GET |
/_cat/repositories |
plain-text name type lines, one per repository, no header row |
Those are all the snapshot routes the router registers (router.rs:485-497,
router.rs:763). There is no DELETE /_snapshot/{repo}/{snapshot}, no
_status, no _verify, no _cleanup, and no snapshot lifecycle management.
See What is not supported.
The body you send is stored verbatim under the repository name
(es_compat.rs:23138). Only one field is ever read back out: the string at
settings.location, which is the filesystem path the snapshot directory is
created under (es_compat.rs:23197-23201).
The repository name must not be empty and must not contain .., /, \, or a
NUL byte. Violations return HTTP 400 with "type": "mapper_parsing_exception" (es_compat.rs:23086-23098).
The location is bounded. validate_snapshot_path rejects any location that
contains a .. path component, and requires the location to canonicalize to a
path inside server.data_dir or inside one of the base directories listed in
limits.snapshot_repo_allowlist in the TOML config
(engine.rs:1971-1997, xerj-common/src/config.rs:960). The allowlist defaults
to empty, which means only data_dir is permitted. This is checked when a
snapshot is created or restored, not when the repository is registered, so a
bad location is accepted by PUT /_snapshot/{repo} and only fails later.
If settings.location is absent the handler falls back to
/tmp/xerj-snapshots (es_compat.rs:23201). That fallback is outside the
default data_dir and will therefore be refused by the location check, so
always set settings.location explicitly.
curl -sS -X PUT localhost:9200/_snapshot/backups \
-H "Authorization: ApiKey $XERJ_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{"type":"fs","settings":{"location":"./data/snapshots"}}'Response:
{"acknowledged": true}The repository registry is an in-memory map built empty at engine start
(engine.rs:257, engine.rs:405). Nothing reloads it from disk, so
repositories have to be registered again after a restart.
curl -sS -X PUT localhost:9200/_snapshot/backups/2026-08-04 \
-H "Authorization: ApiKey $XERJ_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{"indices":"logs-*"}'The body is optional. The only field read is indices
(es_compat.rs:23210-23217).
Since rc.10 the create handler parses indices as an array of names or
patterns, or as a single string that may itself be comma-separated:
{"indices": ["logs-2026-08", "metrics-*"]}
{"indices": "logs-2026-08,metrics-*"}Before rc.10 only the array spelling was read. The string spelling fell through
to "absent", which means "every index on the node", so {"indices":"logs-*"}
captured everything. Both spellings now parse the same way.
Engine::create_snapshot takes concrete index names, so the handler expands
patterns before calling it. It expands them over Engine::index_name_list()
(es_compat.rs:23225-23249), which filters through
xerj_engine::index_guard::visible (engine.rs:1250-1256). The middleware
installs the request's principal as that visibility rule
(authz.rs:1272-1273), so the set of indices captured is the set the caller is
allowed to see. Before rc.10 the wildcard reached the engine verbatim, matched
nothing, and produced an empty snapshot, so {"indices":["*"]} used to back up
nothing at all.
The matcher used on this path is glob_match_simple
(es_compat.rs:19590-19601). It handles exactly three shapes: * on its own,
a trailing wildcard (logs-*), and a leading wildcard (*-2026). Anything
else is compared as an exact string, so a mid-pattern wildcard such as
logs-*-2026 matches nothing here. _all and * both expand to every visible
index.
If indices is absent, the engine snapshots every open index except its own
internal ones. "Internal" here means a name starting with .xerj_
(is_system_index, engine.rs:1925-1927). The reserved agent-memory and
second-brain namespace uses the prefix .xerj-memory- with a hyphen
(xerj-common/src/types.rs:190), which is not .xerj_, so an unnamed snapshot
does include every brain on the node. That is exactly why an unnamed snapshot
is superuser-only; see Authorization.
For each selected index the engine flushes the memtable and then copies the
index directory recursively: WAL files, segment files, and the schema, settings
and es_mapping.json files (engine.rs:1669-1690). The flush happens
immediately before that index's own copy, inside the per-index loop, so indices
are captured at different instants. There is no cross-index point-in-time
consistency, and no lock stops writes arriving during the copy.
An index named in indices that does not exist on the node is skipped
(engine.rs:1670-1673), but it is still listed in the manifest, because the
manifest records the requested list rather than the copied list
(engine.rs:1657-1668, engine.rs:1692-1697).
The operation is synchronous. There is no wait_for_completion on create. The
response is {"accepted": true, "snapshot": <manifest>} where the manifest is
the same JSON written to manifest.json (es_compat.rs:23256-23260,
engine.rs:1692-1712):
{
"accepted": true,
"snapshot": {
"snapshot": "2026-08-04",
"uuid": "…",
"version": "8.13.0",
"indices": ["logs-2026-08"],
"state": "SUCCESS",
"start_time_in_millis": 1754265600123,
"end_time_in_millis": 1754265601047,
"duration_in_millis": 924,
"failures": [],
"shards": {"total": 1, "failed": 0, "successful": 1}
}
}The timestamps are real. start_time_in_millis is sampled before the flush and
copy work begins and end_time_in_millis after it finishes, so
duration_in_millis is the elapsed wall-clock time of the copy
(engine.rs:1655, engine.rs:1692, engine.rs:1702).
The shards counts are index counts, not shard counts: all three are derived
from the length of the index list (engine.rs:1704-1707). version is the
hardcoded string "8.13.0", which is the ES protocol version XERJ reports, not
a XERJ version.
The snapshot name is validated the same way the repository name is, and must
also not be . or .. (es_compat.rs:23105-23123).
curl -sS localhost:9200/_snapshot/backups/2026-08-04 \
-H "Authorization: ApiKey $XERJ_ADMIN_KEY"Returns {"snapshots": [<manifest>]}, or 404
index_not_found_exception if either the repository or the snapshot is unknown
(es_compat.rs:23265-23283).
This handler reads an in-memory map keyed by "{repo}/{snapshot}", populated
when the snapshot was taken in this process (es_compat.rs:23257-23258). After
a restart it returns 404 even though the files are still on disk. Restore does
not use that map: it reads manifest.json from the repository directory, so
restoring a snapshot taken before a restart still works.
There is no GET /_snapshot/{repo}/_all and no wildcard listing of snapshots;
the lookup is an exact key match.
curl -sS -X POST 'localhost:9200/_snapshot/backups/2026-08-04/_restore?wait_for_completion=true' \
-H "Authorization: ApiKey $XERJ_ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{"indices":"logs-2026-08"}'For every index it selects, the restore loop (engine.rs:1857-1892):
- removes the index from the live index map,
- calls
remove_dir_allon<data_dir>/<index>, - recreates the directory and copies the snapshot's copy back,
- reopens the index and reloads its persisted ES mapping.
Every document written to that index after the snapshot was taken is gone. So
is every mapping change. There is no merge mode, no dry run, and no way to
restore under a different name (see the rejected options below). If the reopen
in step 4 fails, the index is recorded in the engine's failed_indices map and
the loop continues to the next index (engine.rs:1895-1898), so the old data
is already deleted at that point.
The list of index names in the response is the set the filter selected. An
index whose snapshot directory was missing is skipped with a warning
(engine.rs:1836-1840) and one whose reopen failed is also skipped, but both
still appear in that list. Treat it as "what was attempted", not as proof of
success.
indices accepts the same two spellings as create: an array, or a single
string that may be comma-separated (es_compat.rs:23345-23356). Each entry is
split on commas again in the engine, and each resulting pattern is matched
against the index list in the snapshot's own manifest.json
(engine.rs:1765-1806).
The matcher on the restore path is glob_match (engine.rs:2066-2087), a full
wildcard matcher supporting * and ? anywhere in the pattern. This is a
different matcher from the one the create path uses, so a pattern that selects
nothing when taking a snapshot may still select something when restoring one.
- A pattern with no
*that matches nothing in the snapshot is an error: HTTP 400,"type": "search_phase_execution_exception", reason[{snapshot}] no index matches [{pattern}] in snapshot(engine.rs:1795-1802). A wildcard that matches nothing is not an error. - A wildcard never selects a
.xerj_*internal index unless the pattern itself starts with.(engine.rs:1786-1793). - With no
indicesat all, every index in the manifest except.xerj_*ones is restored (engine.rs:1775-1782).
Index names coming out of the manifest are revalidated with IndexName::new
before any filesystem operation, and the destination directory is checked to be
inside data_dir both lexically and after canonicalization
(engine.rs:1822-1834, engine.rs:1844-1855,
engine.rs:1866-1877).
These five options are refused with HTTP 400, "type": "illegal_argument_exception", reason restore option [{name}] is not supported by this XERJ version (es_compat.rs:23316-23343):
rename_pattern, rename_replacement, feature_states, index_settings,
ignore_index_settings
They fail loud rather than being ignored, because ignoring a rename would overwrite the source index instead of creating a copy.
Any other field in the restore body is ignored. The handler reads only
indices and the five names above, so include_global_state,
include_aliases, partial and friends have no effect and produce no error.
wait_for_completion is a query-string parameter and is compared to the exact
string "true" (es_compat.rs:23358-23361). wait_for_completion=1 and
wait_for_completion=TRUE both read as false.
The restore runs synchronously either way. The flag only picks the response
shape (es_compat.rs:23368-23386):
{"accepted": true}or, with wait_for_completion=true:
{
"snapshot": {
"snapshot": "2026-08-04",
"indices": ["logs-2026-08"],
"shards": {"total": 1, "failed": 0, "successful": 1}
}
}Again, those counts are index counts.
Snapshot and restore are the only two routes where an index pattern in a
request body is decided by the authorization middleware itself rather than left
to the engine's visibility guard (authz.rs:1143-1152). The reason is in the
code: create_snapshot walks the index map and restore_snapshot expands
against the snapshot manifest and then removes and rewrites index directories,
so neither passes through get_index or delete_index, which is where every
other body-named target meets the guard.
The rules, as decide and authorize_expression apply them:
-
Superuser skips all of it (
authz.rs:1291-1293). A principal is superuser when it presents the configured admin key, and also whenauth.enabledis false orauth.admin_api_keyis empty, which is the point-at-a-folder local posture (auth.rs:214-222). A superuser can back up and restore everything. -
Naming no indices at all is superuser-only. For any non-GET, non-HEAD request under
/_snapshot/with three or more path segments, an empty set of demanded indices is refused (authz.rs:1333-1358). That covers bothPUT /_snapshot/{repo}/{snap}with no body andPOST /_snapshot/{repo}/{snap}/_restorewith noindices, because both cover every index on the node, brains included. The 403 names the resource as<all indices>and the action ascreate_snapshotorrestore_snapshot. -
A pattern that may reach the reserved namespace needs an explicit grant.
may_reach_reserved(authz.rs:177-191) judges a pattern by the literal text before its first*: the expression qualifies if that prefix starts with.xerj-memory-or is itself a prefix of.xerj-memory-. So*,_all,.*,.xerj-*and.xerj-memory-alice*all qualify. For those, only a superuser, or a scoped key whoserole_descriptorsgrant that very pattern with the required privilege, is allowed through (authz.rs:1175-1193). An unscoped key is never allowed a reserved-reaching pattern on these two routes. -
Narrower patterns work normally.
logs-*does not qualify undermay_reach_reserved, so it is not refused and is not subject to the extra check. The ordinary per-tenant backup keeps working. -
Concrete names go through the normal per-index check, resolving aliases first (
authz.rs:1194-1202). -
Restore demands write, not read.
body_targetsrecords snapshotindicesasReadIndex, anddecideupgrades every demand toWriteIndexwhen the last path segment is_restore(authz.rs:895-905,authz.rs:1342-1347), because a restore overwrites what it names.
A refusal is an ES-shaped 403 naming the resource and the action, with the
grant that would fix it (authz.rs:242-262).
Note that "unscoped" is a real category here: a key with no usable
role_descriptors keeps its historical reach over ordinary indices but has no
privilege at all on .xerj-memory-* (auth.rs:99-106). And an explicit grant
is not second-guessed: a scoped key minted with names: ["*"] does satisfy the
check above, because * matches every index and that is what the operator
asked for. Only a superuser can mint such a key. To keep brains isolated, grant
concrete names or a prefix that excludes .xerj-memory-.
POST /v1/admin/backup calls the same Engine::create_snapshot
(native.rs:779-815). Its body has three optional fields: repo_path, name
and indices (native.rs:770-777). repo_path defaults to
<data_dir>/_backups and name defaults to backup-<uuid>. It returns HTTP
201 with the manifest under a manifest key.
Two differences from the ES route are worth knowing:
- It passes
indicesto the engine unchanged, with no pattern expansion. The engine looks index names up exactly, so a wildcard here matches nothing. - The authorization middleware classifies
/v1/...as a cluster route, not as a snapshot route (authz.rs:473-480,authz.rs:675-677), so the snapshot-specific pattern rule above does not apply to it. It falls through to the general cluster-mutation rule: a scoped key is refused, an unscoped key is allowed (authz.rs:1386-1396).
The repository location check still applies, since it lives inside
Engine::create_snapshot.
Confirmed absent from the code, not merely undocumented:
- Deleting a snapshot. The route table registers only
PUTandGETon/_snapshot/{repo}/{snapshot}(router.rs:491-493). Removing a snapshot means deleting its directory yourself. _status,_verify,_cleanup,_slm. No routes exist for any of them.GET /_snapshotwith no repository. The route is/_snapshot/:repo; useGET /_snapshot/_allorGET /_snapshot/*to list repositories.- Listing snapshots in a repository.
GET /_snapshot/{repo}/{snapshot}is an exact key lookup;_alland wildcards are not handled. - Anything but a local filesystem repository. The
typefield you send is stored and echoed by_cat/repositoriesbut is never acted on. There is no S3, GCS, Azure or HDFS implementation; the engine always does a local recursive directory copy. - Incremental or deduplicated snapshots. Every snapshot copies the full index directory. There is no shared blob store and no reference counting between snapshots.
- Global cluster state. Only per-index directories and the manifest are written. Index templates, ILM policies, cluster settings, enrich policies, transform pipelines and security keys are not captured, and are not restored.
- Restore rename, per-index settings overrides, and feature states. Rejected with 400, as listed above.
ignore_unavailable,include_global_state,partial,include_aliases. Not parsed on either verb.- Persistent repository and snapshot metadata. Both maps are in-memory and are lost on restart.
- Cluster-wide coordination. Both operations act on the local node's
data_dir. - Compression or encryption of the copy. Files are copied as they are on disk.
- Point-in-time consistency across indices, and any guard against concurrent writes or a concurrent restore. See What gets copied, and when.
docs/ARCHITECTURE.mdfor where the engine, storage and API layers sit.CHANGELOG.mdfor the rc.10 entry describing the snapshot authorization fix and theindicesparsing fix.