Skip to content

perf(kiloclaw): index-backed first-instance lookup for setup promo#3260

Merged
RSO merged 2 commits into
mainfrom
RSO/responsible-xylophone
May 15, 2026
Merged

perf(kiloclaw): index-backed first-instance lookup for setup promo#3260
RSO merged 2 commits into
mainfrom
RSO/responsible-xylophone

Conversation

@RSO
Copy link
Copy Markdown
Contributor

@RSO RSO commented May 15, 2026

Summary

The userIsWithinFirstKiloClawInstanceWindow query in apps/web/src/lib/kiloclaw/setup-promo.ts was responsible for ~21% of read-replica time-consumed.

The original query used min(created_at) >= now() - interval and filtered only on user_id. Every existing index on kiloclaw_instances.user_id is partial on destroyed_at IS NULL, so the planner couldn't use them — destroyed rows must still count for "first instance" semantics, otherwise a returning user could destroy their first instance and re-qualify for the setup-promo window indefinitely.

Two changes:

  1. Schema — add a non-partial (user_id, created_at) btree index (IDX_kiloclaw_instances_user_id_created_at). This is the only index that can serve a user_id-only lookup over the full instance history.
  2. Query rewrite — replace the min() aggregate with ORDER BY created_at LIMIT 1 plus a JS-side window check. EXPLAIN confirms this becomes an index-only scan returning a single row.

The function's behavior is unchanged. New tests in setup-promo.test.ts (added in the first commit on this branch) lock in the semantics — including the destroyed-rows-still-count case — and pass against both the old and new implementations.

The migration uses CREATE INDEX CONCURRENTLY (precedent: 0115_real_energizer.sql) since kiloclaw_instances is a hot table and a blocking index build would lock writes.

Verification

  • Confirmed with EXPLAIN against the local test database that the rewritten query is an index-only scan using IDX_kiloclaw_instances_user_id_created_at, no heap fetches, no sequential scan.
  • Spot-checked the same EXPLAIN for the original min() query with the new index in place — planner correctly rewrites it to a Limit + Index Only Scan, confirming the index alone would have resolved the perf issue.

Visual Changes

N/A

Reviewer Notes

  • The migration starts with COMMIT; and ends with BEGIN; to allow CREATE INDEX CONCURRENTLY to run outside the transaction Drizzle wraps migrations in. Same pattern as packages/db/src/migrations/0115_real_energizer.sql. Worth a sanity check from whoever owns the migration apply path.
  • Behavior is unchanged but worth confirming the product intent: destroyed instances still count toward "first instance," meaning a user who created their first instance two months ago, destroyed it, and creates a new one today is not within the window. This matches the previous min() semantics.
  • The rewrite intentionally does the time comparison in JS instead of SQL. Two reasons: it keeps the SQL purely an index-lookup with no expression evaluation, and it makes the query plan independent of the planner's min()-via-index optimization (which can occasionally be defeated by parallel workers / unusual stats).

RSO added 2 commits May 15, 2026 10:37
Lock in current behavior before refactoring the hot-path query that
accounts for ~21% of read-replica time. Real-DB tests cover destroyed-row
semantics, multi-instance first-instance ordering, custom maxAgeHours,
and per-user isolation.
This query was ~21% of read-replica time-consumed.

The old aggregate `min(created_at) >= now() - interval` filtered only on
`user_id`; every existing index on that column is partial on
`destroyed_at IS NULL`, so the planner fell back to scanning the whole
user history (destroyed rows must still count for first-instance
semantics, so we can't add the partial predicate to the query).

Add a non-partial `(user_id, created_at)` index and rewrite the lookup
as `ORDER BY created_at LIMIT 1` with the window check in JS. Verified
with EXPLAIN that this becomes an index-only scan returning a single
row. Migration uses CREATE INDEX CONCURRENTLY (precedent: 0115) since
`kiloclaw_instances` is a hot table.
@kilo-code-bot
Copy link
Copy Markdown
Contributor

kilo-code-bot Bot commented May 15, 2026

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (6 files)
  • apps/web/src/lib/kiloclaw/setup-promo.ts — query rewrite, no issues
  • apps/web/src/lib/kiloclaw/setup-promo.test.ts — new tests, coverage is thorough
  • packages/db/src/migrations/0130_kiloclaw_instances_user_id_created_at_idx.sql — migration, no issues
  • packages/db/src/migrations/meta/0130_snapshot.json — generated snapshot, skipped
  • packages/db/src/migrations/meta/_journal.json — generated, skipped
  • packages/db/src/schema.ts — index definition, no issues

Notes

MigrationCOMMIT;…BEGIN; wrapper to allow CREATE INDEX CONCURRENTLY outside Drizzle's implicit transaction is the correct pattern (matches 0115_real_energizer.sql precedent). The IF NOT EXISTS guard is a safe addition.

Query rewriteORDER BY created_at LIMIT 1 backed by the new non-partial (user_id, created_at) index correctly replaces the min() aggregate. The JS-side window check (new Date(row.created_at).getTime() >= Date.now() - ...) is sound given created_at is notNull and the column's mode: 'string' returns a pg-formatted ISO 8601 timestamp that new Date() parses reliably.

Semantics — destroyed instances still count (no destroyed_at IS NULL filter), which is the correct intent as documented in both the PR description and the test "counts destroyed instances when computing the first-instance timestamp".

Test coverage — 9 cases cover: no instances, inside/outside window, multiple instances (oldest wins), destroyed-instance counting, custom maxAgeHours, and cross-user isolation. Solid.

Fix these issues in Kilo Cloud


Reviewed by claude-4.6-sonnet-20260217 · 852,599 tokens

@RSO RSO enabled auto-merge (squash) May 15, 2026 08:48
Copy link
Copy Markdown
Contributor

@jeanduplessis jeanduplessis left a comment

Choose a reason for hiding this comment

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

Reviewed the final combined diff and PR context. Approving per request without line comments.

@RSO RSO merged commit 5d3c954 into main May 15, 2026
40 checks passed
@RSO RSO deleted the RSO/responsible-xylophone branch May 15, 2026 09:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants