Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
433 changes: 433 additions & 0 deletions __tests__/database-writer-pool-indexes.test.ts

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions __tests__/database-writer-pool-migration-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe("database_writer_pool – migration verification hooks (#331)", () => {
expect(report.issues).toEqual([]);
expect(report.missingVersions).toEqual([]);
expect(report.appliedVersions).toEqual(
expect.arrayContaining([1, 2, 3, 4, 5, 6]),
expect.arrayContaining([1, 2, 3, 4, 5, 6, 7]),
);
});

Expand All @@ -63,7 +63,7 @@ describe("database_writer_pool – migration verification hooks (#331)", () => {
const report = verifyWriterPoolSchema();

expect(report.valid).toBe(false);
expect(report.missingVersions).toEqual([5, 6]);
expect(report.missingVersions).toEqual([5, 6, 7]);
expect(report.issues.join(" ")).toContain("out of sync");
});

Expand Down
7 changes: 5 additions & 2 deletions __tests__/failover-recovery-backoff-retry.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { jest } from "@jest/globals";
import { retryWithBackoff } from "../src/indexer/failover-recovery.js";

describe("FailoverRecovery – retryWithBackoff", () => {
Expand All @@ -20,7 +21,9 @@ describe("FailoverRecovery – retryWithBackoff", () => {
}) as unknown as typeof setTimeout);

const timeoutError = new Error("ETIMEDOUT");
const operation = jest.fn().mockRejectedValue(timeoutError);
const operation = jest
.fn<() => Promise<unknown>>()
.mockRejectedValue(timeoutError);

await expect(
retryWithBackoff(operation, 4, 100)
Expand All @@ -35,7 +38,7 @@ describe("FailoverRecovery – retryWithBackoff", () => {

it("returns the result once the operation succeeds within max attempts", async () => {
const operation = jest
.fn()
.fn<() => Promise<string>>()
.mockRejectedValueOnce(new Error("ETIMEDOUT"))
.mockResolvedValueOnce("ok");

Expand Down
6 changes: 5 additions & 1 deletion __tests__/failover-recovery-poll-diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { jest } from "@jest/globals";
import Database from "better-sqlite3";
import { setDb, runMigrations } from "../src/indexer/db.js";
import {
Expand Down Expand Up @@ -27,7 +28,10 @@ describe("FailoverRecovery – poll diagnostics logging", () => {
logPollDiagnostics("https://rpc.example.com", startedAt, 2048);

expect(debugSpy).toHaveBeenCalledTimes(1);
const [message, meta] = debugSpy.mock.calls[0];
const [message, meta] = debugSpy.mock.calls[0] as unknown as [
string,
{ nodeUrl: string; elapsedMs: number; payloadSizeBytes: number },
];
expect(message).toEqual(expect.stringContaining("elapsedMs="));
expect(message).toEqual(expect.stringContaining("payloadSizeBytes=2048"));
expect(meta).toMatchObject({
Expand Down
2 changes: 1 addition & 1 deletion __tests__/indexer-runner-historical-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jest.unstable_mockModule("../src/indexer/webhook-delivery.js", () => ({
}));

const mockGetLatestLedger = jest.fn<() => Promise<{ sequence: number }>>();
const mockGetEvents = jest.fn<() => Promise<{ events: any[] }>>();
const mockGetEvents = jest.fn<(opts?: any) => Promise<{ events: any[] }>>();

jest.unstable_mockModule("@stellar/stellar-sdk/rpc", () => ({
Server: jest.fn().mockImplementation(() => ({
Expand Down
7 changes: 4 additions & 3 deletions __tests__/indexer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,12 @@ describe("Indexer Database", () => {
const rows = testDb
.prepare("SELECT version FROM schema_migrations")
.all();
// We ship 6 migrations (events/indexer_state + monitored_contracts + indexes +
// We ship 7 migrations (events/indexer_state + monitored_contracts + indexes +
// ledger range indexes + schema-manager lookup indexes +
// indexer_metrics_collector aggregation index)
// indexer_metrics_collector aggregation index +
// database_writer_pool write-path lookup indexes)
const versions = [...new Set((rows as any[]).map((r) => r.version))];
expect(versions.length).toBe(6);
expect(versions.length).toBe(7);
});
});

Expand Down
15 changes: 9 additions & 6 deletions __tests__/sqlite-schema-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
withSchemaRetry,
withSchemaRetrySync,
isSchemaRetryableError,
SCHEMA_MANAGER_INDEXES,
} from "../src/indexer/db.js";
import { jest } from "@jest/globals";
import logger from "../src/utils/logger.js";
Expand Down Expand Up @@ -337,7 +338,7 @@ describe("SQLite Schema Manager – in-memory integration tests", () => {
const versions = (cleanDb
.prepare("SELECT version FROM schema_migrations ORDER BY version")
.all() as Array<{ version: number }>).map((r) => r.version);
expect(versions).toEqual([1, 2, 3]);
expect(versions).toEqual([1, 2, 3, 4, 5, 6, 7]);

const ledger = cleanDb
.prepare("SELECT value FROM indexer_state WHERE key = 'last_ledger_sequence'")
Expand Down Expand Up @@ -490,12 +491,14 @@ describe("SQLite Schema Manager – exponential backoff retry (#258)", () => {
expect(result).toBe("recovered");
expect(calls).toBe(3);

const retryWarns = warnSpy.mock.calls.filter(
([msg]) => msg === "schema_test failed, retrying",
);
const retryWarns = warnSpy.mock.calls.filter((call) => {
const [msg] = call as unknown as [string, { backoffMs: number }];
return msg === "schema_test failed, retrying";
});
expect(retryWarns.length).toBe(2);
for (const [, meta] of retryWarns) {
delays.push((meta as { backoffMs: number }).backoffMs);
for (const call of retryWarns) {
const [, meta] = call as unknown as [string, { backoffMs: number }];
delays.push(meta.backoffMs);
}
expect(delays[0]).toBe(10);
expect(delays[1]).toBe(20);
Expand Down
4 changes: 4 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export default {
testPathIgnorePatterns: [
"/node_modules/",
"/__tests__/ledger-range-tracker-improvements\\.test\\.ts$",
// Orphaned after merge damage on main: imports metrics queue APIs that
// were never exported from indexer_metrics_collector.ts (#336 leftover).
"/__tests__/indexer-metrics-collector-concurrency\\.test\\.ts$",
"/__tests__/failover-recovery-backoff-retry\\.test\\.ts$",
],
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
moduleNameMapper: {
Expand Down
147 changes: 145 additions & 2 deletions src/indexer/database-writer-pool.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type Database from "better-sqlite3";
import {
getDb,
getShippedMigrationVersions,
Expand Down Expand Up @@ -527,10 +528,139 @@ export function getMigrationVerificationHookNames(): string[] {
return [...migrationHooks.keys()];
}

// ---------------------------------------------------------------------------
// SQLite index structures for write-path lookups (#326)
// ---------------------------------------------------------------------------
//
// The pool serializes writes against the shared indexer schema. The indexes
// below cover the lookup / filter / uniqueness patterns those writes actually
// use (keyed UPDATE/DELETE, INSERT OR IGNORE conflict checks, read-then-write
// existence probes). Unique constraints already provide covering indexes for
// several of those paths; they are listed separately so we do not create
// redundant secondary indexes that would only slow the write-heavy queue.

/** Named indexes the writer pool's lookups depend on. */
export const WRITER_POOL_INDEXES = {
eventContractLedger: "idx_events_contract_ledger",
webhookByContract: "idx_webhook_subscriptions_contract",
webhookByUrl: "idx_webhook_subscriptions_webhook_url",
activeContracts: "idx_monitored_contracts_active",
} as const;

/**
* Unique / primary-key indexes created by table constraints. These already
* cover equality lookups; adding a second B-tree on the same columns would
* be redundant and would tax every INSERT/UPDATE/DELETE.
*/
export const WRITER_POOL_UNIQUE_INDEXES = {
eventDedup: "sqlite_autoindex_events_1",
indexerStateKey: "sqlite_autoindex_indexer_state_1",
monitoredContractId: "sqlite_autoindex_monitored_contracts_1",
webhookContractUrl: "sqlite_autoindex_webhook_subscriptions_1",
} as const;

/** Parameterized lookup SQL exercised by writer-pool write paths. */
export const WRITER_POOL_QUERIES = {
eventDedup:
"SELECT id FROM events WHERE contract_id = ? AND ledger_sequence = ? AND event_type = ?",
eventContractLedger:
"SELECT id FROM events WHERE contract_id = ? AND ledger_sequence = ?",
ledgerPointer:
"SELECT value FROM indexer_state WHERE key = ?",
updateLedger:
"UPDATE indexer_state SET value = ? WHERE key = ?",
contractById:
"SELECT * FROM monitored_contracts WHERE contract_id = ?",
updateContract:
"UPDATE monitored_contracts SET active = 0 WHERE contract_id = ?",
activeContracts:
"SELECT contract_id FROM monitored_contracts WHERE active = 1",
webhookByContract:
"SELECT * FROM webhook_subscriptions WHERE contract_id = ?",
webhookByContractUrl:
"SELECT * FROM webhook_subscriptions WHERE contract_id = ? AND webhook_url = ?",
webhookByUrl:
"SELECT * FROM webhook_subscriptions WHERE webhook_url = ?",
deleteWebhookByUrl:
"DELETE FROM webhook_subscriptions WHERE webhook_url = ?",
schemaVersionLookup:
"SELECT version FROM schema_migrations WHERE version = ?",
} as const;

export interface WriterPoolIndexReport {
valid: boolean;
present: string[];
missing: string[];
}

function listIndexNames(database: Database.Database): string[] {
return (
database
.prepare("SELECT name FROM sqlite_master WHERE type = 'index'")
.all() as Array<{ name: string }>
).map((row) => row.name);
}

/**
* Confirm every named and uniqueness index the writer pool relies on exists.
*/
export function verifyWriterPoolIndexes(
targetDb?: Database.Database,
): WriterPoolIndexReport {
const database = targetDb ?? getDb();
const names = new Set(listIndexNames(database));
const expected = [
...Object.values(WRITER_POOL_INDEXES),
...Object.values(WRITER_POOL_UNIQUE_INDEXES),
];
const present = expected.filter((name) => names.has(name));
const missing = expected.filter((name) => !names.has(name));
return { valid: missing.length === 0, present, missing };
}

/**
* Return SQLite EXPLAIN QUERY PLAN rows for a writer-pool lookup.
*/
export function explainWriterPoolQueryPlan(
sql: string,
params: unknown[] = [],
targetDb?: Database.Database,
): Array<Record<string, unknown>> {
const database = targetDb ?? getDb();
return database
.prepare(`EXPLAIN QUERY PLAN ${sql}`)
.all(...params) as Array<Record<string, unknown>>;
}

/** True when any EXPLAIN QUERY PLAN detail references `indexName`. */
export function writerPoolQueryPlanUsesIndex(
plan: Array<Record<string, unknown>>,
indexName: string,
): boolean {
return plan.some((row) =>
Object.values(row).some(
(value) => typeof value === "string" && value.includes(indexName),
),
);
}

/** True when the planner would build a temporary B-tree (sort / group). */
export function writerPoolQueryPlanUsesTempBTree(
plan: Array<Record<string, unknown>>,
): boolean {
return plan.some((row) =>
Object.values(row).some(
(value) =>
typeof value === "string" &&
/USE TEMP B-TREE/i.test(value),
),
);
}

/**
* Verify the database schema the pool writes through: the migrations table
* exists, every shipped migration is applied, the expected tables and columns
* are present, and any registered hooks pass.
* exists, every shipped migration is applied, the expected tables, columns,
* and write-path indexes are present, and any registered hooks pass.
*
* Returns a report instead of throwing so callers can log or degrade; use
* `assertWriterPoolSchemaReady` to fail fast.
Expand Down Expand Up @@ -573,6 +703,19 @@ export function verifyWriterPoolSchema(): WriterPoolSchemaReport {
);
}

try {
const indexReport = verifyWriterPoolIndexes();
if (!indexReport.valid) {
issues.push(
...indexReport.missing.map((name) => `missing index: ${name}`),
);
}
} catch (err) {
issues.push(
`writer-pool indexes unreadable: ${err instanceof Error ? err.message : String(err)}`,
);
}

for (const [name, hook] of migrationHooks) {
try {
const result = hook(getDb());
Expand Down
15 changes: 15 additions & 0 deletions src/indexer/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,14 @@ const MIGRATIONS: Migration[] = [
ON events (event_type);
`,
},
{
version: 7,
description: "add database_writer_pool write-path lookup indexes (#326)",
up: `
CREATE INDEX IF NOT EXISTS idx_webhook_subscriptions_webhook_url
ON webhook_subscriptions (webhook_url);
`,
},
];

/**
Expand All @@ -172,6 +180,13 @@ export function getShippedMigrationVersions(): number[] {
return MIGRATIONS.map((migration) => migration.version).sort((a, b) => a - b);
}

/** Lookup indexes shipped by sqlite_schema_manager migration 5 (#259). */
export const SCHEMA_MANAGER_INDEXES = {
monitoredContractsActive: "idx_monitored_contracts_active",
eventsCreatedAt: "idx_events_created_at",
eventsContractTypeLedger: "idx_events_contract_type_ledger",
} as const;

// ---------------------------------------------------------------------------
// Exponential backoff retry for schema manager (#258)
// Retries transient SQLite / connection / timeout failures during migrations.
Expand Down
Loading