-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
indexer: add persistence plugin (#88)
add persistence plugin to persist the indexer’s state between restarts.
- Loading branch information
Showing
11 changed files
with
269 additions
and
39 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
import type { Cursor } from "@apibara/protocol"; | ||
import { type MockBlock, MockClient } from "@apibara/protocol/testing"; | ||
import { klona } from "klona/full"; | ||
import { open } from "sqlite"; | ||
import sqlite3 from "sqlite3"; | ||
import { describe, expect, it } from "vitest"; | ||
import { run } from "../indexer"; | ||
import { generateMockMessages } from "../testing"; | ||
import { type MockRet, getMockIndexer } from "../testing/indexer"; | ||
import { SqlitePersistence, sqlitePersistence } from "./persistence"; | ||
|
||
describe("Persistence", () => { | ||
const initDB = async () => { | ||
const db = await open({ driver: sqlite3.Database, filename: ":memory:" }); | ||
await SqlitePersistence.initialize(db); | ||
return db; | ||
}; | ||
|
||
it("should handle storing and updating a cursor", async () => { | ||
const db = await initDB(); | ||
const store = new SqlitePersistence(db); | ||
|
||
// Assert there's no data | ||
let latest = await store.get(); | ||
expect(latest).toBeUndefined(); | ||
|
||
// Insert value | ||
const cursor: Cursor = { | ||
orderKey: 5_000_000n, | ||
}; | ||
await store.put(cursor); | ||
|
||
// Check that value was stored | ||
latest = await store.get(); | ||
expect(latest).toEqual({ | ||
orderKey: 5_000_000n, | ||
uniqueKey: null, | ||
}); | ||
|
||
// Update value | ||
const updatedCursor: Cursor = { | ||
orderKey: 5_000_010n, | ||
uniqueKey: "0x1234567890", | ||
}; | ||
await store.put(updatedCursor); | ||
|
||
// Check that value was updated | ||
latest = await store.get(); | ||
expect(latest).toEqual({ | ||
orderKey: 5_000_010n, | ||
uniqueKey: "0x1234567890", | ||
}); | ||
|
||
await db.close(); | ||
}); | ||
|
||
it("should handle storing and deleting a cursor", async () => { | ||
const db = await initDB(); | ||
const store = new SqlitePersistence(db); | ||
|
||
// Assert there's no data | ||
let latest = await store.get(); | ||
expect(latest).toBeUndefined(); | ||
|
||
// Insert value | ||
const cursor: Cursor = { | ||
orderKey: 5_000_000n, | ||
}; | ||
await store.put(cursor); | ||
|
||
// Check that value was stored | ||
latest = await store.get(); | ||
expect(latest).toEqual({ | ||
orderKey: 5_000_000n, | ||
uniqueKey: null, | ||
}); | ||
|
||
// Delete value | ||
await store.del(); | ||
|
||
// Check there's no data | ||
latest = await store.get(); | ||
expect(latest).toBeUndefined(); | ||
|
||
await db.close(); | ||
}); | ||
|
||
it("should work with indexer and store cursor of last message", async () => { | ||
const client = new MockClient(messages, [{}]); | ||
|
||
// biome-ignore lint/complexity/noBannedTypes: <explanation> | ||
const persistence = sqlitePersistence<{}, MockBlock, MockRet>({ | ||
driver: sqlite3.Database, | ||
filename: "file:memdb1?mode=memory&cache=shared", | ||
}); | ||
|
||
// create mock indexer with persistence plugin | ||
const indexer = klona(getMockIndexer([persistence])); | ||
|
||
await run(client, indexer); | ||
|
||
// open same db again to check last cursor | ||
const db = await open({ | ||
driver: sqlite3.Database, | ||
filename: "file:memdb1?mode=memory&cache=shared", | ||
}); | ||
|
||
const store = new SqlitePersistence(db); | ||
|
||
const latest = await store.get(); | ||
|
||
expect(latest).toMatchInlineSnapshot(` | ||
{ | ||
"orderKey": 5000009n, | ||
"uniqueKey": null, | ||
} | ||
`); | ||
}); | ||
}); | ||
|
||
const messages = generateMockMessages(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
import type { Cursor } from "@apibara/protocol"; | ||
import { type Database, type ISqlite, open } from "sqlite"; | ||
import { defineIndexerPlugin } from "./config"; | ||
|
||
type SqliteArgs = ISqlite.Config; | ||
|
||
export function sqlitePersistence<TFilter, TBlock, TRet>(args: SqliteArgs) { | ||
return defineIndexerPlugin<TFilter, TBlock, TRet>((indexer) => { | ||
let db: Database; | ||
let store: SqlitePersistence; | ||
|
||
indexer.hooks.hook("run:before", async () => { | ||
db = await open(args); | ||
|
||
await SqlitePersistence.initialize(db); | ||
|
||
store = new SqlitePersistence(db); | ||
}); | ||
|
||
indexer.hooks.hook("connect:before", async ({ request }) => { | ||
const lastCursor = await store.get(); | ||
|
||
if (lastCursor) { | ||
request.startingCursor = lastCursor; | ||
} | ||
}); | ||
|
||
indexer.hooks.hook("sink:flush", async ({ endCursor }) => { | ||
if (endCursor) { | ||
await store.put(endCursor); | ||
} | ||
}); | ||
}); | ||
} | ||
|
||
export class SqlitePersistence { | ||
constructor(private _db: Database) {} | ||
|
||
static async initialize(db: Database) { | ||
await db.exec(` | ||
CREATE TABLE IF NOT EXISTS checkpoints ( | ||
id TEXT NOT NULL PRIMARY KEY, | ||
order_key INTEGER NOT NULL, | ||
unique_key TEXT | ||
); | ||
`); | ||
} | ||
async get(): Promise<Cursor | undefined> { | ||
const row = await this._db.get<CheckpointRow>( | ||
` | ||
SELECT * | ||
FROM checkpoints | ||
WHERE id = ? | ||
`, | ||
["default"], | ||
); | ||
|
||
if (!row) return undefined; | ||
|
||
return { orderKey: BigInt(row.order_key), uniqueKey: row.unique_key }; | ||
} | ||
|
||
async put(cursor: Cursor) { | ||
await this._db.run( | ||
` | ||
INSERT INTO checkpoints (id, order_key, unique_key) | ||
VALUES (?, ?, ?) | ||
ON CONFLICT(id) DO UPDATE SET | ||
order_key = excluded.order_key, | ||
unique_key = excluded.unique_key | ||
`, | ||
["default", Number(cursor.orderKey), cursor.uniqueKey], | ||
); | ||
} | ||
|
||
async del() { | ||
await this._db.run( | ||
` | ||
DELETE FROM checkpoints | ||
WHERE id = ? | ||
`, | ||
["default"], | ||
); | ||
} | ||
} | ||
|
||
export type CheckpointRow = { | ||
id: string; | ||
order_key: number; | ||
unique_key?: `0x${string}`; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import type { StreamDataResponse } from "@apibara/protocol"; | ||
import type { MockBlock } from "@apibara/protocol/testing"; | ||
|
||
export function generateMockMessages( | ||
count = 10, | ||
): StreamDataResponse<MockBlock>[] { | ||
return [...Array(count)].map((_, i) => ({ | ||
_tag: "data", | ||
data: { | ||
finality: "accepted", | ||
data: [{ blockNumber: BigInt(5_000_000 + i) }], | ||
endCursor: { orderKey: BigInt(5_000_000 + i) }, | ||
}, | ||
})); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,3 @@ | ||
export * from "./setup"; | ||
export * from "./vcr"; | ||
export * from "./helper"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters