Skip to content

SqlCache measures SQLD's 5 KB stored-SQL cap in UTF-16 code units, but the server enforces it in UTF-8 bytes #353

Description

@light-merlin-dark

Summary

SqlCache.apply guards SQLD's 5 KB stored-SQL cap with sqlText.length >= 5000, which counts UTF-16 code units. SQLD enforces that cap in UTF-8 bytes. Any statement that falls between the two measures is cached and stored by the client, refused by the server, and every later batch that references its sql_id fails with SQL text <id> not found.

Pure-ASCII SQL is unaffected because the two measures coincide. The bug only appears once statements carry multibyte text — CJK, accented Latin, emoji — which makes it look like a mysterious, data-dependent failure rather than a size limit.

Where the two measures disagree

Client — packages/libsql-client/src/sql_cache.ts (unchanged since 06debb5, Apr 2024, and still present in the published 0.17.4):

// Stored SQL cannot exceed 5kb.
if (sqlText.length >= 5000) {   // UTF-16 code units
    continue;
}

Server — libsql-server/src/hrana/http/request.rs:

const MAX_STORED_SQL_SIZE: ByteSize = ByteSize::kb(5);
...
} else if req.sql.len() > MAX_STORED_SQL_SIZE.as_u64() as usize {   // UTF-8 bytes
    bail!(StreamResponseError::SqlTooLarge { size: ByteSize::b(req.sql.len() as _) })
}

String::len() in Rust is the byte length, so the server's window is bytes <= 5000 while the client's is codeUnits < 5000. A statement of 3,024 characters / 9,024 bytes satisfies the client's check and violates the server's.

Why the reported error points at the wrong thing

The store_sql request that SQLD refuses is pipelined alongside the work that uses it, and HttpStream.storeSql routes its rejection into _setClosed rather than to the caller:

storeSql(sql) {
    const sqlId = this.#sqlIdAlloc.alloc();
    this.#sendStreamRequest({ type: "store_sql", sqlId, sql })
        .then(() => undefined, (error) => this._setClosed(error));
    return new Sql(this, sqlId);
}

So the first error the application actually sees is the downstream one — an HTTP 400 naming an id the server never registered. Because the 30-entry LRU recycles slots, that id is frequently a small number like 0, which reads like a protocol bug rather than an oversized statement. Nothing in the message mentions size, encoding, or which statement was at fault.

Reproduction

Self-contained and synthetic — no sqld instance required. The fake server implements only the two relevant SQLD behaviours: refuse store_sql above 5,000 UTF-8 bytes, and answer 400 SQL text <id> not found when a batch names an unknown id.

$ npm install @libsql/client@0.17.4
$ node repro.mjs
statement length (UTF-16 code units): 3024
statement length (UTF-8 bytes):       9024
client caches it (length < 5000):     true
server stores it (bytes <= 5000):     false

RESULT: failed as reported
  refused store_sql requests: 1
  error: SERVER_ERROR: Server returned HTTP status 400: SQL text 0 not found
repro.mjs
import { createClient } from "@libsql/client";

const SQLD_STORED_SQL_BYTE_CAP = 5000;

// Each character is one UTF-16 code unit but three UTF-8 bytes, so the statement
// sits under the cap by characters and over it by bytes.
const TEXT = "借".repeat(3000);
const SQL = `INSERT INTO t VALUES('${TEXT}')`;

console.log("statement length (UTF-16 code units):", SQL.length);
console.log("statement length (UTF-8 bytes):      ", Buffer.byteLength(SQL, "utf8"));
console.log("client caches it (length < 5000):    ", SQL.length < 5000);
console.log("server stores it (bytes <= 5000):    ", Buffer.byteLength(SQL, "utf8") <= SQLD_STORED_SQL_BYTE_CAP);

const stored = new Map();
let refusedStores = 0;

class ProtocolFailure extends Error {}

const resolveSql = (stmt) => {
  if (typeof stmt.sql === "string") return stmt.sql;
  const text = stored.get(stmt.sql_id);
  if (text === undefined) throw new ProtocolFailure(`SQL text ${stmt.sql_id} not found`);
  return text;
};

const emptyResult = () => ({
  cols: [], rows: [], affected_row_count: 0, last_insert_rowid: null,
  replication_index: null, rows_read: 0, rows_written: 0, query_duration_ms: 0,
});

