UNPKG

@lunora/cli

Version:

The Lunora CLI: init, dev, deploy, codegen, migrate, seed, doctor, insights, logs, registry, and the rest of the project commands

174 lines (131 loc) 7.52 kB
--- name: lunora-performance-audit description: Diagnoses and fixes Lunora performance problems — full-table scans, missing indexes, OCC write conflicts, oversized subscriptions, and sharding/`.global()` scaling. Use when queries are slow, mutations conflict, `lunora insights` reports a hot-spot, or `@lunora/advisor` flags a table. --- # Lunora Performance Audit Systematically diagnose Lunora performance issues, route to the right fix, and apply it across sibling functions consistently. ## When to Use - Queries feel slow or read far more rows than they return. - Mutations retry or conflict under load (OCC). - Subscriptions fan out updates too broadly or re-run too often. - The Lunora Studio **Advisors** tab or `@lunora/advisor` flags a table. ## When Not to Use - Scale is small, traffic is modest, and there is no measured problem — prefer simpler code. Do not introduce sharding, digests, or splits on speculation. ## Core Workflow 1. **Scope** one concrete user flow with a clear entry and exit. 2. **Trace** every `ctx.db` read and write in that flow. 3. **Route** to the matching problem class below. 4. **Fix siblings** consistently — every function touching the same table should read it the same indexed way. 5. **Verify** behavior is unchanged and the advisor finding clears. ## Signal Gathering ### Runtime signal: `lunora insights` When the worker is running and has served traffic, start here — it reports the measured problem, not a suspected one: ```bash lunora insights # against the local dev worker lunora insights --shard channel:demo # scope to one shard lunora insights --limit 25 --json # machine-readable, more rows lunora insights --prod --url https://app.example.com --token $LUNORA_ADMIN_TOKEN ``` It ranks per-function **write-conflict hot-spots** (OCC contention — the sharding signal), **error rates**, and **latency outliers**. A function at the top of the write-conflict list is the direct input to the OCC section below; a latency outlier usually resolves to the read-amplification section. The Studio **Issues** panel and `lunora logs` cover the error side in more detail once `insights` tells you where to look. ### Static signal: the advisors These need no traffic, so they also work on a cold codebase: - **Lunora Studio → Advisors tab** surfaces `@lunora/advisor` findings live in dev. - `@lunora/advisor` runs ~90 static lints over `defineSchema` + discovered query reads / insert writes (plus a few runtime lints). The performance/schema rules most relevant here: - `filter-without-index` — a query filters a table with no covering index. - `unindexed-foreign-key` — a relation/FK column has no index. - `duplicate-index` / `empty-index` — wasted or malformed indexes. - `index-references-unknown-field`, `relation-references-unknown-field`, `relation-references-unknown-table` — broken index/relation definitions. - `table-without-insert` — a table is read but never written (often a schema/typo signal). ## Problem Class: Read Amplification (the common one) **Symptom:** a query reads the whole table to return a few rows; `filter-without-index` fires. **Fix:** read through an index, not `.filter()`. Declare the index on the table and use `.withIndex()` with an equality/range on the leading columns. ```ts // Bad — scans every row, then filters in memory. const mine = (await ctx.db.query("documents").collect()).filter((d) => d.orgId === orgId); // Good — declare the index… documents: defineTable({ orgId: v.string(), createdAt: v.number() /* … */ }).index("by_org_created", ["orgId", "createdAt"]); // …and read through it. const mine = await ctx.db .query("documents") .withIndex("by_org_created", (q) => q.eq("orgId", orgId)) .collect(); ``` Index columns are ordered: put equality columns first, then the range/sort column. Fix every sibling query on the table the same way. ## Problem Class: Write Conflicts (OCC) **Symptom:** mutations on hot rows retry or fail under concurrency, or the function tops the write-conflict section of `lunora insights`. ShardDO uses optimistic concurrency control — concurrent writes to the same DO that touch overlapping state conflict and retry. **Fixes, in order of preference:** 1. **Narrow the write.** Patch only the fields that changed; avoid read-modify- write over rows another mutation also touches. 2. **Partition with `.shardBy(key)`.** Move per-user / per-tenant / per-room state into its own DO so writes for different keys never contend. This is the primary horizontal-scale lever — most write contention is a single-DO hotspot. 3. **Avoid unbounded counters/aggregates in the hot path.** Accumulate in a sharded/append shape and fold lazily rather than serializing every writer through one row. ## Problem Class: Subscription Cost **Symptom:** a `useQuery` re-runs and re-pushes to many clients on unrelated writes. **Fixes:** - **Scope query args tightly** so a subscription only depends on the rows it renders — a query keyed by `orgId` should not re-run for another org's write. - **Read through indexes** (above) so the reactive dependency is the narrow index range, not the whole table. - ShardDO subscriptions are **hibernated WebSockets** — idle connections cost nothing; the lever is _how many rows each live query depends on_, not raw connection count. ## Problem Class: Cross-Region Reads **Symptom:** read-heavy, rarely-written data is slow for far-away users. **Fix:** chain `.global()` on the table to replicate it to D1 for low-latency cross-region reads (with read-your-writes via the Sessions API). Reserve it for read-mostly tables — `.global()` adds the D1 migration flow (see the `lunora-migration-helper` skill) and write-path cost. If the dataset outgrows D1, `.global({ backend: "hyperdrive" })` serves the same reactive `.global()` contract from Postgres/MySQL over Cloudflare Hyperdrive — see the `lunora-setup-hyperdrive-global` skill (and `lunora migrate d1-to-hyperdrive` to move an existing dataset). ### `.shardBy(key)` vs `.global()` — choose one per table - `.shardBy(key)`: partitions a table across Durable Objects by key — scales _writes_ (e.g. messages per room). Reads are per-shard. - `.global()`: replicates a table to D1 — scales _cross-region reads_ with read-your-writes (e.g. a mostly-read catalog). - They are not combined on the same table; the default (neither) is a single root-scoped ShardDO. ## Guardrails - Prefer simpler code when scale is small or the signal is weak. - Do not recommend structural changes (digest tables, document splitting, sharding) without a measured signal or a known hot path. - When you change how one function reads/writes a table, change its siblings to match — half-migrated access patterns are their own bug. ## Checklist - [ ] Scoped one concrete flow; traced every `ctx.db` read/write. - [ ] Ran `lunora insights` (if the worker has traffic) for the measured signal. - [ ] Checked the Studio Advisors tab / `@lunora/advisor` findings. - [ ] Read amplification: replaced `.filter()` with an indexed `.withIndex()`. - [ ] Write conflicts: narrowed writes and/or partitioned with `.shardBy(key)`. - [ ] Subscription cost: scoped query args so live queries depend on few rows. - [ ] Cross-region: applied `.global()` only to read-mostly tables. - [ ] Fixed sibling functions consistently; verified behavior unchanged.