Skip to content

feat: don't prevent sui-indexer running on locked RedeemSolver#362

Merged
robert-zaremba merged 4 commits intomasterfrom
robert/sui-indexer-lock
Feb 20, 2026
Merged

feat: don't prevent sui-indexer running on locked RedeemSolver#362
robert-zaremba merged 4 commits intomasterfrom
robert/sui-indexer-lock

Conversation

@robert-zaremba
Copy link
Contributor

@robert-zaremba robert-zaremba commented Feb 20, 2026

Summary by Sourcery

Make Sui indexer and redeem solver cron jobs independently lockable so one can run while the other is locked or busy.

Enhancements:

  • Refactor scheduled handler into a cron startup helper that acquires separate locks for the Sui indexer and redeem solver and runs only the jobs whose locks are available.
  • Extend D1Storage locking API to support acquiring and releasing multiple cron locks in a single call, returning per-lock results.

Tests:

  • Update storage lock tests for the new multi-lock API and add coverage for acquiring multiple locks and handling partial lock acquisition scenarios.

Signed-off-by: Robert Zaremba <robert@zaremba.ch>
Copilot AI review requested due to automatic review settings February 20, 2026 12:00
@robert-zaremba robert-zaremba requested a review from a team as a code owner February 20, 2026 12:00
@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Feb 20, 2026

Reviewer's Guide

Refactors the Sui indexer scheduled job to acquire and manage independent locks for the indexer and redeem solver, enabling the indexer to run even when the redeem solver is locked, and updates storage locking utilities and tests to support acquiring and releasing multiple locks at once.

Sequence diagram for scheduled cron running SuiIndexer and RedeemSolver with independent locks

sequenceDiagram
    participant Cron as CronScheduler
    participant Worker as SuiIndexerWorker
    participant Storage as D1Storage
    participant SuiIndexer as SuiIndexer
    participant RedeemSolver as RedeemSolver

    Cron->>Worker: scheduled(event, env, ctx)
    activate Worker
    Worker->>Worker: startCronJobs(env)

    Worker->>Storage: new D1Storage(env.DB)
    Worker->>Storage: getActiveNetworks()
    Storage-->>Worker: activeNetworks

    Worker->>Worker: getSecret(env.NBTC_MINTING_SIGNER_MNEMONIC)
    Worker-->>Worker: mnemonic
    Worker->>Worker: createSuiClients(activeNetworks, mnemonic)
    Worker-->>Worker: suiClients

    Worker->>Storage: acquireLocks([lockIndexer, lockRedeemSolver], ttl)
    Storage-->>Worker: [indexerLockToken, redeemSolverLockToken]

    alt indexerLockToken is not null
        Worker->>SuiIndexer: runSuiIndexer(storage, activeNetworks, suiClients)
    else indexerLockToken is null
        Worker->>Worker: logger.warn SuiIndexer lock is busy
    end

    alt redeemSolverLockToken is not null
        Worker->>RedeemSolver: runRedeemSolver(storage, env, suiClients, activeNetworks)
    else redeemSolverLockToken is null
        Worker->>Worker: logger.warn RedeemSolver lock is busy
    end

    alt no jobs started
        Worker-->>Cron: return
    else at least one job started
        Worker->>Worker: Promise.allSettled(jobs)
        Worker-->>Worker: results
        Worker->>Worker: reportErrors(results, "scheduled", "Scheduled task error", jobNames)
        Worker->>Storage: releaseLocks([lockIndexer, lockRedeemSolver])
        Storage-->>Worker: void
        Worker-->>Cron: return
    end
    deactivate Worker
Loading

Class diagram for updated D1Storage locking API and worker cron entrypoint

classDiagram
    class D1Storage {
        - db: D1Database
        + constructor(db: D1Database)
        + getActiveNetworks(): Promise~string[]~
        + acquireLocks(lockNames: string[], ttlMs: number): Promise~(number | null)[]~
        + releaseLocks(lockNames: string[]): Promise~void~
    }

    class SuiIndexerWorker {
        + fetch(request: Request, env: Env, ctx: ExecutionContext): Promise~Response~
        + scheduled(event: ScheduledController, env: Env, ctx: ExecutionContext): Promise~void~
        + startCronJobs(env: Env): Promise~void~
    }

    class SuiTasks {
        + runSuiIndexer(storage: D1Storage, activeNetworks: string[], suiClients: SuiNet[]): Promise~void~
        + runRedeemSolver(storage: D1Storage, env: Env, suiClients: SuiNet[], activeNetworks: string[]): Promise~void~
    }

    SuiIndexerWorker --> D1Storage : uses
    SuiIndexerWorker --> SuiTasks : invokes
    D1Storage ..> D1Database : wraps