const fetchImpl = async (input, init) => {
  const request = input instanceof Request ? input : new Request(input, init);
  const path = new URL(request.url).pathname;
  // Decline the protobuf endpoint so the client negotiates JSON Hrana v2.
  if (!path.endsWith("/v2/pipeline")) return new Response("not found", { status: 404 });

  const body = await request.json();
  const results = [];
  try {
    for (const entry of body.requests) {
      if (entry.type === "store_sql") {
        if (Buffer.byteLength(String(entry.sql), "utf8") > SQLD_STORED_SQL_BYTE_CAP) {
          refusedStores += 1; // sqld bails here; the id is never registered
        } else {
          stored.set(entry.sql_id, entry.sql);
        }
        results.push({ type: "ok", response: { type: "store_sql" } });
      } else if (entry.type === "close_sql") {
        stored.delete(entry.sql_id);
        results.push({ type: "ok", response: { type: "close_sql" } });
      } else if (entry.type === "execute") {
        resolveSql(entry.stmt);
        results.push({ type: "ok", response: { type: "execute", result: emptyResult() } });
      } else if (entry.type === "batch") {
        const steps = (entry.batch.steps ?? []).map((step) => { resolveSql(step.stmt); return emptyResult(); });
        results.push({
          type: "ok",
          response: { type: "batch", result: { step_results: steps, step_errors: steps.map(() => null) } },
        });
      } else {
        results.push({ type: "ok", response: { type: entry.type } });
      }
    }
  } catch (error) {
    if (error instanceof ProtocolFailure) {
      return new Response(error.message, { status: 400, headers: { "content-type": "text/plain" } });
    }
    throw error;
  }
  return Response.json({ baton: "baton", base_url: null, results });
};

const client = createClient({
  url: "https://namespace.example.invalid",
  authToken: "token",
  fetch: fetchImpl,
});

const tx = await client.transaction("write");
try {
  // The first batch stores the oversized SQL (server refuses); the second
  // reuses the cached id (server has nothing under that id).
  await tx.batch([{ sql: SQL, args: [] }]);
  await tx.batch([{ sql: SQL, args: [] }]);
  await tx.commit();
  console.log("\nRESULT: no error — the defect did not reproduce");
} catch (error) {
  console.log("\nRESULT: failed as reported");
  console.log("  refused store_sql requests:", refusedStores);
  console.log("  error:", String(error.message ?? error));
} finally {
  client.close();
}

How we hit it

Restoring a logical SQL dump into a namespace holding translated (Japanese) prose failed deterministically, always at the same statement, with SERVER_ERROR: Server returned HTTP status 400: SQL text 0 not found. The offending statement measured 4,218 characters / 8,618 bytes — comfortably under the client's guard, well over the server's.

Two things made this expensive to diagnose, and both are worth knowing if you try to reproduce it:

  • Statement size alone does not reproduce it. A synthetic 9,007-character ASCII statement replays cleanly, because past 5,000 characters the client's guard correctly leaves the text inline. You need multibyte text sized between the two measures.
  • The reported sql_id is misleading. It reflects whichever LRU slot was recycled, not the failing statement.

Suggested fix

Measure the guard in the same units the server does:

if (new TextEncoder().encode(sqlText).length >= 5000) {
    continue;
}

Verified: patching this single line in @libsql/client@0.17.4 makes the repro above pass — the oversized statement is left inline and the batch succeeds.

TextEncoder is available on every runtime this package targets. If allocating a byte array per statement on the hot path is a concern, a cheap pre-filter preserves the current fast path for ASCII, since UTF-8 never expands a JS string beyond 3 bytes per code unit:

// Fast path: cannot possibly exceed the cap.
if (sqlText.length * 3 >= 5000 && new TextEncoder().encode(sqlText).length >= 5000) {
    continue;
}

(Incidentally, the client's >= 5000 is one byte stricter than the server's > 5000. That direction is safe, so it is only worth aligning if you touch the line anyway.)

Two optional follow-ups, both independent of the units fix:

  1. Surface the refused store_sql error to the caller instead of only into _setClosed, so the application sees The statement is too large to be stored rather than a downstream missing-id 400.
  2. Include the byte size in the client-side skip decision's reasoning, or document the cap's units in the comment above the guard — the current comment says "5kb" without saying which measure.

Environment

  • @libsql/client 0.17.4 (also reproduced on 0.15.9); guard unchanged on main
  • Node.js v22.22.3, macOS (arm64)
  • Server: sqld with --enable-namespaces, HTTP transport, JSON Hrana v2 pipeline

By Merlin (rbeckner.com) and his AI agent (Claude Code, Claude Fable 5, thinking on: high).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions