skaidb wire protocol

This is the canonical specification every official driver implements. It describes the binary fast-path protocol spoken on the server's quic_port (default 7000, raw TCP today). A simpler HTTP/JSON gateway also exists on rest_port (default 7080) and is documented at the end.

The binary protocol is deliberately small: a length-prefixed frame, a four-step SCRAM-SHA-256 handshake, then request/response query frames. The reference implementation is the Rust driver (crates/skaidb-driver, published as the skaidb crate from https://github.com/porcupin26/skaidb-rust) and the Python driver (https://github.com/porcupin26/skaidb-python), which is verified against a live cluster. The published copy of this document is https://skaidb.org/docs/PROTOCOL.html. Every official driver runs the shared conformance vectors generated from the reference encoders — https://skaidb.org/conformance/vectors.json, harness contract at https://skaidb.org/conformance/README.md — against its public API, so a new driver has a byte-exact test suite to start from.

All multi-byte integers are little-endian (LE) except the frame length prefix, which is big-endian (BE). Watch that distinction — it is the single most common porting mistake.


1. Framing

Every message — handshake step or query — is one frame:

+-----------------+--------------------------+
| u32 length (BE) | payload (length bytes)   |
+-----------------+--------------------------+
  • length is the byte count of payload, big-endian.
  • Maximum accepted payload is 64 MiB (64 * 1024 * 1024).
  • Read: read 4 bytes → length → read exactly length bytes.
  • Write: write the BE length, then the payload, then flush.

Within payloads, all length fields are u32 little-endian unless stated.

The separate member-to-member protocol extends its index-count request (opcode 67) with an optional trailing byte 1 requesting an eligible-key set proof. Absent means the ordinary count. The proof reply uses the existing counter response prefix (u64 count) followed by 32 raw SHA-256 bytes over the ordered eligible primary keys, each preceded by its u64 little-endian length. Older decoders ignore these trailers. A coordinator requesting a proof never accepts an older peer's bare counter as membership agreement; it uses the normal consistency-level row merge. Client query frames are unchanged.


2. Handshake (SCRAM-SHA-256)

Exactly four frames, always — even when the server has auth disabled (it then accepts any proof). Run it immediately after connecting, before any query.

client → AuthStart     { username, client_nonce }
server → AuthChallenge { salt, iterations, server_nonce }
client → AuthFinish    { client_proof }
server → AuthOutcome   Ok{ server_signature } | Denied{ reason }

Each handshake payload starts with a 1-byte tag:

Message Tag
AuthStart 10
AuthChallenge 11
AuthFinish 12
AuthOutcome 13

Encodings (a str/bytes field = u32 LE length followed by the bytes; strings are UTF-8):

AuthStart (client → server)

u8  = 10
str username
str client_nonce

client_nonce is any fresh, unique ASCII string (e.g. "c"+pid+"."+counter).

AuthChallenge (server → client)

u8  = 11
bytes salt              (u32 LE len + salt bytes)
u32 iterations (LE)
str server_nonce

AuthFinish (client → server)

u8  = 12
32 bytes client_proof    (raw, NOT length-prefixed — always exactly 32 bytes)

AuthOutcome (server → client)

u8  = 13
u8  ok_flag
  if ok_flag == 1:  32 bytes server_signature   (raw, exactly 32 bytes)
  if ok_flag == 0:  str reason

2.1 Computing the proof

First build the auth message (identical on both sides). salt_hex is the salt as lowercase hex; iterations is its decimal ASCII form; \0 is a single NUL byte:

auth_message = username + "\0" + client_nonce + "\0" + server_nonce
                        + "\0" + salt_hex      + "\0" + str(iterations)

Then, with HMAC = HMAC-SHA-256, H = SHA-256, PBKDF2 = PBKDF2-HMAC-SHA-256:

salted    = PBKDF2(password, salt, iterations, dkLen = 32)
clientKey = HMAC(salted, "Client Key")
storedKey = H(clientKey)
clientSig = HMAC(storedKey, auth_message)
clientProof = clientKey XOR clientSig           # 32 bytes, sent in AuthFinish

Note the HMAC key argument is first: HMAC(key, message).

When the password is non-empty, verify the server_signature from a successful AuthOutcome:

serverKey      = HMAC(salted, "Server Key")
expectedServerSig = HMAC(serverKey, auth_message)

If expectedServerSig != server_signature, treat the connection as untrustworthy and close it. Skip this check when connecting anonymously (empty password).

2.3 Anonymous connections

If the server has auth disabled, connect with username "anonymous" and an empty password. The handshake still runs all four frames; the server returns Ok. Do not verify the server signature when the password is empty.

2.4 Choosing a mechanism

AuthStart may end with one more byte, the mechanism. A client that omits it gets SCRAM-SHA-256; a server that predates a mechanism refuses it (the connection closes).

u8  = 10
str username
str client_nonce
u8  mechanism        (optional: 0 = SCRAM-SHA-256, 1 = GSSAPI, 2 = EXTERNAL)
  • 0 — SCRAM-SHA-256: the four frames above.
  • 1 — GSSAPI (Kerberos): after AuthStart, both sides exchange AuthToken frames (u8 = 14, bytes token) until the context is established; the server then sends AuthOutcome. The username is only a claim — the Kerberos principal is the identity. See KERBEROS.md.
  • 2 — EXTERNAL (TLS client certificate): the connection must be TLS and present a client certificate that chains to the server's auth.x509_ca_file (with auth.x509_enabled). No exchange follows AuthStart: the server answers AuthOutcome at once. The certificate's subject Common Name is the username; AuthStart.username must be empty or equal it, and client_nonce may be empty. A successful outcome carries 32 zero bytes as server_signature — do not verify it (TLS already authenticated the server).

3. Query request / response

After a successful handshake the connection is a simple request → response loop. One request frame, one response frame, repeat. The connection is persistent and may be reused for many queries (and should be — pool/reuse it).

3.1 Request

u8  = 1                       # OP_QUERY
u8  consistency               # 0 = ONE, 1 = QUORUM, 2 = ALL
u32 sql_len (LE)
sql bytes (UTF-8)

Consistency selects how many replicas must acknowledge (writes) or be consulted (reads) before the server answers. Drivers default to QUORUM (1).

Servers ≥ 0.17.0 also accept the prepared-statement opcodes 2–4 (§3.3); a driver that only ever sends OP_QUERY is fully compatible in both directions.

3.2 Response

First byte is a tag:

Tag Meaning
0 Rows
1 Mutation
2 Ddl
3 Error
4 Prepared
5 RowsHeader
6 RowsChunk
7 RowsEnd
8 ResultSets

Rows (0) — a SELECT result set:

u8  = 0
u32 ncols (LE)
ncols × [ u32 len (LE) + column name (UTF-8) ]
u32 nrows (LE)
nrows × row, where each row is:
    u32 ncells (LE)
    ncells × [ u32 vlen (LE) + value bytes ]      # value bytes per §4

ncells always equals ncols. Each cell's value bytes is a self-describing Value.

RowsHeader (5) / RowsChunk (6) / RowsEnd (7) — the streamed form of a result set, sent only in reply to OP_QUERY_STREAM (§3.4):

u8  = 5                       # RowsHeader, once, before any row
u32 ncols (LE)
ncols × [ u32 len (LE) + column name (UTF-8) ]

u8  = 6                       # RowsChunk, zero or more times
u32 nrows (LE)
nrows × row                   # row layout exactly as in Rows (0)

u8  = 7                       # RowsEnd, once, terminates the stream

Each frame is an ordinary length-prefixed frame (§2) — a chunk is not a continuation of the header's frame. Read frames until RowsEnd.

ResultSets (8) — several result sets in one reply: a CALL whose body ran EMIT <select> statements. The emitted sets come first, in order, the call's final result last (an ordinary CALL without EMIT still answers with Rows (0)):

u8  = 8
u32 nsets (LE)
nsets × [ columns block + rows block exactly as in Rows (0), i.e.
          u32 ncols, ncols × name, u32 nrows, nrows × row ]

Drivers with a native multi-set API iterate from the first set (Python cursor.nextset(), Go Rows.NextResultSet, Java ResultSet.nextResultSet(), .NET SkaidbDataReader.NextResult()); the others expose every set under result_sets/resultSets with the LAST set as the result's own rows. REST /query answers the same way (result_sets plus the last set inline).

Mutation (1) — INSERT/UPDATE/DELETE:

u8  = 1
u64 affected (LE)

Ddl (2) — CREATE/DROP/etc. succeeded:

u8  = 2

Error (3) — the statement failed:

u8  = 3
str message      (u32 LE len + UTF-8)

Drivers should raise/return this message as a query error.

Prepared (4) — reply to OP_PREPARE (§3.3):

u8  = 4
u32 statement id (LE)
u16 parameter count (LE)

3.3 Prepared statements (server ≥ 0.17.0)

A statement containing ? placeholders can be parsed once and executed many times with different bindings — skipping the per-request SQL parse and giving a typed, injection-safe parameter path. Prepared ids are scoped to the connection that created them: they are invalid on any other connection, and gone when the connection closes. Only SELECT/INSERT/UPDATE/DELETE — and EXPLAIN of any of those (servers ≥ 0.86.0) — can be prepared; DDL and session statements are refused with Error.

OP_PREPARE — parse and cache; answered with Prepared:

u8  = 2                       # OP_PREPARE
u32 sql_len (LE)
sql bytes (UTF-8), may contain `?` placeholders

OP_EXECUTE — run a prepared statement; answered like a normal query:

u8  = 3                       # OP_EXECUTE
u8  consistency               # as in OP_QUERY
u32 statement id (LE)
u16 nparams (LE)
nparams × [ u32 len (LE) + value bytes ]   # §4 Value encoding, in `?` order

The binding count must equal the statement's parameter count exactly, else Error.

OP_CLOSE — free a prepared statement's slot (a server caps open statements per connection at 256); answered with Ddl on success:

u8  = 4                       # OP_CLOSE
u32 statement id (LE)

OP_EXECUTE_BATCH (servers ≥ 0.87.0) — run a prepared statement once per parameter row in a single round-trip (the executemany wire form); answered with Mutation carrying the total affected count:

u8  = 7                       # OP_EXECUTE_BATCH
u8  consistency               # as in OP_QUERY
u32 statement id (LE)
u32 nrows (LE)
nrows × [ u16 nparams (LE) + nparams × [ u32 len (LE) + value bytes ] ]

Each row observes the same semantics as a separate OP_EXECUTE (per-row autocommit, per-row arity check). Plain-INSERT batches without ON CONFLICT are executed as spliced multi-row statements in chunks (servers ≥ 0.196.4) — same results, one write-path traversal per chunk; other statements execute row by row. On the first failing row the reply is an Error naming the row index and how many rows applied before it — earlier rows stay applied, matching the loop the driver would otherwise run. storage.statement_timeout_secs bounds the batch as one statement: every chunk or row counts its deadline from the batch's start, and once it has passed the batch stops before the next one with the statement-timeout error, named and counted the same way. The whole request must fit one frame (§2, 64 MiB).

OP_HELLO (servers ≥ 0.203.0) — best-effort client self-identification, sent once after authentication (and again after any reconnect, since the registry row dies with the connection):

u8  = 8                       # OP_HELLO
u32 name_len (LE)             # driver name, e.g. "python"
name bytes (UTF-8)
u32 version_len (LE)
version bytes (UTF-8)

Answered with Ddl. The values surface in the drivers table's client_name/client_version columns (server-side truncated to 64 characters). An old server answers with Error("… unknown opcode"), which the client MUST ignore — identity is telemetry, never load-bearing. Every official driver sends it automatically, reporting its own package version.

3.4 Streaming a result set

OP_QUERY_STREAM — run a statement and receive its rows in chunks instead of one large frame:

u8  = 5                       # OP_QUERY_STREAM
u8  consistency               # as in OP_QUERY
u32 sql_len (LE)
sql bytes (UTF-8)

OP_QUERY_STREAM_PARAMS (servers ≥ 0.330.0) — the same with bound parameters:

u8  = 9                       # OP_QUERY_STREAM_PARAMS
u8  consistency               # as in OP_QUERY
u32 sql_len (LE)
sql bytes (UTF-8), may contain `?` placeholders
u16 nparams (LE)
nparams × [ u32 len (LE) + value bytes ]   # §4 Value encoding, in `?` order

The parameters bind the statement's ? placeholders in order, exactly as OP_EXECUTE binds a prepared statement's (the statement kinds that can be prepared, §3.3); a count that does not match is answered with Error. Send it only when there ARE parameters: OP_QUERY_STREAM stays the parameterless form, so a parameterless stream works against every server. An older server answers OP_QUERY_STREAM_PARAMS with Error("… unknown opcode"). The reply is the same as OP_QUERY_STREAM's.

The reply depends on what the statement produced:

  • Row-producing — RowsHeader, then zero or more RowsChunk, then RowsEnd. Columns arrive in the header, before any row, so a driver can expose them immediately; this is why the column set has to be knowable up front (see the note on SELECT * in QUERY_SYNTAX.md).
  • A CALL whose procedure body EMITs result sets — one ordinary ResultSets frame (tag 8) holding every set, exactly as the buffered path receives it: the procedure's output is materialized, so there is nothing to stream. A driver decodes it with its buffered-path decoder and exposes the sets as its buffered result does (see ResultSets above): a driver with a next-set walker starts the stream on the first set and moves on with it (Python nextset(), Go NextResultSet, Java nextResultSet(), .NET NextResult(), Rust next_result_set(), C skaidb_stream_next_set); one that carries result_sets/resultSets (Node.js, Ruby, PHP) streams the LAST set as its rows, with every set alongside. The exchange is over, so the connection is free again at once.
  • Anything else (INSERT, DDL, USE, …) — one ordinary Mutation or Ddl frame. There is no header and no RowsEnd, so a client must branch on the first frame's tag rather than assuming a stream.
  • Failure — an Error frame. If it arrives before the header the statement simply failed. It can also arrive after the header, when a statement fails partway through (a node dying mid-scan, a scan budget tripping); the rows already delivered are valid and the stream ends there.

The connection is busy for the whole stream: no other request may be sent on it until RowsEnd or Error. A client that abandons a stream early must drain the remaining frames before reusing the connection, or close it.

Whether the server also streams internally depends on the statement. A plain SELECT of bare columns from one table — any filter that is a function of the row, unordered or in storage-key order — is executed page by page in storage-key order, so the node holds one page rather than the whole result, and no scan budget applies to a page; other shapes are materialised server-side and merely delivered in chunks (the scan budgets apply to them, and a budget error says why the statement could not page). EXPLAIN reports which, in its stream row; the rules are in QUERY_SYNTAX.md ("Streamed queries"). Either way the client's memory is bounded, which is the portable guarantee.

An old server answers unknown opcodes with Error("… unknown opcode") — drivers can feature-detect by preparing once and falling back to client-side interpolation (§5), and fall back from OP_EXECUTE_BATCH to a per-row OP_EXECUTE loop the same way.


4. Value encoding

Each result cell is one value, encoded losslessly. First byte is a type tag, followed by the type's payload:

Tag Type Payload
0 Null (none)
1 Bool u8 (0 or 1)
2 Int i64 LE
3 Float f64 LE (IEEE-754 bits, little-endian)
4 Decimal i128 mantissa LE (16 bytes) + u32 scale LE
5 String u32 len LE + UTF-8 bytes
6 Bytes u32 len LE + raw bytes
7 Uuid 16 raw bytes (big-endian / RFC 4122 byte order)
8 Timestamp i64 LE — Unix time in milliseconds
9 Array u32 count LE + count values (recursive)
10 Document u32 count LE + count × [ u32 keylen LE + key + value ]

Notes: - Decimal value = mantissa / 10^scale. Drivers may surface it as a string/decimal type to avoid precision loss. Any u32 scale is accepted, stored and returned unchanged. In text form (CAST(x AS TEXT), JSON results, skaidbsh) a scale up to 1000 renders positionally (0.005); a larger one renders exactly in scientific notation (1E-70000), so a driver that parses decimals from text must accept both spellings. - Counts and lengths are validated against the payload before anything is allocated: an Array/Document count larger than the bytes that follow (an element needs at least one byte, a field at least five) is a bad value encoding error, and no count sizes a buffer beyond the frame. - Timestamp is milliseconds since the Unix epoch (can be negative). - Document keys are unique and sorted by their UTF-8 bytes; the order a client inserted them in is not kept. - Map these to the most natural language type (Array→list, Document→map/dict, Uuid→UUID type or canonical string, Bytes→byte array).


5. Parameter binding (client-side fallback)

Against servers ≥ 0.17.0 prefer the server-side prepared statements of §3.3. For older servers (or drivers that have not implemented §3.3), offer the parameterized API (?/%s/$1 placeholders) by interpolating arguments into the SQL client-side, with correct SQL quoting:

  • string: wrap in single quotes; escape each ' by doubling it → ''. (skaidb uses standard SQL '' escaping; backslashes are literal.)
  • integer / float: numeric literal as-is (use a round-trip-safe float format; reject NaN/Inf).
  • bool: TRUE / FALSE.
  • null / None / nil: NULL.
  • bytes: hex/blob literal if supported, else document as unsupported.

Always quote-escape strings — this is the SQL-injection boundary. Provide the placeholder style idiomatic to the language (see each driver's README).


6. Connection lifecycle & errors

  1. TCP connect (set TCP_NODELAY for low latency).
  2. Run the 4-frame handshake. On Denied, raise an auth error and close — unless the reason starts with too many connections: (below).
  3. Loop: send request frame, read response frame.
  4. On any framing/IO error, the connection is dead — discard it (don't reuse).
  5. An Error response is a statement error, not a connection error; the connection stays usable for the next query.

Capacity refusals are connection failures, never auth failures. A node at server.max_connections refuses a new connection in one of two ways, and a driver must treat both as "this connection failed — retry, or use another node", not as rejected credentials (a pool that stops on an auth error must not stop here):

  • the socket is closed without a handshake frame — at accept, or right after the server read AuthStart (the few slots above the ceiling are reserved for the configured superuser, and a client that names anyone else is refused before any challenge). The driver sees EOF or a reset where it expected AuthChallenge: an I/O error, per rule 4;
  • AuthOutcome Denied whose reason starts with too many connections: — only where the server learns who the client is from the exchange itself (GSSAPI without a username in AuthStart). Map it to your connection-error class, keeping the reason as the message.

A cluster has multiple nodes (default internode/clients on each). Every node accepts reads and writes (leaderless), so a driver may connect to any node, or round-robin a list of hosts for availability. Token-aware routing is optional.

6.1 Balancing a pool across members

Every official driver's connection pool spreads its connections across the members by the rules below, so an application behaves the same whichever language it is written in. The option names follow each language's style (load_balancing / loadBalancing / LoadBalancing); durations are in the language's usual unit.

Members. The pool starts from the configured endpoints (the seeds). With discovery on, it runs SHOW ENDPOINTS (no privilege needed; server ≥ 0.327) and folds the answer in:

SHOW ENDPOINTS
-- endpoint             | state       | local
-- '10.0.0.1:7000'      | 'ready'     | true
-- '10.0.0.2:7000'      | 'resyncing' | false
-- '10.0.0.3:7000'      | 'ready'     | false

One row per ring member: its client endpoint, its state as the answering member sees it (ready; resyncing — backfilling, answers correctly but slower; unreachable — its circuit breaker is open there), and local on the answering member's own row. Witnesses are not members and never appear. A standalone server answers no rows; a server older than the statement answers an Error — either way the pool keeps its seeds and stops asking.

  • Discovery runs on the first connection made to each member whose name the pool has not learned yet (the local row names it, so a seed given as a host name is matched to the endpoint the server reports and never counted twice), and every refresh_interval (default 60 s) after that — every 5 s instead while a listed member is resyncing or unreachable (often a matter of seconds, such as a peer's circuit breaker right after it restarted).
  • A listed endpoint the pool does not know becomes a member. A discovered member missing from a later listing is dropped. A seed is never dropped: a seed whose member left the ring is marked unlisted.
  • Only ready members get new connections. A member that becomes ready again starts with a fresh latency average and no errors, like a re-admission.

Policies (load_balancing):

Policy A checkout goes to
random (default) any idle connection; a new connection dials the usable members in random order — each connection sticks to the member it reached
round_robin the next usable member in endpoint order
least_latency of two usable members picked at random, the one with the lower latency × (checked-out connections + 1); a member not measured yet, or not in the last 2 s, scores 0, so it is tried — a lightly loaded pool keeps re-measuring every member instead of settling on whichever answered fastest once

With a target member the pool hands out an idle connection to it, or dials it; a failed dial falls through to the other usable members (random order), then to the unusable ones (resyncing/unreachable before ejected, the soonest re-admission first). An idle connection to a member that is no longer usable is closed rather than handed out, and one returned to the pool is closed instead of kept.

Idle order (idle_order): lifo (default — the most recently returned connection first, so a quiet pool keeps few sockets warm) or fifo (the longest idle first, so every idle connection keeps being used and a server-side idle timeout never finds one).

Latency. Per member, the pool keeps an exponentially weighted average of request round trips (send to the first response frame). The fastest of the member's first three round trips seeds it: a round trip is only ever lengthened — by the member, the network or the client itself (a thread that stalls while the application starts, say) — so one or two slow first statements cannot set it (a stall that covers all three still seeds it high, and it decays from there as any average does). From then on each sample moves it 5 % of the way, and no sample counts for more than 10 × the larger of the current average and the ejection floor. A single slow statement therefore barely moves it; a member that stays slow converges within a few dozen statements. Until its average is seeded a member counts as not measured.

Health ejection. A member is ejected — no new connections, its idle ones closed — when

  • eject_errors (default 3) consecutive transport failures hit it: a failed dial, an I/O error or a timeout mid-request, a capacity refusal. A statement Error, and a login refused for its credentials, are not member failures; or
  • with at least 10 samples on it and on another usable member, its average is above eject_latency_factor (default 3; 0 disables) × the median of the other usable members' averages (the lower middle value when even) and more than eject_latency_floor (default 50 ms) above it.

A driver that logs its ejections names, for a latency ejection, the member's average, the median it was compared with and the slowest of the member's last 10 round trips — which tells a slow member from one slow statement. (The Rust and C drivers do not log.)

The ejection lasts eject_backoff (default 5 s), doubled for each repeat up to eject_backoff_max (default 60 s); a member that stays healthy for eject_backoff_max after a re-admission starts over at the base. When the time is up, a probe dials the member alone and runs one statement: success re-admits it with a fresh latency average and errors cleared (the probe connection joins the idle set), failure ejects it again. Drivers probe from a background thread or timer; a runtime without one (PHP) probes on the first checkout after the backoff. The last usable member is never ejected — a slow or flaky member beats none.

Read-your-writes (read_your_writes, default off). The member that coordinated a write reads it back at any consistency, ONE included (CLUSTER_CONSISTENCY.md). With a window set, after a write succeeds on a connection checked out under an affinity key, the pool sends checkouts under the same key to that member until the window ends (unless the member is ejected or not ready). The key is an optional checkout argument — a user id, a session id; checkouts without one share a pool-wide key. A statement counts as a write unless its first keyword (after whitespace, comments and opening parentheses) is SELECT, SHOW, EXPLAIN, DESCRIBE, DESC, VALUES, WITH or USE. Derive the key without querying through the same pool: a helper that checks out a connection and only then computes the key from something that itself reads the database (a web framework's "current user" loader) checks out again from inside its own checkout. Take the raw id from the session or token instead.

Introspection. members() lists, per member: the endpoint the pool dials, its discovered name, its state (ready, ejected, resyncing, unreachable, unlisted), the latency average, connections checked out and idle, and whether it is a seed.


7. REST/JSON gateway (alternative, port 7080)

For environments where a binary client is inconvenient (browsers, quick scripts), the server also speaks HTTP/1.1:

  • POST /query with the SQL as the body (raw text) or JSON {"sql": "..."}.
  • Auth: HTTP Basic (Authorization: Basic base64(user:pass)) when the server requires auth.
  • One request per connection (Connection: close); request bodies are capped at 64 MiB (HTTP 413 past it), and sockets carry 30 s read / 60 s write timeouts (servers ≥ 0.83.1).
  • Response JSON:
  • rows: {"columns": [...], "rows": [[...], ...]} (values as JSON) — servers ≥ 0.87.0 send row results with Transfer-Encoding: chunked (no size cap; any HTTP client reassembles transparently). JSON has no non-finite numbers: a float that is ±Infinity or NaN is the string "Infinity", "-Infinity" or "NaN"; null is SQL NULL only. (The binary protocol's float is the IEEE value itself.)
  • mutation: {"affected": N}
  • ddl: {"ok": true}
  • error: {"error": "..."} (HTTP 400)
  • GET /metrics returns Prometheus text.

The REST path loses binary type fidelity (UUID/Bytes/Timestamp arrive as their JSON forms) and opens a fresh connection per request, so the binary protocol is preferred for application drivers. Drivers may offer REST as a fallback mode.

Search the docs