Skip to content
Open
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
37 changes: 35 additions & 2 deletions backend/src/routes/v1/admin.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
getIndexerStatus,
resetIndexer,
replayFromLedger,
previewReset,
previewReplay,
} from '../../services/indexerService.js';

import { prisma, pool } from '../../lib/prisma.js';
Expand Down Expand Up @@ -340,8 +342,15 @@ router.get('/indexer/status', async (req: Request, res: Response) => {
* /v1/admin/indexer/reset:
* post:
* tags: [Admin]
* summary: Reset indexer lastProcessedLedger
* summary: Reset indexer lastProcessedLedger (supports dry-run preview)
* security: [{ adminAuth: [] }]
* parameters:
* - in: query
* name: dryRun
* schema:
* type: boolean
* default: false
* description: If true, return the projected reset scope without mutating state.
* requestBody:
* required: true
* content:
Expand Down Expand Up @@ -393,7 +402,15 @@ router.post('/indexer/reset', async (req: Request, res: Response) => {
res.status(400).json({ error: 'ledger must be a non-negative integer' });
return;
}

const dryRun = req.query.dryRun === 'true';

try {
if (dryRun) {
const preview = await previewReset(ledger);
res.json({ dryRun: true, preview });
return;
}
await resetIndexer(ledger);
res.json({ ok: true, lastLedger: ledger });
} catch (err) {
Expand All @@ -406,15 +423,23 @@ router.post('/indexer/reset', async (req: Request, res: Response) => {
* /v1/admin/indexer/replay:
* post:
* tags: [Admin]
* summary: Replay events from a given ledger (StreamEvent rows deduplicated; stream mutations not idempotent — see indexerService.ts JSDoc)
* summary: Replay events from a given ledger (supports dry-run preview)
* security: [{ adminAuth: [] }]
* parameters:
* - in: query
* name: from_ledger
* required: true
* schema:
* type: integer
* - in: query
* name: dryRun
* schema:
* type: boolean
* default: false
* description: If true, return the projected replay scope without mutating state.
* responses:
* 200:
* description: Dry-run preview of the replay scope
* 202:
* description: Replay started
* content:
Expand Down Expand Up @@ -456,7 +481,15 @@ router.post('/indexer/replay', async (req: Request, res: Response) => {
res.status(400).json({ error: 'from_ledger must be a non-negative integer' });
return;
}

const dryRun = req.query.dryRun === 'true';

try {
if (dryRun) {
const preview = await previewReplay(fromLedger);
res.json({ dryRun: true, preview });
return;
}
const requestId = await replayFromLedger(fromLedger);
res.status(202).json({ ok: true, replayingFrom: fromLedger, requestId });
} catch (err) {
Expand Down
68 changes: 68 additions & 0 deletions backend/src/services/indexer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,74 @@ export async function resetIndexer(toLedger: number): Promise<void> {
logger.info(`[IndexerService] Reset lastProcessedLedger to ${toLedger}`);
}

/**
* Preview what a reset would do without mutating state.
* Returns the current cursor and the target ledger so operators can
* verify the intended scope before committing.
*/
export interface ResetPreview {
currentLastLedger: number;
currentLastCursor: string | null;
targetLastLedger: number;
}

export async function previewReset(targetLedger: number): Promise<ResetPreview> {
const state = await prisma.indexerState.findUnique({
where: { id: INDEXER_STATE_ID },
});
return {
currentLastLedger: state?.lastLedger ?? 0,
currentLastCursor: state?.lastCursor ?? null,
targetLastLedger: targetLedger,
};
}

/**
* Preview what a replay from a given ledger would do without mutating state.
* Returns the event count, ledger range, and current cursor so operators can
* sanity-check before committing a destructive replay.
*/
export interface ReplayPreview {
fromLedger: number;
currentLastLedger: number;
currentLastCursor: string | null;
eventCount: number;
minLedgerInReplayRange: number | null;
maxLedgerInReplayRange: number | null;
}

export async function previewReplay(
fromLedger: number,
): Promise<ReplayPreview> {
const state = await prisma.indexerState.findUnique({
where: { id: INDEXER_STATE_ID },
});
const currentLastLedger = state?.lastLedger ?? 0;

const rangeFilter: import('../generated/prisma/index.js').Prisma.StreamEventWhereInput =
currentLastLedger > 0
? { ledgerSequence: { gte: fromLedger, lte: currentLastLedger } }
: { ledgerSequence: { gte: fromLedger } };

const [eventCount, aggregate] = await Promise.all([
prisma.streamEvent.count({ where: rangeFilter }),
prisma.streamEvent.aggregate({
where: rangeFilter,
_min: { ledgerSequence: true },
_max: { ledgerSequence: true },
}),
]);

return {
fromLedger,
currentLastLedger,
currentLastCursor: state?.lastCursor ?? null,
eventCount,
minLedgerInReplayRange: aggregate._min.ledgerSequence,
maxLedgerInReplayRange: aggregate._max.ledgerSequence,
};
}

export async function replayFromLedger(fromLedger: number, customRequestId?: string): Promise<string> {
const requestId = customRequestId || requestContext.getStore()?.requestId || randomUUID();
return requestContext.run({ requestId }, async () => {
Expand Down
92 changes: 92 additions & 0 deletions backend/tests/integration/admin-metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ vi.mock('../../src/services/indexerService.js', () => ({
getIndexerStatus: vi.fn().mockResolvedValue({}),
resetIndexer: vi.fn().mockResolvedValue(undefined),
replayFromLedger: vi.fn().mockResolvedValue(undefined),
previewReset: vi.fn().mockResolvedValue({}),
previewReplay: vi.fn().mockResolvedValue({}),
}));

vi.mock('../../src/workers/soroban-event-worker.js', () => ({
Expand All @@ -113,6 +115,8 @@ import {
getIndexerStatus,
resetIndexer,
replayFromLedger,
previewReset,
previewReplay,
} from '../../src/services/indexerService.js';

// ─── Helpers ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -520,6 +524,50 @@ describe('POST /v1/admin/indexer/reset', () => {
expect(res.status).toBe(500);
expect(res.body).toEqual({ error: 'Reset failed' });
});

it('returns a dry-run preview without calling resetIndexer when dryRun=true', async () => {
const preview = {
currentLastLedger: 1000,
currentLastCursor: 'cursor_xyz',
targetLastLedger: 500,
};
vi.mocked(previewReset).mockResolvedValueOnce(preview);

const res = await request(app)
.post('/v1/admin/indexer/reset?dryRun=true')
.set('Authorization', `Bearer ${createToken()}`)
.send({ ledger: 500 });

expect(res.status).toBe(200);
expect(res.body).toEqual({ dryRun: true, preview });
expect(previewReset).toHaveBeenCalledWith(500);
expect(resetIndexer).not.toHaveBeenCalled();
});

it('treats non-true dryRun values as a real reset', async () => {
const res = await request(app)
.post('/v1/admin/indexer/reset?dryRun=false')
.set('Authorization', `Bearer ${createToken()}`)
.send({ ledger: 300 });

expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true, lastLedger: 300 });
expect(resetIndexer).toHaveBeenCalledWith(300);
expect(previewReset).not.toHaveBeenCalled();
});

it('returns 500 when previewReset throws for a dry-run request', async () => {
vi.mocked(previewReset).mockRejectedValueOnce(new Error('Preview failed'));

const res = await request(app)
.post('/v1/admin/indexer/reset?dryRun=true')
.set('Authorization', `Bearer ${createToken()}`)
.send({ ledger: 500 });

expect(res.status).toBe(500);
expect(res.body).toEqual({ error: 'Reset failed' });
expect(resetIndexer).not.toHaveBeenCalled();
});
});

describe('POST /v1/admin/indexer/replay', () => {
Expand Down Expand Up @@ -587,5 +635,49 @@ describe('POST /v1/admin/indexer/replay', () => {
expect(res.status).toBe(500);
expect(res.body).toEqual({ error: 'Replay failed' });
});

it('returns a dry-run preview without triggering a replay when dryRun=true', async () => {
const preview = {
fromLedger: 200,
currentLastLedger: 1000,
currentLastCursor: 'cursor_xyz',
eventCount: 42,
minLedgerInReplayRange: 210,
maxLedgerInReplayRange: 999,
};
vi.mocked(previewReplay).mockResolvedValueOnce(preview);

const res = await request(app)
.post('/v1/admin/indexer/replay?from_ledger=200&dryRun=true')
.set('Authorization', `Bearer ${createToken()}`);

expect(res.status).toBe(200);
expect(res.body).toEqual({ dryRun: true, preview });
expect(previewReplay).toHaveBeenCalledWith(200);
expect(replayFromLedger).not.toHaveBeenCalled();
});

it('treats non-true dryRun values as a real replay', async () => {
const res = await request(app)
.post('/v1/admin/indexer/replay?from_ledger=200&dryRun=false')
.set('Authorization', `Bearer ${createToken()}`);

expect(res.status).toBe(202);
expect(res.body).toMatchObject({ ok: true, replayingFrom: 200 });
expect(replayFromLedger).toHaveBeenCalledWith(200);
expect(previewReplay).not.toHaveBeenCalled();
});

it('returns 500 when previewReplay throws for a dry-run request', async () => {
vi.mocked(previewReplay).mockRejectedValueOnce(new Error('Preview failed'));

const res = await request(app)
.post('/v1/admin/indexer/replay?from_ledger=200&dryRun=true')
.set('Authorization', `Bearer ${createToken()}`);

expect(res.status).toBe(500);
expect(res.body).toEqual({ error: 'Replay failed' });
expect(replayFromLedger).not.toHaveBeenCalled();
});
});

Loading