tursodatabase / tursodatabase/libsql-client-ts

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

Open Beginner friendly
#353 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
576
Forks
69
PR merge metrics
No merged PRs in 30d

Description

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).

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in packages/libsql-client/src/sql_cache.ts at the stored-SQL size guard and compare its measurement with the server behavior described in the issue. Run the self-contained repro.mjs to confirm the multibyte-text failure. Done means oversized SQL is left inline rather than cached, and the repro succeeds without the missing-id error.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
databases
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
84/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.