Skip to content

fix: reduce cold-start D1 queries from 159 to ~25#532

Merged
ascorbic merged 1 commit into
mainfrom
fix/cold-start-query-explosion
Apr 13, 2026
Merged

fix: reduce cold-start D1 queries from 159 to ~25#532
ascorbic merged 1 commit into
mainfrom
fix/cold-start-query-explosion

Conversation

@ascorbic
Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a cold-start query explosion where every Worker cold start on D1 executes 159+ queries and takes 3s+ to load. Reduces to ~25 queries.

A user reported 150+ D1 queries per pageload. Root cause analysis of the query log revealed three compounding issues:

  1. Kysely Migrator introspects the entire schema twice (~116 queries on D1). The Migrator calls pragma_table_info() for every table in the database -- twice -- just to check if two migration tables exist. On D1 (which can't use cross-joins with pragma functions), each check is a separate query per table.

  2. FTS triggers keep soft-deleted content in the search index, causing a count mismatch between the FTS and content tables. verifyAndRepairAll() at startup detects the mismatch and triggers a full DROP/CREATE/INSERT rebuild for every search-enabled collection -- on every cold start.

  3. enableSearch() double-populates the FTS index during seeding. Triggers insert rows during content creation, then enableSearch() does a second bulk INSERT, doubling the row count and guaranteeing a mismatch on the next startup.

Changes

Migration short-circuit (runner.ts): Before creating the Kysely Migrator, do a single SELECT COUNT(*) FROM _emdash_migrations. If the count matches the known migration count, all migrations are applied -- skip the Migrator entirely. Saves ~116 queries on D1.

FTS trigger fix (fts-manager.ts): The INSERT trigger now has WHEN NEW.deleted_at IS NULL. The UPDATE trigger uses a conditional INSERT (INSERT ... SELECT ... WHERE NEW.deleted_at IS NULL) so soft-deleted content is removed from the FTS index instead of being re-inserted. This keeps FTS and content counts in sync, preventing false-positive rebuilds.

enableSearch() uses rebuildIndex (fts-manager.ts): Instead of createFtsTable() + populateFromContent() (which appends to existing trigger-populated rows), uses rebuildIndex() which drops and recreates cleanly.

Before vs After (D1, 3 search-enabled collections, ~57 tables)

Category Before After
Migration check ~116 queries 1 query
FTS verification ~90 queries (3 full rebuilds) ~6 queries (3 count checks)
Other init queries ~10 queries ~10 queries
Page content queries ~8 queries ~8 queries
Total ~159 queries ~25 queries

Type of change

  • Bug fix
  • Performance improvement

Checklist

AI-generated code disclosure

  • This PR includes AI-generated code

- Short-circuit migrations when all are already applied (1 query vs ~116)
- Fix FTS triggers to exclude soft-deleted content from search index
- Use rebuildIndex in enableSearch to prevent duplicate FTS rows
- Prevents false-positive FTS index rebuilds on every Worker cold start
Copilot AI review requested due to automatic review settings April 13, 2026 15:28
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 13, 2026

🦋 Changeset detected

Latest commit: 3372f11

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-playground 3372f11 Apr 13 2026, 03:18 PM

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 13, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@532

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@532

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@532

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@532

emdash

npm i https://pkg.pr.new/emdash@532

create-emdash

npm i https://pkg.pr.new/create-emdash@532

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@532

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@532

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@532

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@532

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@532

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@532

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@532

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@532

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@532

commit: 3372f11

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Reduces Worker cold-start D1 query volume by avoiding expensive Kysely migrator schema introspection when migrations are already applied, and by fixing/avoiding unnecessary FTS index rebuilds caused by soft-deletes and duplicate index population.

Changes:

  • Add a migration “fast-path” that skips the Kysely Migrator when _emdash_migrations indicates all migrations are applied.
  • Update FTS triggers to exclude soft-deleted content from the index and keep counts consistent.
  • Change enableSearch() to rebuild the index from scratch to prevent duplicate rows (especially during seeding).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
packages/core/src/search/fts-manager.ts Adjusts FTS triggers to respect deleted_at, and switches enableSearch() to rebuildIndex() to avoid duplicated index rows and startup rebuild loops.
packages/core/src/database/migrations/runner.ts Adds a cheap query-based short-circuit to skip Migrator initialization when migrations are already applied.
.changeset/wide-flies-think.md Patch changeset documenting the cold-start query reduction and FTS/migration optimizations.
Comments suppressed due to low confidence (1)

packages/core/src/search/fts-manager.ts:334

  • The new trigger logic (excluding soft-deleted rows) and the new enableSearch() behavior (rebuild-from-scratch to avoid duplicates) are key to preventing cold-start rebuild loops. There are existing integration tests for FTSManager, but none appear to cover soft-delete behavior or the duplicate-index scenario; adding a test that soft-deletes content and asserts the FTS row count stays in sync would help prevent regressions.
	 * Enable search for a collection.
	 *
	 * Uses rebuildIndex to ensure a clean state -- drop any existing FTS
	 * table/triggers, recreate them, and populate from content. This avoids
	 * duplicate rows when triggers have already populated the index (e.g.
	 * during seeding where content is inserted before search is enabled).
	 */
	async enableSearch(
		collectionSlug: string,
		options?: { weights?: Record<string, number> },
	): Promise<void> {
		if (!isSqlite(this.db)) {
			throw new Error("Full-text search is only available with SQLite databases");
		}
		// Get searchable fields
		const searchableFields = await this.getSearchableFields(collectionSlug);

		if (searchableFields.length === 0) {
			throw new Error(
				`No searchable fields defined for collection "${collectionSlug}". ` +
					`Mark at least one field as searchable before enabling search.`,
			);
		}

		// Rebuild from scratch to ensure clean state (no duplicate rows)
		await this.rebuildIndex(collectionSlug, searchableFields, options?.weights);

		// Update search config
		await this.setSearchConfig(collectionSlug, {
			enabled: true,
			weights: options?.weights,
		});

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

const result = await sql<{ count: number }>`
SELECT COUNT(*) as count FROM ${sql.ref(MIGRATION_TABLE)}
`.execute(db);
if (result.rows[0]?.count === MIGRATION_COUNT) {
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

The fast-path compares result.rows[0]?.count directly to MIGRATION_COUNT. Depending on the dialect/driver, COUNT(*) can come back as a string or bigint, which would make this comparison always fail and negate the optimization. Consider normalizing the value (e.g., Number(...)) before comparing.

Suggested change
if (result.rows[0]?.count === MIGRATION_COUNT) {
const appliedMigrationCount = Number(result.rows[0]?.count);
if (appliedMigrationCount === MIGRATION_COUNT) {

Copilot uses AI. Check for mistakes.
Comment on lines +137 to +147
try {
const result = await sql<{ count: number }>`
SELECT COUNT(*) as count FROM ${sql.ref(MIGRATION_TABLE)}
`.execute(db);
if (result.rows[0]?.count === MIGRATION_COUNT) {
return { applied: [] };
}
} catch {
// Table doesn't exist yet (first run). Fall through to the Migrator
// which will create it.
}
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

The catch {} around the fast-path will swallow any error (including unexpected DB issues) and silently fall back to the migrator, which can make real problems harder to detect. Consider only ignoring the specific "table does not exist" error and rethrowing others (or at least logging unexpected failures).

Copilot uses AI. Check for mistakes.
Comment on lines +117 to 128
// Insert trigger - only index non-deleted content
await sql
.raw(`
CREATE TRIGGER IF NOT EXISTS "${ftsTable}_insert"
AFTER INSERT ON "${contentTable}"
WHEN NEW.deleted_at IS NULL
BEGIN
INSERT INTO "${ftsTable}"(rowid, id, locale, ${fieldList})
VALUES (NEW.rowid, NEW.id, NEW.locale, ${newFieldList});
END
`)
.execute(this.db);
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

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

These triggers are created with IF NOT EXISTS. Since this PR changes trigger behavior, existing installations that already have the old triggers will keep the old definitions until something explicitly drops/recreates them (e.g., a rebuild). If you want the fix to take effect immediately, consider dropping the triggers first (or otherwise forcing recreation/versioning) before creating them.

Copilot uses AI. Check for mistakes.
@ascorbic ascorbic merged commit 1b743ac into main Apr 13, 2026
36 checks passed
@ascorbic ascorbic deleted the fix/cold-start-query-explosion branch April 13, 2026 15:35
@emdashbot emdashbot Bot mentioned this pull request Apr 13, 2026
fmhall pushed a commit to fmhall/emdash that referenced this pull request Apr 13, 2026
- Short-circuit migrations when all are already applied (1 query vs ~116)
- Fix FTS triggers to exclude soft-deleted content from search index
- Use rebuildIndex in enableSearch to prevent duplicate FTS rows
- Prevents false-positive FTS index rebuilds on every Worker cold start
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants