Skip to content

fix: close the remaining Cloudflare platform-limit gaps - #398

Merged
prisis merged 10 commits into
alphafrom
fix/remaining-platform-limits
Aug 10, 2026
Merged

fix: close the remaining Cloudflare platform-limit gaps#398
prisis merged 10 commits into
alphafrom
fix/remaining-platform-limits

Conversation

@prisis

@prisis prisis commented Aug 10, 2026

Copy link
Copy Markdown
Member

Closes the platform-limit gaps left open by #396 and #397, and finishes the audit of every package that calls a Cloudflare API.

Correctness

The meter charged after the cross-backend write. A .global() insert returns before onWrite, so the global branch charges the transaction meter itself — but it charged after global.insert resolved. That row lands in D1, and a mutation's rollback is state.storage.transaction, which rewinds the Durable Object's SQLite and nothing else. A ceiling breach therefore threw with the D1 row committed and no way to undo it. insertMany now charges the whole batch up front, the way its shard-local sibling already did, so a breach is found while none of the rows exist.

Expression depth. SQLite parses a AND b AND c left-deep — one expression-tree node per clause — against Workerd's SQLITE_LIMIT_EXPR_DEPTH of 100, where stock SQLite allows 1,000. A where assembled programmatically reaches that in a way no hand-written predicate would: a filter builder over a wide form, an RLS policy merged into a caller's predicate, an OR across a long id list. Verified against a real SQLite with the limit lowered — 200 flat terms raise Expression tree is too large, the same 200 balanced parse fine.

joinClauses now splits in half, so 200 clauses nest 8 deep instead of 200. AND/OR are associative under three-valued logic, so regrouping cannot change what matches, and the left half always comes first so bound parameters number exactly as before. SQLite's own whereSplit recurses into both children of an AND, so the planner decomposes a balanced tree into the same term set — index selection is unaffected.

Guards

Statement backstop. runSql is the one place every statement passes through; it now rejects text over 100,000 characters or 100 bound parameters, both O(1) reads on values already in hand. Deliberately a backstop rather than the defence — every builder sizes its own statement, so this fires only when one regressed or a caller hand-wrote SQL, which is exactly when a message naming the limit beats a bare SQLITE_ERROR from prepare.

Vectorize metadata. defineRag stores each chunk's text as vector metadata unless a textStore is supplied, and Vectorize caps that at 10 KiB. chunkSize was unchecked, so a value above the cap meant every upsert failed at the far side with nothing naming the cause. The ceiling was already in the package's own docblock — documented, not enforced. Skipped when a custom chunk splitter makes chunkSize inert, and when a textStore moves text out.

Visibility

fan_out_breadth warns when a shard group has enough active shards that a cross-shard read over it would approach the per-invocation internal-subrequest ceiling. It measures capacity, not observed fan-out, and says so — the shardTraffic feeder reports live shards, not what any one invocation touched. Not a hard cap either: the real ceiling is 1,000 on Free but the configured limit (default 10,000) on Paid, so refusing at a fixed number would break deployments that work today.

The Limits page gains Pipelines and Workers AI/Vectorize sections, and its subrequest row — which said 50 (Free) / 1000 (Paid) — now separates external from internal, since the internal ceiling is the one a cross-shard read spends.

Audit result

Complete against the canonical binding list in @lunora/platform: D1/DO SQL, KV, R2, Analytics Engine, Vectorize, Queues all have an owner for every documented ceiling. Verified-correct-as-is: browser, notify (its Web Push 4 KB is a browser-vendor limit, not Cloudflare), mail, images, container, observability, and agent — whose 8 MB utterance cap bounds DO memory on a buffer that is never stored, so it does not meet the 2 MB row ceiling.

Left to the platform, now documented: Queues byte caps and Pipelines' 5 MB ingestion call. Measuring either means serializing every payload a second time on a send path, and both are rejected clearly by Cloudflare.

Known, not addressed

legacyRowPredicate in reprojection-backfill.ts binds 5 parameters per field unchunked, so a table with 21+ v.bigint()/v.bytes() columns exceeds the parameter cap. Pre-existing and unreachable on Cloudflare before this branch too (it already failed); the new backstop now names it instead of letting SQLite do so. It wants its own change — chunking an OR-across-fields scan changes what the query means.

The global patch/patchMany branches still call no recordWrite at all, so the "unbounded rows to a .global() table" hole the insert comment describes remains open for patches. Pre-existing, untouched here.

Testing

Both thermo review passes ran against this branch and their findings are folded in — including one that would have shipped CI red (a test that passed under Vitest while failing lint:types, since Vitest does not type-check) and one lint that would have fired a permanent false WARN on any app with 500+ tenants.

The depth test now measures terms-per-level rather than paren nesting, and was confirmed to fail against a flat sql.join — the previous version passed on the implementation it was meant to guard.

Green: shard-engine 1101, advisor 475, ai 124, sql-store 108, do 530, runtime 898; api:check matches all 47 snapshots; eslint, prettier and lint:types clean on every touched package.

🤖 Generated with Claude Code

https://claude.ai/code/session_011sk9BLaUZDkPVAZ1sKhuDP

Summary by CodeRabbit

  • New Features

    • Added advisors for unusually high failure rates and broad shard fan-out before limits are approached.
    • Added documented limits for subrequests, Pipelines, Workers AI, and Vectorize.
  • Bug Fixes

    • Improved validation for oversized Vectorize metadata, SQL statements, and bound parameters.
    • Improved database write metering and large SQL filter handling.
    • Improved reliability when processing large document backfills.
  • Documentation

    • Added remediation guidance and updated runtime limit examples.

prisis and others added 6 commits August 10, 2026 14:18
A `.global()` insert returns before `onWrite`, so the global branch charges the
transaction meter itself — but it charged after `global.insert` had already
resolved. That write lands in D1, and a mutation's rollback is
`state.storage.transaction`, which rewinds the Durable Object's SQLite and
nothing else. So a ceiling breach threw with the D1 row committed and no way to
undo it: a rollback that silently kept half the write, and with `insertMany`,
every row before the offending one.

Charging first makes the breach refuse the write instead of following it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sk9BLaUZDkPVAZ1sKhuDP
SQLite parses `a AND b AND c` left-deep — one expression-tree node per clause —
and Workerd caps SQLITE_LIMIT_EXPR_DEPTH at 100 where stock SQLite allows 1,000.
A `where` assembled programmatically reaches that in a way no hand-written
predicate would: a filter builder over a wide form, an RLS policy merged into a
caller's predicate, an OR across a long id list. Past it the statement fails to
parse; it does not run slowly. Confirmed against a real SQLite with the limit
lowered: 200 flat terms raise "Expression tree is too large", the same 200
balanced parse fine.

`joinClauses` now splits the chain in half recursively, so 200 clauses nest 8
deep instead of 200. AND and OR are associative under SQL's three-valued logic —
`(a AND b) AND c` and `a AND (b AND c)` agree on true, false and NULL alike — so
the regrouping cannot change what matches, and the left-to-right walk keeps
bound parameters in the same order.

The rendered SQL changes shape, so three emitted-SQL assertions move with it
(including the pagination seek predicate, whose parameter order is unchanged).
Each now says why the shape is what it is, so it does not get "tidied" back to
flat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sk9BLaUZDkPVAZ1sKhuDP
Every builder in this package sizes its own statement — compounds nest, long
`IN` lists bind one JSON parameter, batch INSERTs chunk, clause chains balance.
Nothing checked the result, so a builder regression or a hand-written statement
reached SQLite as a bare `SQLITE_ERROR` from prepare, naming neither the limit
nor the statement.

`runSql` is the one place every statement passes through, and both ceilings are
readable there from values already in hand: the rendered text's length and the
parameter count. O(1) each, so the hot path pays nothing.

Deliberately a backstop and not the defence: it fires only when something
upstream already went wrong, which is exactly when a clear message is worth
most. Existing suites pass unchanged, which is its own evidence the builders
stay under both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sk9BLaUZDkPVAZ1sKhuDP
…ings

A cross-shard read issues one Durable Object RPC per shard. Those are in-house
subrequests, so they do not consume the six external connection slots — but they
do count against the per-invocation subrequest ceiling (1,000 on Free; on Paid,
the configured limit, default 10,000), and that ceiling is reached by breadth
alone. Bounded concurrency does not help: it paces the fan-out, it does not
shrink it.

`fan_out_breadth` warns at 500 shards. Not a hard cap, deliberately — the real
ceiling depends on the account's plan and configured limit, so refusing at a
fixed number would break deployments that work today. It reads the shard count
from the `shardTraffic` feeder `hot_shard` already consumes, so it costs no
extra cross-shard work, and it counts per function group because the ceiling is
per invocation rather than per deployment.