Loading

File-Level Changes

Change Details Files
Refactor scheduled handler into a shared cron startup function with separate locks for the Sui indexer and RedeemSolver, and parallel execution of whichever jobs successfully obtain locks.
  • Extract scheduled handler logic into a new startCronJobs(env) helper and have scheduled() delegate to it.
  • Acquire two independent cron locks (for indexer and redeem solver) via the new multi-lock API and conditionally enqueue each job based on whether its lock was obtained.
  • Run the selected cron jobs in parallel with Promise.allSettled and pass dynamic job names into reportErrors.
  • Ensure all acquired locks are released via the new releaseLocks helper in a finally block and add logging when locks are busy.
packages/sui-indexer/src/index.ts
Extend D1Storage to support acquiring and releasing multiple cron locks in one call while preserving per-lock error logging.
  • Replace acquireLock with acquireLocks that iterates over a list of lock names, running the same INSERT ... ON CONFLICT ... WHERE expires_at <= acquired_at logic for each and collecting per-lock tokens.
  • Replace releaseLock with releaseLocks that iterates over lock names and deletes each from cron_locks with dedicated error logging per lock.
  • Preserve existing error handling behavior (logError and rethrow) inside the per-lock try/catch blocks for both acquire and release operations.
packages/sui-indexer/src/storage.ts
Update and extend storage tests to cover the new multi-lock API and its behavior.
  • Update existing tests to call acquireLocks/releaseLocks instead of acquireLock/releaseLock and adjust assertions to use token arrays.
  • Add a test verifying multiple independent locks can be acquired and persist in the database.
  • Add a test ensuring that acquiring multiple locks returns null for already-held locks while still returning tokens for new ones.