The Limits page claimed the runtime warns when a DO crosses 1 GB, sustains
>700 req/s, or fans out over 100 shards. None of the three exists anywhere in
the codebase — not in the runtime, the observability layer, the Vite overlay, or
the Studio. That section now describes the advisor lints that do exist and the
runway each leaves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sk9BLaUZDkPVAZ1sKhuDP
In the default mode `defineRag` stores each chunk's text as the vector's
metadata, and Vectorize caps that at 10 KiB. `chunkSize` is caller-configurable
and was unchecked, so a value above the cap meant every upsert failed at the far
side with nothing naming `chunkSize` as the cause. The ceiling was already
written down in the package's own docblock — documented, not enforced.

Checked once when the RAG is defined rather than on every write, and skipped
entirely when a `textStore` moves chunk text out of metadata, which is what that
option is for. Compared in characters against a byte ceiling deliberately: one
character is at least one byte, so this rejects only sizes that could not fit
even in pure ASCII, and never a config that works.

Also documents Pipelines (5 MB per ingestion call, which `ctx.pipelines.send`
forwards as one call) and the Vectorize ceilings on the Limits page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sk9BLaUZDkPVAZ1sKhuDP
The `defineRag` test did not type-check — `RagTextStore` is `{ getMany, put }`
and the stub supplied `get`. Vitest does not type-check, so the suite was green
while `lint:types` was red. That is the second time this branch's tests passed
without the gate; the test was written after the last typecheck ran.

`joinClauses` had three branches that earned nothing and one that was dangerous.
The `clauses.length === 1 && first` guard made an unreachable state recurse
forever rather than return — falsy `first` fell through to a split that recursed
on the same array. The `length === 2` case rendered exactly what the general one
does, and `if (!left || !right)` was provably dead, since both halves of a split
are non-empty. Two branches now, and the depth argument moves into the docblock.

The expression-depth test asserted paren nesting, which the flat form it
replaced never exceeded — it passed on the implementation it was meant to guard.
It now measures terms per level, mirroring `widestCompound` in the same file,
and fails on a flat `sql.join`. A behavioural case runs 200 AND'd conditions
through real SQLite.

`fan_out_breadth` claimed to count one invocation's fan-out from a signal that
carries neither. The Studio feeder sets no group at all, so every shard collapsed
into one bucket, and idle shards counted — so a healthy 600-tenant app whose
every read is shard-pinned got a permanent WARN telling it to narrow a read it
never made. It now measures shard-set *capacity*, says so, and filters idle
shards the way `hot_shard` does.

`defineRag` rejected `chunkSize` even when a custom `chunk` splitter made it
inert, and tested `=== undefined` where the rest of the function uses truthiness.

The `insertMany` global branch charged per row inside the write loop, so a breach
still left every earlier row committed in D1 — the comment claimed otherwise. It
now charges the whole batch up front, like the shard-local branch beside it, and
the single-insert comment states the trade the reorder makes.

Also: `MAX_BOUND_PARAMS` duplicated `WORKERD_SQLITE_LIMITS.boundParams` in the
same package, whose docblock exists to stop exactly that; the backstop's three
throw paths had no tests; the Limits page contradicted itself on subrequests
(50 external vs 1,000 internal); and both canonical lint tables were missing two
runtime lints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sk9BLaUZDkPVAZ1sKhuDP
@netlify

netlify Bot commented Aug 10, 2026

Copy link
Copy Markdown

Deploy Preview for lunorash ready!

Name Link
🔨 Latest commit b2000c9
🔍 Latest deploy log https://app.netlify.com/projects/lunorash/deploys/6a7a062cda0a590008201e6a
😎 Deploy Preview https://deploy-preview-398--lunorash.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@github-actions

Copy link
Copy Markdown
Contributor

Thank you for following the naming conventions! 🙏

@github-actions

Copy link
Copy Markdown
Contributor

Thank you for confirming the Contributor License Agreement! 🙏

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@prisis, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 21 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ba08d846-3dc5-481a-ae92-82e3ea57df42

📥 Commits

Reviewing files that changed from the base of the PR and between c8f5ab4 and b2000c9.

⛔ Files ignored due to path filters (2)
  • packages/ai/__tests__/rag.test.ts is excluded by !**/__tests__/**, !**/*.test.ts and included by packages/**
  • packages/shard-engine/__tests__/ctx-db.headroom.test.ts is excluded by !**/__tests__/**, !**/*.test.ts and included by packages/**
📒 Files selected for processing (3)
  • apps/docs/src/content/docs/limits.mdx
  • packages/ai/src/rag/define-rag.ts
  • packages/shard-engine/src/ctx-db.ts

Walkthrough

The pull request adds a fan-out advisor lint, documents platform limits, validates Vectorize metadata sizing, enforces SQLite limits, centralizes write metering, balances generated predicates, and simplifies reprojection backfill queries.

Changes

Runtime advisor lints

Layer / File(s) Summary
Fan-out breadth lint implementation
packages/advisor/src/lints/runtime/fan-out-breadth.ts
Adds the fan_out_breadth lint. It groups active shards and reports groups with at least 500 shards.
Lint registration and documentation
packages/advisor/src/index.ts, apps/docs/src/content/docs/concepts/advisors.mdx, apps/docs/src/content/docs/limits.mdx
Exports and runs fanOutBreadth. Documentation describes advisor entries, internal subrequest limits, and remediation guidance.

Vectorize metadata validation

Layer / File(s) Summary
Chunk-size limit and validation
packages/ai/src/rag/define-rag.ts, apps/docs/src/content/docs/limits.mdx
Defines the 10 KiB Vectorize metadata limit and rejects oversized built-in chunks without a custom chunker or textStore. Documents related service limits.

Shard-engine limit enforcement

Layer / File(s) Summary
SQL limit definition and validation
packages/shard-engine/src/drizzle.ts, packages/shard-engine/src/do-exec.ts
Adds the SQL text-length limit and rejects SQL text or bound-parameter counts that exceed configured ceilings.
Centralized write metering
packages/shard-engine/src/ctx-db.ts
Charges global and shard-local writes before database operations, including unsafe batch inserts, patches, and replacements.
Balanced predicate construction
packages/shard-engine/src/where-sql.ts
Builds balanced AND/OR trees while preserving clause and parameter order.
Backfill query shaping
packages/shard-engine/src/reprojection-backfill.ts
Uses json_each with fixed parameters to match configured fields and encoded bigint or bytes values.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: closing remaining Cloudflare platform-limit gaps.
Description check ✅ Passed The description explains the changes, testing, linked issues, known gaps, and review context, although it omits several template headings.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/remaining-platform-limits

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (1)
packages/shard-engine/src/where-sql.ts (1)

211-215: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the empty-array case explicit.

compileNode calls joinClauses at Line 280 without an empty-array check. The current clauses.length <= 1 guard returns undefined, so runtime behavior is safe. Split the zero- and one-clause cases explicitly to document this contract and prevent a future midpoint change from recursing forever on [].

Proposed fix
-    // Both halves of a split are non-empty and strictly smaller, so the
-    // recursion always reaches this case.
-    if (clauses.length <= 1) {
+    if (clauses.length === 0) {
+        return undefined;
+    }
+    if (clauses.length === 1) {
         return clauses[0];
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/shard-engine/src/where-sql.ts` around lines 211 - 215, Update
joinClauses to handle clauses.length === 0 explicitly before the single-clause
case, returning the established empty-result value for empty arrays, then retain
clauses.length === 1 returning clauses[0]. Keep the existing recursive split
behavior unchanged for multiple clauses and preserve compileNode’s contract when
it calls joinClauses without an empty-array check.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/docs/src/content/docs/limits.mdx`:
- Around line 80-81: Update the external “Subrequests” row in the limits table
to describe 10,000 as the Paid default and state that the configured Paid limit
applies, supporting configuration up to 10 million while preserving the Free
limit and existing fetch() description.
- Around line 120-122: Update the limits documentation to scope the
metadata-ceiling `chunkSize` validation to configurations where Lunora stores
chunk text in Vectorize metadata; clarify that configuring `textStore` removes
this validation because chunk text is stored externally.
- Around line 117-118: Update the limits table entry for “topK with full
metadata” to reflect a maximum of 50 when returnMetadata is "all"; retain the
100 limit only for queries that omit full metadata and vector values.

In `@packages/ai/src/rag/define-rag.ts`:
- Around line 33-40: Update the Vectorize metadata-size validation and its
configuration comment to measure the complete per-vector metadata payload,
including chunk text, metadata(), input.metadata, sourceId, chunkIndex, and
chunk-zero bookkeeping, rather than text alone. Account for UTF-8 byte length
and ensure chunkSize=10,240 cannot overflow the 10 KiB limit; preserve the
unrestricted textStore path. Add boundary coverage for ASCII, multibyte text,
caller metadata, and textStore behavior.

In `@packages/shard-engine/src/ctx-db.ts`:
- Around line 3364-3368: Update both global write paths in
packages/shard-engine/src/ctx-db.ts: at lines 3364-3368, charge the global
writer’s normalized single-row estimate before global.insert; at lines
3480-3483, charge normalized estimates for the full batch before the D1 loop.
Use the existing normalization and accounting mechanisms so writtenBytes
reflects defaults and metadata and enforces the transaction ceiling.
- Around line 3474-3485: The insertMany global-table path must precharge every
document before invoking the first writer.insert call. Update the insertMany
flow to record headroom for the entire documents batch ahead of sequential
writes, while preserving meterExempt behavior and avoiding duplicate precharging
that could overcount usage.

In `@packages/shard-engine/src/do-exec.ts`:
- Around line 30-34: Update runSql’s SQL length validation to measure query text
with TextEncoder().encode(query).byteLength instead of query.length, and report
the byte count in the LunoraError while preserving the existing limit check.
Update the sqlTextLength documentation in drizzle.ts to state that the limit is
measured in bytes.

---

Nitpick comments:
In `@packages/shard-engine/src/where-sql.ts`:
- Around line 211-215: Update joinClauses to handle clauses.length === 0
explicitly before the single-clause case, returning the established empty-result
value for empty arrays, then retain clauses.length === 1 returning clauses[0].
Keep the existing recursive split behavior unchanged for multiple clauses and
preserve compileNode’s contract when it calls joinClauses without an empty-array
check.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d8b6567b-3dec-4a9e-97f8-5cfaf5d6e057

📥 Commits

Reviewing files that changed from the base of the PR and between 424b60d and f7e4769.

⛔ Files ignored due to path filters (6)
  • api-snapshots/advisor.api.md is excluded by none and included by none
  • packages/advisor/__tests__/runtime-lints.test.ts is excluded by !**/__tests__/**, !**/*.test.ts and included by packages/**
  • packages/ai/__tests__/rag.test.ts is excluded by !**/__tests__/**, !**/*.test.ts and included by packages/**
  • packages/shard-engine/__tests__/query-args.test.ts is excluded by !**/__tests__/**, !**/*.test.ts and included by packages/**
  • packages/shard-engine/__tests__/where-sql.test.ts is excluded by !**/__tests__/**, !**/*.test.ts and included by packages/**
  • packages/shard-engine/__tests__/workerd-sql-limits.test.ts is excluded by !**/__tests__/**, !**/*.test.ts and included by packages/**
📒 Files selected for processing (9)
  • apps/docs/src/content/docs/concepts/advisors.mdx
  • apps/docs/src/content/docs/limits.mdx
  • packages/advisor/src/index.ts
  • packages/advisor/src/lints/runtime/fan-out-breadth.ts
  • packages/ai/src/rag/define-rag.ts
  • packages/shard-engine/src/ctx-db.ts
  • packages/shard-engine/src/do-exec.ts
  • packages/shard-engine/src/drizzle.ts
  • packages/shard-engine/src/where-sql.ts

Comment thread apps/docs/src/content/docs/limits.mdx Outdated
Comment thread apps/docs/src/content/docs/limits.mdx Outdated
Comment thread apps/docs/src/content/docs/limits.mdx Outdated
Comment thread packages/ai/src/rag/define-rag.ts
Comment thread packages/shard-engine/src/ctx-db.ts Outdated
Comment thread packages/shard-engine/src/ctx-db.ts
Comment thread packages/shard-engine/src/do-exec.ts Outdated
@codecov-commenter

codecov-commenter commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.48485% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 87.17%. Comparing base (95d33d6) to head (b2000c9).
⚠️ Report is 217 commits behind head on alpha.

Files with missing lines Patch % Lines
...kages/advisor/src/lints/runtime/fan-out-breadth.ts 93.33% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##            alpha     #398      +/-   ##
==========================================
+ Coverage   87.09%   87.17%   +0.08%     
==========================================
  Files        1172     1183      +11     
  Lines       63383    64154     +771     
  Branches    15447    15734     +287     
==========================================
+ Hits        55202    55927     +725     
- Misses       7654     7700      +46     
  Partials      527      527              
Files with missing lines Coverage Δ
packages/advisor/src/index.ts 100.00% <100.00%> (ø)
packages/ai/src/rag/define-rag.ts 97.58% <100.00%> (+0.11%) ⬆️
packages/shard-engine/src/ctx-db.ts 93.72% <100.00%> (+0.58%) ⬆️
packages/shard-engine/src/do-exec.ts 95.00% <100.00%> (+2.14%) ⬆️
packages/shard-engine/src/drizzle.ts 97.22% <ø> (-2.78%) ⬇️
packages/shard-engine/src/reprojection-backfill.ts 93.18% <100.00%> (ø)
packages/shard-engine/src/where-sql.ts 97.19% <100.00%> (-2.81%) ⬇️
...kages/advisor/src/lints/runtime/fan-out-breadth.ts 93.33% <93.33%> (ø)

... and 8 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codspeed-hq

codspeed-hq Bot commented Aug 10, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 18.02%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
✅ 252 untouched benchmarks
⏩ 10 skipped benchmarks1

Performance Changes

Benchmark BASE HEAD Efficiency
1 shard × 1000 rows (single round-trip) 3.2 ms 2.7 ms +18.02%

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing fix/remaining-platform-limits (b2000c9) with alpha (424b60d)

Open in CodSpeed

Footnotes

  1. 10 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

The lint task runs `eslint . --max-warnings=0`, so `vitest/prefer-expect-assertions`
fails the build at warning severity. Both tests added in this branch were missing
`expect.assertions(...)`.

They passed locally because I filtered eslint output for `error`, which is
exactly the class that does not fail this gate — the check to run is the package's
own `lint:eslint`, and to read its exit code rather than its text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sk9BLaUZDkPVAZ1sKhuDP
* fix(shard-engine): close the two deferred platform-limit holes

**The re-projection scan could not run on a wide table.** `legacyRowPredicate`
bound five parameters per reprojectable column and OR'd one clause per column,
so a table with 21 `v.bigint()`/`v.bytes()` columns exceeded Workerd's
100-parameter cap — and its 100-term OR chain would have hit the
expression-depth ceiling on the way. Both `countLegacyRows` (the `--dry-run`
figure) and `isLegacyRow` were unusable there.

The field list now rides in as one JSON parameter walked by `json_each`, the
same shape `sqliteInList` uses: four parameters and one `EXISTS`, whatever the
column count. `json_each` yields paths already quoted by `jsonPathSegment`, so
appending `[0]`/`[1]` keeps a field literally named `a.b` resolving to itself.

**A `.global()` patch or replace consumed no ceiling at all.** Both fall back to
the D1 writer and return before `onWrite`, where the meter normally charges — so
a mutation could rewrite a global table without ever touching its budget, the
exact hole the `insert` branch's comment describes. Both now charge before
crossing the boundary, since a D1 write commits where the DO's transaction
cannot roll it back.

`patch` charges the delta rather than the merged row: the row lives in D1 and
reading it back to size it would double the round-trips on every global patch.
That meters a global patch lighter than the shard-local path, which charges the
whole merged document — under-counting by the untouched fields is the right side
of that trade, since the delta is what the call actually sends. `replace` has
the whole document in hand, so its charge is exact.

Both regressions are pinned by tests confirmed to fail without the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sk9BLaUZDkPVAZ1sKhuDP

* fix(shard-engine): meter the global delete, and name the metering gate

Review found the third sibling still open. `delete`'s `.global()` fallback has
the same shape as the `patch` and `replace` branches this branch fixed — no
located row, no pinned table, delegate to D1 and return before `onWrite` — and
it charged nothing. `deleteWhere` over a `.global()` table routes through
`deleteMany` to it, so a whole batch was free of the meter however many rows it
removed. It now charges a row and no bytes, which is what `onWrite` charges for
the local delete paths.

The reason it was missed is the reason it is now hard to miss again: `if
(!meterExempt) { headroom?.recordWrite(x) }` was copy-pasted at six sites across
700 lines, so "did this branch remember?" was invisible. One named `meterWrite`
makes an absence an obviously-missing line.

Also from review: the metering test moves to `ctx-db.headroom.test.ts`, whose
header describes exactly this ("a runaway read or write through the real
`ctx.db` must be stopped with an attributable error"), and asserts the error
CODE via the file's `codeOf` helper. Its old assertion —
`toThrow(/TRANSACTION_LIMIT_EXCEEDED|limit/)` — could never match on the first
alternative, since `toThrow` reads the message, so it was really `/limit/` and
would have passed on any error carrying that word. It now covers all three
branches.

The workerd suite pins the re-projection predicate's shape directly. That file's
docblock claimed `json_each` had one use in the repo and "nothing else would
notice if that changed"; there are two shapes now, and authorization is per
function rather than per query, so the correlated `EXISTS` with a `||`-computed
path is pinned separately — it is the stricter of the two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sk9BLaUZDkPVAZ1sKhuDP

* refactor(shard-engine): walk the document instead of building a path per field

`legacyRowPredicate` kept the frame it inherited — build a JSON path per field,
extract at that path — and only changed how the paths were delivered. The frame
was what cost. `json_each` over the document walks the row's own top-level
members, where the key IS the field name, so there is no path to build.

That deletes the `jsonPathSegment` dependency from this module, the path
mapping, the `|| '[0]'` concatenation, and the docblock paragraphs that existed
only to explain the JSON-path grammar. A field literally named `a.b` — or one
carrying a quote, a bracket, or an emoji — is now compared as a plain string and
cannot re-parse as a nested key. The statement stays four bound parameters wide
whatever the column count, which was the point of the previous change.

`type = 'array'` is load-bearing, not an optimisation: `json_each` hands back a
scalar member's raw SQL text, and `json_extract('abc', '$[0]')` is a
malformed-JSON error rather than NULL. It also preserves the exclusion the whole
predicate turns on — a current projection is a JSON string, so it never reaches
the element tests, and a tagged `Date` under `v.any()` still fails on the kind.

Equivalence was measured, not argued: old and new agree on every cell of 21
document shapes against 9 field-name sets, including quotes, brackets,
backslashes, unicode and the empty name. The workerd test moves with it — the
shape it pins is the one the engine now emits, and `json_each`'s `type` and
`key` columns over a column (rather than a bound literal) are the parts an
allowlist could plausibly treat differently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sk9BLaUZDkPVAZ1sKhuDP

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
**`insertMany` could half-commit a global batch.** It loops the single-row
`insert`, which charges its own row immediately before pushing to D1 — so a
ceiling breach part-way down left every earlier row committed in a backend the
DO's transaction cannot roll back. The whole batch is charged up front now, the
way `insertManyUnsafe`'s global branch already was, and the loop runs through
`runUnmetered` so no row is counted twice. Shard-local batches are untouched:
the DO transaction rewinds them, so there is nothing to pre-empt.

**The statement-length backstop measured the wrong unit.**
`SQLITE_LIMIT_SQL_LENGTH` counts bytes; `String.length` counts UTF-16 units, so
a multi-byte statement could pass and still be refused by SQLite. One UTF-16
unit is at most three UTF-8 bytes, so anything under a third of the limit cannot
breach it — that comparison stays the whole cost for every statement this
package emits, and only the remainder pays for an encode.

**`defineRag`'s ceiling covers the whole metadata object**, not the chunk text
alone: the source id, chunk index, chunk #0's bookkeeping, and any `metadata`
the caller attaches all live in the same 10 KiB. Spending the entire ceiling on
text was provably wrong, so the check now reserves room for the rest. The
reserve is a generous guess by necessity — the caller's `metadata` is unknown
when the RAG is defined — and the guard remains a floor, not a guarantee.

Docs, all three factual: Paid external subrequests are configurable to 10M
rather than fixed at 10,000; Vectorize's full-metadata `topK` is 50, where 20 is
the legacy-V1 number `defineRag` still encodes (flagged rather than changed, a
cap being a behaviour change); and the `chunkSize` check is scoped to the case
where Lunora actually stores chunk text in metadata.

Not taken: charging the global writer's normalized row rather than the caller's
input. The meter bounds THIS isolate's memory, and normalization happens in D1 —
the caller's document is what this isolate holds, so it is the honest figure.
Same reasoning the `patch` delta already documents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sk9BLaUZDkPVAZ1sKhuDP

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
packages/ai/src/rag/define-rag.ts (1)

34-52: ⚠️ Potential issue | 🟠 Major

Validate the complete metadata payload at index time.

The 2 KiB reserve is only a heuristic. Line [279] accepts chunkSize === 8_192, but a built-in chunk containing 8_192 é characters is 16_384 UTF-8 bytes before metadata overhead. The check also skips textStore configurations, although input.metadata and bookkeeping fields still enter the object at Lines [468-491]. A custom chunk is similarly unchecked.

An oversized payload can then fail at context.vectors.upsert after textStore.put or lexicalStore.index has already run at Lines [446-464]. Measure every final metadata object with the exact UTF-8 representation sent to Vectorize before any side-store write. Include custom chunks and input.metadata. Keep the configuration check only as early feedback. Also change “lifts the constraint entirely”: textStore removes only TEXT_KEY, not the complete per-vector metadata limit.

Cloudflare’s limits page, updated August 5, 2026, lists metadata per vector as 10 KiB. (developers.cloudflare.com)

As per path instructions, packages/**/src/**/*.ts must verify error handling and follow project patterns.

Verification
#!/usr/bin/env bash
set -euo pipefail

rg -n -C 8 \
  'VECTORIZE_METADATA_BYTES|METADATA_OVERHEAD_RESERVE|input\.metadata|TEXT_KEY|textStore\.put|lexicalStore\.index|vectors\.upsert' \
  packages/ai/src/rag/define-rag.ts

python - <<'PY'
text = "é" * 8192
assert len(text.encode("utf-8")) == 16384
assert len(text.encode("utf-8")) > 10 * 1024
print("Multibyte boundary exceeds the Vectorize limit before metadata overhead.")
PY

Also applies to: 267-282

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai/src/rag/define-rag.ts` around lines 34 - 52, Replace the
heuristic metadata reserve with exact validation of every final Vectorize
metadata object in the indexing flow, including built-in and custom chunks,
input.metadata, and textStore configurations; measure the UTF-8 byte length
before textStore.put or lexicalStore.index and fail through the project’s
established error-handling pattern before any side-store writes or
vectors.upsert. Retain the existing configuration check only as early feedback,
and update its documentation to state that textStore removes TEXT_KEY but does
not remove the complete per-vector metadata limit. Anchor the changes around
VECTORIZE_METADATA_BYTES, the chunk/index path near textStore.put and
lexicalStore.index, and the final metadata construction using TEXT_KEY.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/shard-engine/src/ctx-db.ts`:
- Around line 3627-3628: Replace the runUnmetered wrapper in insertOne with a
local, per-operation metering bypass so concurrent operations sharing the writer
remain metered. Extract the global forwarding and broadcast logic into an
internal helper that accepts whether its own charge should be skipped, and
invoke it from insertOne with the precharged case while preserving normal
metering for other writer operations.