packages/sui-indexer/src/storage.test.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location> `packages/sui-indexer/src/storage.ts:885-876` </location>
<code_context>
-		} catch (error) {
-			logError({ msg: "Failed to release lock", method: "releaseLock", lockName }, error);
-			throw error;
+	async releaseLocks(lockNames: string[]): Promise<void> {
+		for (const lockName of lockNames) {
+			try {
+				await this.db
+					.prepare(`DELETE FROM cron_locks WHERE lock_name = ?`)
+					.bind(lockName)
+					.run();
+			} catch (error) {
+				logError({ msg: "Failed to release lock", method: "releaseLock", lockName }, error);
+				throw error;
</code_context>

<issue_to_address>
**issue (bug_risk):** Releasing multiple locks stops on first error, potentially leaving later locks unreleased.

Since `releaseLocks` throws on the first failure, any subsequent lock names are never processed, which can leave some locks held even though the caller asked to release them all. Consider attempting all deletions and either aggregating/logging all errors before throwing once, or defining behavior for partial failures if that’s acceptable.
</issue_to_address>

### Comment 2
<location> `packages/sui-indexer/src/storage.ts:876-877` </location>
<code_context>
+					.bind(lockName, now, now + ttlMs)
+					.first<number>("acquired_at");
+				results.push(result ?? null);
+			} catch (error) {
+				logError({ msg: "Failed to acquire lock", method: "acquireLock", lockName }, error);
+				throw error;
+			}
</code_context>

<issue_to_address>
**nitpick:** Logged method names are now inconsistent with the new multi-lock API.

Within `acquireLocks`/`releaseLocks` you still log `method: "acquireLock"` / `"releaseLock"`, which no longer matches the multi-lock API. Please update the logged method name (or add a dedicated field for multi-lock usage) so logs clearly reflect the new behavior and remain easy to correlate when debugging.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Copy link
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

This PR adjusts the sui-indexer’s scheduled cron execution to use separate distributed locks for the indexer and redeem-solver jobs, so the indexer can still run even when the RedeemSolver is locked/busy.

Changes:

  • Replace single-lock APIs with multi-lock acquireLocks / releaseLocks in D1Storage.
  • Refactor the scheduled handler to acquire independent locks and run only the unlocked jobs in parallel.
  • Update and extend lock-related unit tests to cover multi-lock acquisition behavior.

Reviewed changes

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

File Description
packages/sui-indexer/src/storage.ts Introduces multi-lock acquire/release helpers for cron locking.
packages/sui-indexer/src/index.ts Uses two independent cron locks and runs unlocked jobs selectively.
packages/sui-indexer/src/storage.test.ts Updates existing lock tests and adds new multi-lock scenarios.
Comments suppressed due to low confidence (4)

packages/sui-indexer/src/index.ts:36

  • startCronJobs does DB reads + secret retrieval + client construction before attempting to acquire the cron locks. When locks are busy (common under overlap), this does unnecessary work and increases load/latency. Acquire the locks first (or at least check whether any lock was acquired) before fetching secrets/creating Sui clients.
async function startCronJobs(env: Env): Promise<void> {
	const storage = new D1Storage(env.DB);
	const activeNetworks = await storage.getActiveNetworks();
	const mnemonic = await getSecret(env.NBTC_MINTING_SIGNER_MNEMONIC);
	const suiClients = await createSuiClients(activeNetworks, mnemonic);

packages/sui-indexer/src/index.ts:15

  • PipelinePromise is imported but not used. Since this is a Workers target, importing types from Node’s stream is also easy to break if Node typings aren’t present. Remove this import if it’s not needed.
import type { PipelinePromise } from "stream";

packages/sui-indexer/src/storage.ts:878

  • The method field passed to logError still says acquireLock, but the function is now acquireLocks. Updating this keeps logs searchable/consistent.
			} catch (error) {
				logError({ msg: "Failed to acquire lock", method: "acquireLock", lockName }, error);
				throw error;

packages/sui-indexer/src/storage.ts:894

  • The method field passed to logError still says releaseLock, but the function is now releaseLocks. Updating this keeps logs searchable/consistent.
			} catch (error) {
				logError({ msg: "Failed to release lock", method: "releaseLock", lockName }, error);
				throw error;

Signed-off-by: Robert Zaremba <robert@zaremba.ch>
Copy link
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

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

Comments suppressed due to low confidence (5)

packages/sui-indexer/src/index.ts:15

  • PipelinePromise is imported but never used in this file. This adds noise and can confuse readers (and may trip linting in some setups). Remove the unused import.
import type { PipelinePromise } from "stream";

packages/sui-indexer/src/index.ts:36

  • The cron job does expensive setup work (DB query for active networks, secret fetch, client creation) before checking whether any cron locks are available. If both locks are busy, this work is wasted every minute. Acquire the locks first and return early when none are acquired; only build activeNetworks/suiClients for the jobs that will actually run.
	const storage = new D1Storage(env.DB);
	const activeNetworks = await storage.getActiveNetworks();
	const mnemonic = await getSecret(env.NBTC_MINTING_SIGNER_MNEMONIC);
	const suiClients = await createSuiClients(activeNetworks, mnemonic);

packages/sui-indexer/src/index.ts:37

  • The lock names changed from the previous cron-sui-indexer style to CronSuiIndexer/CronRedeemSolver. Renaming lock keys changes the distributed locking behavior during rollout (old/new deployments won’t contend on the same lock), which can allow overlapping runs. Consider keeping the existing lock key for the Sui indexer (and only introducing a new key for redeem solver), or add a migration/compat strategy.
	const lockNames = ["CronSuiIndexer", "CronRedeemSolver"];

packages/sui-indexer/src/index.ts:64

  • Job selection is coupled to the positional order of lockNames via switch (i). If the array order ever changes (or a new lock is inserted), it’s easy to accidentally run the wrong job under the wrong lock. Prefer mapping lock name -> job function (or iterating named tuples) to keep the lock/job association explicit.
			switch (i) {
				case 0:
					jobs.push(runSuiIndexer(storage, activeNetworks, suiClients));
					break;
				case 1:
					jobs.push(runRedeemSolver(storage, env, suiClients, activeNetworks));
					break;
				default:
					logger.error({ msg: "unhandled job index: " + i });
			}

packages/sui-indexer/src/storage.test.ts:669

  • This test can be flaky: now is captured before calling acquireLocks(), and acquired_at may equal now in the same millisecond. Using toBeGreaterThan(now) can intermittently fail; use toBeGreaterThanOrEqual(now) (or mock time) instead.
			const now = Date.now();
			const expiredTime = now - 10000;
			await db
				.prepare(
					"INSERT INTO cron_locks (lock_name, acquired_at, expires_at) VALUES (?, ?, ?)",
				)
				.bind("lock-x", expiredTime - 60000, expiredTime)
				.run();

			const tokens = await storage.acquireLocks(["lock-y", "lock-x", "lock-z"], 60000);
			expect(tokens).toHaveLength(3);
			expect(tokens[0]).toBeGreaterThan(now);
			expect(tokens[1]).toBeGreaterThan(now);
			expect(tokens[2]).toBeGreaterThan(now);

Signed-off-by: Robert Zaremba <robert@zaremba.ch>
Signed-off-by: Robert Zaremba <robert@zaremba.ch>
Copy link
Contributor

@sczembor sczembor left a comment

Choose a reason for hiding this comment

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

pre-approving, just one comment

@robert-zaremba robert-zaremba merged commit 58c150d into master Feb 20, 2026
12 checks passed
@robert-zaremba robert-zaremba deleted the robert/sui-indexer-lock branch February 20, 2026 14:47
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.

3 participants