---

Duplicate comments:
In `@packages/ai/src/rag/define-rag.ts`:
- Around line 34-52: Replace the heuristic metadata reserve with exact
validation of every final Vectorize metadata object in the indexing flow,
including built-in and custom chunks, input.metadata, and textStore
configurations; measure the UTF-8 byte length before textStore.put or
lexicalStore.index and fail through the project’s established error-handling
pattern before any side-store writes or vectors.upsert. Retain the existing
configuration check only as early feedback, and update its documentation to
state that textStore removes TEXT_KEY but does not remove the complete
per-vector metadata limit. Anchor the changes around VECTORIZE_METADATA_BYTES,
the chunk/index path near textStore.put and lexicalStore.index, and the final
metadata construction using TEXT_KEY.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6c3c8146-0b45-4b0d-81df-72660727a075

📥 Commits

Reviewing files that changed from the base of the PR and between 8fe01b6 and c8f5ab4.

⛔ Files ignored due to path filters (2)
  • packages/ai/__tests__/rag.test.ts is excluded by !**/__tests__/**, !**/*.test.ts and included by packages/**
  • packages/shard-engine/__tests__/workerd-sql-limits.test.ts is excluded by !**/__tests__/**, !**/*.test.ts and included by packages/**
📒 Files selected for processing (5)
  • apps/docs/src/content/docs/limits.mdx
  • packages/ai/src/rag/define-rag.ts
  • packages/shard-engine/src/ctx-db.ts
  • packages/shard-engine/src/do-exec.ts
  • packages/shard-engine/src/drizzle.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/shard-engine/src/drizzle.ts
  • apps/docs/src/content/docs/limits.mdx
  • packages/shard-engine/src/do-exec.ts

Comment thread packages/shard-engine/src/ctx-db.ts Outdated
`insertMany` suppressed the per-row charge with `runUnmetered`, which sets a
writer-wide flag for the duration of its await. Anything interleaved into
that await on the same writer — a routine
`Promise.all([ctx.db.insertMany("<global>", rows), ctx.db.insert(…)])` —
skipped `meterWrite` too, local writes included. `deleteAll` accepts that
trade for an unusual pattern; a global `insertMany` is ordinary.

The global half of `insert` moves into an `insertGlobal` helper that takes
the charge as a parameter, so the batch suppresses only its own rows and a
concurrent write stays metered. `runUnmetered` is back to `deleteAll` as its
sole caller, which is what its own docblock already claimed.

Also record what the global charge does not cover: the caller's document,
not the row D1 stores, so `writtenBytes` runs light by whatever the far-side
defaults add. `writtenRows` — the ceiling that protects the isolate — is
exact.

Vectorize metadata is now measured where it is assembled. The config-time
`chunkSize` check compares CHARACTERS against a byte ceiling, so ~3.4k
characters of CJK exceed 10 KiB at a `chunkSize` it waves through, and the
caller's own `metadata` is not known until index time — the overhead reserve
can only guess at it. Both reached Vectorize and failed there with nothing
naming the cause, which is the failure the check exists to replace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@prisis
prisis merged commit f438f80 into alpha Aug 10, 2026
41 checks passed
@prisis
prisis deleted the fix/remaining-platform-limits branch August 10, 2026 17:19
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

This pull request has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs.
Please note this issue tracker is not a help forum. We recommend using our GitHub Discussions tab for questions.

@github-actions github-actions Bot locked as resolved and limited conversation to collaborators Sep 9, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants