diff --git a/packages/kusama/src/__snapshots__/assetHubKusama.scheduler.e2e.test.ts.snap b/packages/kusama/src/__snapshots__/assetHubKusama.scheduler.e2e.test.ts.snap index 5e5783eaa..3891445af 100644 --- a/packages/kusama/src/__snapshots__/assetHubKusama.scheduler.e2e.test.ts.snap +++ b/packages/kusama/src/__snapshots__/assetHubKusama.scheduler.e2e.test.ts.snap @@ -40,50 +40,6 @@ exports[`Kusama Asset Hub Scheduler > cancel scheduled task with wrong origin > ] `; -exports[`Kusama Asset Hub Scheduler > cancelling a named scheduled task is possible > events for scheduled task cancellation 1`] = ` -[ - { - "data": { - "index": 0, - "when": "(redacted)", - }, - "method": "Canceled", - "section": "scheduler", - }, - { - "data": { - "id": null, - "result": "Ok", - "task": "(redacted)", - }, - "method": "Dispatched", - "section": "scheduler", - }, -] -`; - -exports[`Kusama Asset Hub Scheduler > cancelling a scheduled task is possible > events for scheduled task cancellation 1`] = ` -[ - { - "data": { - "index": 0, - "when": "(redacted)", - }, - "method": "Canceled", - "section": "scheduler", - }, - { - "data": { - "id": null, - "result": "Ok", - "task": "(redacted)", - }, - "method": "Dispatched", - "section": "scheduler", - }, -] -`; - exports[`Kusama Asset Hub Scheduler > execution of scheduled preimage lookup call works > events for scheduled lookup-task execution 1`] = ` [ { @@ -206,6 +162,24 @@ exports[`Kusama Asset Hub Scheduler > scheduling a call is possible, and the cal "method": "Dispatched", "section": "scheduler", }, + { + "data": { + "id": null, + "result": "Ok", + "task": "(redacted)", + }, + "method": "Dispatched", + "section": "scheduler", + }, + { + "data": { + "id": null, + "result": "Ok", + "task": "(redacted)", + }, + "method": "Dispatched", + "section": "scheduler", + }, ] `; @@ -253,6 +227,24 @@ exports[`Kusama Asset Hub Scheduler > scheduling a named call is possible, and t "method": "Dispatched", "section": "scheduler", }, + { + "data": { + "id": null, + "result": "Ok", + "task": "(redacted)", + }, + "method": "Dispatched", + "section": "scheduler", + }, + { + "data": { + "id": null, + "result": "Ok", + "task": "(redacted)", + }, + "method": "Dispatched", + "section": "scheduler", + }, ] `; @@ -483,6 +475,24 @@ exports[`Kusama Asset Hub Scheduler > scheduling a task after a delay is possibl "method": "Dispatched", "section": "scheduler", }, + { + "data": { + "id": null, + "result": "Ok", + "task": "(redacted)", + }, + "method": "Dispatched", + "section": "scheduler", + }, + { + "data": { + "id": null, + "result": "Ok", + "task": "(redacted)", + }, + "method": "Dispatched", + "section": "scheduler", + }, ] `; diff --git a/packages/polkadot/src/__snapshots__/assetHubPolkadot.scheduler.e2e.test.ts.snap b/packages/polkadot/src/__snapshots__/assetHubPolkadot.scheduler.e2e.test.ts.snap index 945546169..9a1ffd438 100644 --- a/packages/polkadot/src/__snapshots__/assetHubPolkadot.scheduler.e2e.test.ts.snap +++ b/packages/polkadot/src/__snapshots__/assetHubPolkadot.scheduler.e2e.test.ts.snap @@ -40,50 +40,6 @@ exports[`Polkadot Asset Hub Scheduler > cancel scheduled task with wrong origin ] `; -exports[`Polkadot Asset Hub Scheduler > cancelling a named scheduled task is possible > events for scheduled task cancellation 1`] = ` -[ - { - "data": { - "index": 0, - "when": "(redacted)", - }, - "method": "Canceled", - "section": "scheduler", - }, - { - "data": { - "id": null, - "result": "Ok", - "task": "(redacted)", - }, - "method": "Dispatched", - "section": "scheduler", - }, -] -`; - -exports[`Polkadot Asset Hub Scheduler > cancelling a scheduled task is possible > events for scheduled task cancellation 1`] = ` -[ - { - "data": { - "index": 0, - "when": "(redacted)", - }, - "method": "Canceled", - "section": "scheduler", - }, - { - "data": { - "id": null, - "result": "Ok", - "task": "(redacted)", - }, - "method": "Dispatched", - "section": "scheduler", - }, -] -`; - exports[`Polkadot Asset Hub Scheduler > execution of scheduled preimage lookup call works > events for scheduled lookup-task execution 1`] = ` [ { @@ -206,6 +162,24 @@ exports[`Polkadot Asset Hub Scheduler > scheduling a call is possible, and the c "method": "Dispatched", "section": "scheduler", }, + { + "data": { + "id": null, + "result": "Ok", + "task": "(redacted)", + }, + "method": "Dispatched", + "section": "scheduler", + }, + { + "data": { + "id": null, + "result": "Ok", + "task": "(redacted)", + }, + "method": "Dispatched", + "section": "scheduler", + }, ] `; @@ -253,6 +227,24 @@ exports[`Polkadot Asset Hub Scheduler > scheduling a named call is possible, and "method": "Dispatched", "section": "scheduler", }, + { + "data": { + "id": null, + "result": "Ok", + "task": "(redacted)", + }, + "method": "Dispatched", + "section": "scheduler", + }, + { + "data": { + "id": null, + "result": "Ok", + "task": "(redacted)", + }, + "method": "Dispatched", + "section": "scheduler", + }, ] `; @@ -483,6 +475,24 @@ exports[`Polkadot Asset Hub Scheduler > scheduling a task after a delay is possi "method": "Dispatched", "section": "scheduler", }, + { + "data": { + "id": null, + "result": "Ok", + "task": "(redacted)", + }, + "method": "Dispatched", + "section": "scheduler", + }, + { + "data": { + "id": null, + "result": "Ok", + "task": "(redacted)", + }, + "method": "Dispatched", + "section": "scheduler", + }, ] `; diff --git a/packages/polkadot/src/__snapshots__/collectivesPolkadot.scheduler.e2e.test.ts.snap b/packages/polkadot/src/__snapshots__/collectivesPolkadot.scheduler.e2e.test.ts.snap index 343a93631..9c026babc 100644 --- a/packages/polkadot/src/__snapshots__/collectivesPolkadot.scheduler.e2e.test.ts.snap +++ b/packages/polkadot/src/__snapshots__/collectivesPolkadot.scheduler.e2e.test.ts.snap @@ -40,50 +40,6 @@ exports[`Collectives Polkadot Scheduler E2E tests > cancel scheduled task with w ] `; -exports[`Collectives Polkadot Scheduler E2E tests > cancelling a named scheduled task is possible > events for scheduled task cancellation 1`] = ` -[ - { - "data": { - "index": 0, - "when": "(redacted)", - }, - "method": "Canceled", - "section": "scheduler", - }, - { - "data": { - "id": null, - "result": "Ok", - "task": "(redacted)", - }, - "method": "Dispatched", - "section": "scheduler", - }, -] -`; - -exports[`Collectives Polkadot Scheduler E2E tests > cancelling a scheduled task is possible > events for scheduled task cancellation 1`] = ` -[ - { - "data": { - "index": 0, - "when": "(redacted)", - }, - "method": "Canceled", - "section": "scheduler", - }, - { - "data": { - "id": null, - "result": "Ok", - "task": "(redacted)", - }, - "method": "Dispatched", - "section": "scheduler", - }, -] -`; - exports[`Collectives Polkadot Scheduler E2E tests > execution of scheduled preimage lookup call works > events for scheduled lookup-task execution 1`] = ` [ { @@ -206,6 +162,24 @@ exports[`Collectives Polkadot Scheduler E2E tests > scheduling a call is possibl "method": "Dispatched", "section": "scheduler", }, + { + "data": { + "id": null, + "result": "Ok", + "task": "(redacted)", + }, + "method": "Dispatched", + "section": "scheduler", + }, + { + "data": { + "id": null, + "result": "Ok", + "task": "(redacted)", + }, + "method": "Dispatched", + "section": "scheduler", + }, ] `; @@ -253,6 +227,24 @@ exports[`Collectives Polkadot Scheduler E2E tests > scheduling a named call is p "method": "Dispatched", "section": "scheduler", }, + { + "data": { + "id": null, + "result": "Ok", + "task": "(redacted)", + }, + "method": "Dispatched", + "section": "scheduler", + }, + { + "data": { + "id": null, + "result": "Ok", + "task": "(redacted)", + }, + "method": "Dispatched", + "section": "scheduler", + }, ] `; @@ -483,6 +475,24 @@ exports[`Collectives Polkadot Scheduler E2E tests > scheduling a task after a de "method": "Dispatched", "section": "scheduler", }, + { + "data": { + "id": null, + "result": "Ok", + "task": "(redacted)", + }, + "method": "Dispatched", + "section": "scheduler", + }, + { + "data": { + "id": null, + "result": "Ok", + "task": "(redacted)", + }, + "method": "Dispatched", + "section": "scheduler", + }, ] `; diff --git a/packages/shared/src/helpers/index.ts b/packages/shared/src/helpers/index.ts index 3c3ef33fb..394114d3c 100644 --- a/packages/shared/src/helpers/index.ts +++ b/packages/shared/src/helpers/index.ts @@ -144,19 +144,9 @@ export async function scheduleCallListWithOrigin( ) .exhaustive() - const agenda = [ - [ - [scheduledBlock], - calls.map(({ call, origin }) => ({ - call, - origin, - })), - ], - ] - await client.dev.setStorage({ Scheduler: { - agenda: agenda, + agenda: [[[scheduledBlock], calls]], }, }) } diff --git a/packages/shared/src/scheduler.ts b/packages/shared/src/scheduler.ts index c1c8aadc9..31dfae59b 100644 --- a/packages/shared/src/scheduler.ts +++ b/packages/shared/src/scheduler.ts @@ -4,7 +4,7 @@ import { type Chain, testAccounts } from '@e2e-test/networks' import { type Client, type RootTestTree, setupNetworks } from '@e2e-test/shared' import type { SubmittableExtrinsic } from '@polkadot/api/types' -import type { PalletSchedulerScheduled, SpWeightsWeightV2Weight } from '@polkadot/types/lookup' +import type { SpWeightsWeightV2Weight } from '@polkadot/types/lookup' import type { ISubmittableResult } from '@polkadot/types/types' import { sha256AsU8a } from '@polkadot/util-crypto' @@ -18,6 +18,7 @@ import { checkSystemEvents, getBlockNumber, nextSchedulableBlockNum, + scheduleInlineCallListWithSameOrigin, scheduleInlineCallWithOrigin, scheduleLookupCallWithOrigin, type TestConfig, @@ -57,6 +58,116 @@ import { /// Helpers /// ------- +/** + * Append some tasks to the agenda at the given block number. + * + * In the below tests involving the scheduler, a common pattern is to assume the agenda under test to be empty, as the + * `scheduleInlineCallWithOrigin` helper function erases the block number's agenda before inserting a task into it. + * However, if the task being inserted is itself a scheduling call, then this second call will be scheduled normally, + * and won't truncate its block's agenda. + * + * Accessing the agenda with `agenda[0]` is an antipattern, and so this function is useful to check whether the method + * being used to search for a task (usually `find*ScheduledTask`) in a general manner is correct. + * tests with `agenda[0]` may fail, as the first task in the agenda may or may not be the one being tested. + */ +async function addDummyTasksToAgenda< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(client: Client, targetBlockNumber: number): Promise { + const currentAgenda = await client.api.query.scheduler.agenda(targetBlockNumber!) + const bogusCall = client.api.tx.system.remark('bogus task').method.toHex() + const modifiedAgenda = [...currentAgenda] + + const task = client.api.createType('Option', { + call: { Inline: bogusCall }, + maybeId: null, + priority: 1, + maybePeriodic: null, + origin: { system: 'Root' }, + }) + + // `.unshift()` prepends, `.push()` would append. + modifiedAgenda.push(task, task) + + await client.dev.setStorage({ + Scheduler: { + agenda: [[[targetBlockNumber], modifiedAgenda]], + }, + }) +} + +/** + * Find an unnamed scheduled task in the agenda that matches the specified criteria. + * + * @param client The test client connected to the forked chain. + * @param blockNumber The block number in which to search the agenda. + * @param callHex The hex-encoded call to look for. + * @param priority Priority of the task being looked for. + * @returns An object containing the task (if found), its index, and the full scheduled agenda. + */ +async function findUnnamedScheduledTask< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(client: Client, blockNumber: number, callHex: string, priority: number) { + const scheduled = await client.api.query.scheduler.agenda(blockNumber) + + let taskIndex = -1 + const task = scheduled.find((item, index) => { + if (!item.isSome) return false + const unwrapped = item.unwrap() + + if (unwrapped.maybeId.isSome) return false + if (unwrapped.priority.toNumber() !== priority) return false + if (!unwrapped.call.isInline || unwrapped.call.asInline.toHex() !== callHex) return false + + taskIndex = index + return true + }) + + return { + task: task?.isSome ? task.unwrap() : undefined, + taskIndex, + scheduled, + } +} + +/** + * Find a named scheduled task in the agenda that matches the specified criteria. + * + * @param client The test client connected to the forked chain. + * @param blockNumber The block number in which to search the agenda. + * @param callHex The hex-encoded call to look for. + * @param priority Priority of the task being looked for. + * @param taskId The task ID to match. + * @returns An object containing the task (if found), its index, and the full scheduled agenda. + */ +async function findNamedScheduledTask< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(client: Client, blockNumber: number, callHex: string, priority: number, taskId: Uint8Array) { + const scheduled = await client.api.query.scheduler.agenda(blockNumber) + + let taskIndex = -1 + const task = scheduled.find((item, index) => { + if (!item.isSome) return false + const unwrapped = item.unwrap() + + if (unwrapped.maybeId.isNone) return false + if (unwrapped.maybeId.unwrap().toU8a().toString() !== taskId.toString()) return false + if (unwrapped.priority.toNumber() !== priority) return false + if (!unwrapped.call.isInline || unwrapped.call.asInline.toHex() !== callHex) return false + + taskIndex = index + return true + }) + + return { + task: task?.isSome ? task.unwrap() : undefined, + taskIndex, + scheduled, + } +} + /** * Helper used in tests to origin checks on `Root`-gated scheduler extrinsics. * @@ -186,6 +297,9 @@ export async function cancelScheduledTaskBadOriginTest< const scheduled = await client.api.query.scheduler.agenda(targetBlockNumber!) expect(scheduled.length).toBe(1) + // Note: Hardcoded index 0 is used, because this test only verifies that the origin is improper. + // It doesn't matter which specific task is being canceled - as long as the agenda is not empty, + // the test serves its purpose. expect(scheduled[0].isSome).toBeTruthy() await check(scheduled[0].unwrap()).toMatchObject({ maybeId: null, @@ -239,6 +353,9 @@ export async function cancelNamedScheduledTaskBadOriginTest< const scheduled = await client.api.query.scheduler.agenda(targetBlockNumber!) expect(scheduled.length).toBe(1) + // Note: Hardcoded index 0 is used, because this test only verifies that the origin is improper. + // It doesn't matter which specific task is being canceled - as long as the agenda is not empty, + // the test serves its purpose. expect(scheduled[0].isSome).toBeTruthy() await check(scheduled[0].unwrap()) .redact({ redactKeys: /maybeId/ }) @@ -294,20 +411,22 @@ export async function scheduledCallExecutes< await client.dev.newBlock() - let scheduled = await client.api.query.scheduler.agenda(targetBlockNumber!) - expect(scheduled.length).toBe(1) - expect(scheduled[0].isSome).toBeTruthy() + // Insert irrelevant task to test agenda indexing + await addDummyTasksToAgenda(client, targetBlockNumber!) - await check(scheduled[0].unwrap()).toMatchObject({ + let { task, scheduled } = await findUnnamedScheduledTask( + client, + targetBlockNumber!, + adjustIssuanceTx.method.toHex(), + 0, + ) + expect(scheduled.length).toBeGreaterThanOrEqual(1) + expect(task).toBeDefined() + + await check(task).toMatchObject({ maybeId: null, priority: 0, call: { inline: adjustIssuanceTx.method.toHex() }, - maybePeriodic: null, - origin: { - system: { - root: null, - }, - }, }) await client.dev.newBlock() @@ -368,20 +487,23 @@ export async function scheduledNamedCallExecutes< await client.dev.newBlock() - let scheduled = await client.api.query.scheduler.agenda(targetBlockNumber!) - expect(scheduled.length).toBe(1) - expect(scheduled[0].isSome).toBeTruthy() + // Insert irrelevant task to test agenda indexing + await addDummyTasksToAgenda(client, targetBlockNumber!) - await check(scheduled[0].unwrap()).toMatchObject({ + let { task, scheduled } = await findNamedScheduledTask( + client, + targetBlockNumber!, + adjustIssuanceTx.method.toHex(), + 0, + taskId, + ) + expect(scheduled.length).toBeGreaterThanOrEqual(1) + expect(task).toBeDefined() + + await check(task).toMatchObject({ maybeId: `0x${Buffer.from(taskId).toString('hex')}`, priority: 0, call: { inline: adjustIssuanceTx.method.toHex() }, - maybePeriodic: null, - origin: { - system: { - root: null, - }, - }, }) await client.dev.newBlock() @@ -416,6 +538,93 @@ export async function cancelScheduledTask< const adjustIssuanceTx = client.api.tx.balances.forceAdjustTotalIssuance('Increase', 1) + const initialBlockNumber = await getBlockNumber(client.api, testConfig.blockProvider) + // This won't represent the same timespan in relay and parachains, but for this test, that is irrelevant. + const targetBlockNumber: number = initialBlockNumber + 1000 + + const scheduleTx = client.api.tx.scheduler.schedule(targetBlockNumber!, null, 0, adjustIssuanceTx) + + await scheduleInlineCallWithOrigin(client, scheduleTx.method.toHex(), { system: 'Root' }, testConfig.blockProvider) + + await client.dev.newBlock() + + // Insert irrelevant tasks just before the total issuance change that was scheduled above + await addDummyTasksToAgenda(client, targetBlockNumber!) + + // Find this test's scheduled task (unnamed, priority 0, with the `adjustIssuance` call) + const adjustIssuanceCall = adjustIssuanceTx.method.toHex() + let { task, taskIndex, scheduled } = await findUnnamedScheduledTask(client, targetBlockNumber!, adjustIssuanceCall, 0) + + const preCancellationAgendaLength = scheduled.length + expect(preCancellationAgendaLength).toBeGreaterThanOrEqual(1) + expect(task).toBeDefined() + expect(taskIndex).toBeGreaterThanOrEqual(0) + + const cancelTx = client.api.tx.scheduler.cancel(targetBlockNumber!, taskIndex) + + await scheduleInlineCallWithOrigin(client, cancelTx.method.toHex(), { system: 'Root' }, testConfig.blockProvider) + + await client.dev.newBlock() + + // Check events - should have ExtrinsicSuccess and Canceled, but no TotalIssuanceForced + const events = await client.api.query.system.events() + + const schedulerEvents = events.filter((record) => { + const { event } = record + return event.section === 'scheduler' + }) + + // Should have exactly one Canceled event + const canceledEvents = schedulerEvents.filter((record) => { + const { event } = record + return event.method === 'Canceled' + }) + expect(canceledEvents.length).toBe(1) + + // Verify the Canceled event data + const canceledEvent = canceledEvents[0] + assert(client.api.events.scheduler.Canceled.is(canceledEvent.event)) + const eventData = canceledEvent.event.data + expect(eventData.when.toNumber()).toBe(targetBlockNumber!) + expect(eventData.index.toNumber()).toBe(taskIndex) + + // Should have no TotalIssuanceForced events + const issuanceForcedEvents = events.filter((record) => { + const { event } = record + return event.section === 'balances' && event.method === 'TotalIssuanceForced' + }) + expect(issuanceForcedEvents.length).toBe(0) + + // Agenda's length should be the same, but the first entry which was just cancelled should be `None` + scheduled = await client.api.query.scheduler.agenda(targetBlockNumber) + const postCancellationAgendaLength = scheduled.length + expect(postCancellationAgendaLength).toBe(preCancellationAgendaLength) + + expect(scheduled[0].isNone).toBeTruthy() +} + +/** + * Test cancellation of a named scheduled task. + * + * This test verifies the behavior of `scheduler.cancelNamed` when cancelling a named task. It checks that: + * 1. Schedule a named task for future execution. + * 2. Verify the task is in the agenda and lookup entry points to correct block and index. + * 3. Cancel the named task. + * 4. Verify `Canceled` event is emitted, no `TotalIssuanceForced` event, lookup is removed, and task is removed from agenda. + * + * @param chain The test chain. + * @param testConfig The test configuration. + */ +export async function cancelScheduledNamedTask< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, testConfig: TestConfig) { + const [client] = await setupNetworks(chain) + + const adjustIssuanceTx = client.api.tx.balances.forceAdjustTotalIssuance('Increase', 1) + + const taskId = sha256AsU8a('task_id') + const initialBlockNumber = await getBlockNumber(client.api, testConfig.blockProvider) const offset = blockProviderOffset(testConfig) let targetBlockNumber: number @@ -428,46 +637,122 @@ export async function cancelScheduledTask< }) .exhaustive() - const scheduleTx = client.api.tx.scheduler.schedule(targetBlockNumber!, null, 0, adjustIssuanceTx) + // ---------------------- + // 1. Schedule named task + // ---------------------- - await scheduleInlineCallWithOrigin(client, scheduleTx.method.toHex(), { system: 'Root' }, testConfig.blockProvider) + const scheduleNamedTx = client.api.tx.scheduler.scheduleNamed(taskId, targetBlockNumber!, null, 0, adjustIssuanceTx) + + await scheduleInlineCallWithOrigin( + client, + scheduleNamedTx.method.toHex(), + { system: 'Root' }, + testConfig.blockProvider, + ) await client.dev.newBlock() - let scheduled = await client.api.query.scheduler.agenda(targetBlockNumber!) - expect(scheduled.length).toBe(1) - expect(scheduled[0].isSome).toBeTruthy() + // Insert irrelevant task to test agenda indexing + await addDummyTasksToAgenda(client, targetBlockNumber!) - const cancelTx = client.api.tx.scheduler.cancel(targetBlockNumber!, 0) + // ----------------------------------------------------------------------------- + // 2. Check data: verify task is in agenda and lookup points to correct location + // ----------------------------------------------------------------------------- + + // Note: `cancelNamed` finds the task by ID, so position in the agenda doesn't matter. + const { task, scheduled } = await findNamedScheduledTask( + client, + targetBlockNumber!, + adjustIssuanceTx.method.toHex(), + 0, + taskId, + ) + expect(scheduled.length).toBeGreaterThanOrEqual(1) + expect(task).toBeDefined() + + // Verify lookup entry exists and points to correct block and index + const lookupBeforeCancellation = await client.api.query.scheduler.lookup(taskId) + expect(lookupBeforeCancellation.isSome).toBeTruthy() + const [lookupBlock, lookupIndex] = lookupBeforeCancellation.unwrap() + expect(lookupBlock.toNumber()).toBe(targetBlockNumber!) + + // Find the actual task index in the agenda to verify lookup points to correct index + const actualTaskIndex = scheduled.findIndex((item) => { + if (!item.isSome) return false + const unwrapped = item.unwrap() + return unwrapped.maybeId.isSome && unwrapped.maybeId.unwrap().toU8a().toString() === taskId.toString() + }) + expect(actualTaskIndex).toBeGreaterThanOrEqual(0) + expect(lookupIndex.toNumber()).toBe(actualTaskIndex) + + // ------------------------ + // 3. Cancel the named task + // ------------------------ + + const cancelTx = client.api.tx.scheduler.cancelNamed(taskId) await scheduleInlineCallWithOrigin(client, cancelTx.method.toHex(), { system: 'Root' }, testConfig.blockProvider) await client.dev.newBlock() - // This should capture 2 system events, and no `TotalIssuanceForced`. - // 1. One system event will be for the test-originated dispatch injected via the helper `scheduleInlineCallWithOrigin` - // 2. The other will be a `scheduler.Cancelled` event of the scheduled task - await checkSystemEvents(client, 'scheduler', { section: 'balances', method: 'TotalIssuanceForced' }) - .redact({ - redactKeys: /new|old|when|task/, - }) - .toMatchSnapshot('events for scheduled task cancellation') + // ------------------------------------------------------------------------------- + // 4. Check data and events: verify `Canceled` event, no execution, lookup removed + // ------------------------------------------------------------------------------- - scheduled = await client.api.query.scheduler.agenda(targetBlockNumber!) - expect(scheduled.length).toBe(0) + // Check events - should have `Canceled`, but no `TotalIssuanceForced` + const events = await client.api.query.system.events() - await client.dev.newBlock() + const schedulerEvents = events.filter((record) => { + const { event } = record + return event.section === 'scheduler' + }) + + // Should have exactly one `Canceled` event + const canceledEvents = schedulerEvents.filter((record) => { + const { event } = record + return event.method === 'Canceled' + }) + expect(canceledEvents.length).toBe(1) + + // Verify the `Canceled` event data + const canceledEvent = canceledEvents[0] + assert(client.api.events.scheduler.Canceled.is(canceledEvent.event)) + const eventData = canceledEvent.event.data + expect(eventData.when.toNumber()).toBe(targetBlockNumber!) + + // Should have no `TotalIssuanceForced` events (task was cancelled, not executed) + const issuanceForcedEvents = events.filter((record) => { + const { event } = record + return event.section === 'balances' && event.method === 'TotalIssuanceForced' + }) + expect(issuanceForcedEvents.length).toBe(0) + + // Verify lookup entry is removed after cancellation + const lookupAfterCancellation = await client.api.query.scheduler.lookup(taskId) + expect(lookupAfterCancellation.isNone).toBeTruthy() + + // Verify the named task is removed from the agenda + const afterCancellation = await findNamedScheduledTask( + client, + targetBlockNumber!, + adjustIssuanceTx.method.toHex(), + 0, + taskId, + ) + expect(afterCancellation.task).toBeUndefined() } /** - * Test cancellation of a (named) scheduled task + * Test cancellation of a named task using the unnamed cancellation function. * - * 1. schedule a `Root`-origin call for execution sometime in the future - * 2. cancel the call by scheduling `scheduler.cancel` to execute before the scheduled call - * 3. verify that the original call is not executed - * 4. verify that its data is removed from the agenda + * This test verifies that a named task can be cancelled using `scheduler.cancel(block, index)` + * instead of `scheduler.cancelNamed(taskId)`. It checks that: + * 1. Schedule a named task for future execution. + * 2. Verify the task is in the agenda and lookup storage, with correct task ID, block number, and index. + * 3. Cancel the named task using `scheduler.cancel` with its block number and index. + * 4. Verify `Canceled` event is emitted, no `TotalIssuanceForced` event, lookup is removed, and task is removed from agenda. */ -export async function cancelScheduledNamedTask< +export async function cancelNamedTaskWithUnnamedCancellation< TCustom extends Record | undefined, TInitStorages extends Record> | undefined, >(chain: Chain, testConfig: TestConfig) { @@ -489,6 +774,10 @@ export async function cancelScheduledNamedTask< }) .exhaustive() + // ---------------------- + // 1. Schedule named task + // ---------------------- + const scheduleNamedTx = client.api.tx.scheduler.scheduleNamed(taskId, targetBlockNumber!, null, 0, adjustIssuanceTx) await scheduleInlineCallWithOrigin( @@ -500,36 +789,247 @@ export async function cancelScheduledNamedTask< await client.dev.newBlock() - let scheduled = await client.api.query.scheduler.agenda(targetBlockNumber!) - expect(scheduled.length).toBe(1) - expect(scheduled[0].isSome).toBeTruthy() + // Insert irrelevant task to test agenda indexing + await addDummyTasksToAgenda(client, targetBlockNumber!) - const cancelTx = client.api.tx.scheduler.cancelNamed(taskId) + // ----------------------------------------------------------------------------- + // 2. Check data: verify task is in agenda and lookup points to correct location + // ----------------------------------------------------------------------------- - await scheduleInlineCallWithOrigin(client, cancelTx.method.toHex(), { system: 'Root' }, testConfig.blockProvider) + const { task, taskIndex, scheduled } = await findNamedScheduledTask( + client, + targetBlockNumber!, + adjustIssuanceTx.method.toHex(), + 0, + taskId, + ) + expect(scheduled.length).toBeGreaterThanOrEqual(1) + expect(task).toBeDefined() + expect(taskIndex).toBeGreaterThanOrEqual(0) - await client.dev.newBlock() + // Verify lookup entry exists and points to correct block and index + const lookupBeforeCancellation = await client.api.query.scheduler.lookup(taskId) + expect(lookupBeforeCancellation.isSome).toBeTruthy() + const [lookupBlock, lookupIndex] = lookupBeforeCancellation.unwrap() + expect(lookupBlock.toNumber()).toBe(targetBlockNumber!) + expect(lookupIndex.toNumber()).toBe(taskIndex) - await checkSystemEvents(client, 'scheduler', { section: 'balances', method: 'TotalIssuanceForced' }) - .redact({ - redactKeys: /when|task/, - }) - .toMatchSnapshot('events for scheduled task cancellation') + // ----------------------------------------------------------- + // 3. Cancel the named task using unnamed cancellation function + // ----------------------------------------------------------- - scheduled = await client.api.query.scheduler.agenda(targetBlockNumber!) - expect(scheduled.length).toBe(0) + // Use `scheduler.cancel(block, index)` instead of `scheduler.cancelNamed(taskId)` + const cancelTx = client.api.tx.scheduler.cancel(targetBlockNumber!, taskIndex) + + await scheduleInlineCallWithOrigin(client, cancelTx.method.toHex(), { system: 'Root' }, testConfig.blockProvider) await client.dev.newBlock() + // ------------------------------------------------------------------------------- + // 4. Check data and events: verify `Canceled` event, no execution, lookup removed + // ------------------------------------------------------------------------------- + + const currentBlockNumber = await nextSchedulableBlockNum(client.api, testConfig.blockProvider) + expect(currentBlockNumber).toBe(targetBlockNumber!) + + // Check events - there should be one `Canceled` event const events = await client.api.query.system.events() - const filteredEvents = events.filter((ev) => { - const { event } = ev - return ( - (event.section === 'scheduler' && event.method === 'Cancelled') || - (event.section === 'balances' && event.method === 'TotalIssuanceForced') + + const canceledEvents = events.filter((record) => { + const { event } = record + return event.section === 'scheduler' && event.method === 'Canceled' + }) + expect(canceledEvents.length).toBe(1) + + // Verify the `Canceled` event data + const canceledEvent = canceledEvents[0] + assert(client.api.events.scheduler.Canceled.is(canceledEvent.event)) + const eventData = canceledEvent.event.data + expect(eventData.when.toNumber()).toBe(targetBlockNumber!) + expect(eventData.index.toNumber()).toBe(taskIndex) + + // Verify lookup entry is removed after cancellation + const lookupAfterCancellation = await client.api.query.scheduler.lookup(taskId) + expect(lookupAfterCancellation.isNone).toBeTruthy() + + // Verify the named task is removed from the agenda + const afterCancellation = await findNamedScheduledTask( + client, + targetBlockNumber!, + adjustIssuanceTx.method.toHex(), + 0, + taskId, + ) + expect(afterCancellation.task).toBeUndefined() +} + +/** + * Test cancellation of multiple named scheduled tasks and agenda cleanup. + * + * 1. Schedule 3 named tasks with total issuance adjustments of 3, 5, and 7 units + * 2. Verify all tasks are present in the agenda with correct data and lookup entries + * 3. Cancel the task with adjustment 5, verify the agenda contains a `None` entry in its place, while tasks 3 and 7 remain + * 4. Cancel the task with adjustment 3, verify both slots 0 and 1 are `None` but task 7 remains in slot 2 + * 5. Cancel the last task with adjustment 7, verify the agenda is completely empty + */ +export async function cancelNamedTask< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, testConfig: TestConfig) { + const [client] = await setupNetworks(chain) + + // Helper function to create an adjust total issuance transaction with a given adjustment amount + const adjustIssuanceTx = (amount: number) => client.api.tx.balances.forceAdjustTotalIssuance('Increase', amount) + + const initialBlockNumber = await getBlockNumber(client.api, testConfig.blockProvider) + const offset = blockProviderOffset(testConfig) + // This is *not* the same timespan across relay/parachains, but it's irrelevant: what's necessary is a task far in + // the future. + const targetBlockNumber: number = initialBlockNumber + 1000 * offset + + const scheduleNamedTx = (issuanceAdjustment: number) => + client.api.tx.scheduler.scheduleNamed( + sha256AsU8a(`task_id_${issuanceAdjustment}`), + targetBlockNumber, + null, + 0, + adjustIssuanceTx(issuanceAdjustment), ) + + const issuanceAdjustments = [3, 5, 7] + + // Helper function: create a list of scheduling requests (using named tasks), each + // scheduling a total issuance increase with a unique prime adjustment amount. + const scheduleTxs = (adjustments: number[]) => adjustments.map((adj) => scheduleNamedTx(adj)) + + // ------------------------------ + // Step 1: Schedule 3 named tasks + // ------------------------------ + + // Clear the target block's agenda to ensure only our tasks are scheduled + await client.dev.setStorage({ + Scheduler: { + agenda: [[[targetBlockNumber], []]], + }, }) - expect(filteredEvents.length).toBe(0) + + await scheduleInlineCallListWithSameOrigin( + client, + scheduleTxs(issuanceAdjustments).map((schdTx) => schdTx.method.toHex()), + { system: 'Root' }, + testConfig.blockProvider, + ) + + await client.dev.newBlock() + + // ------------------------------------------------------------------------- + // Step 2: Verify all tasks are present with correct data and lookup entries + // ------------------------------------------------------------------------- + + let scheduled = await client.api.query.scheduler.agenda(targetBlockNumber) + expect(scheduled.length).toBe(issuanceAdjustments.length) + + for (let index = 0; index < issuanceAdjustments.length; index++) { + const adjustmentAmount = issuanceAdjustments[index] + + expect(scheduled[index].isSome).toBeTruthy() + const task = scheduled[index].unwrap() + + // Check task ID + expect(task.maybeId.isSome).toBeTruthy() + const taskId = task.maybeId.unwrap().toU8a() + expect(taskId).toEqual(sha256AsU8a(`task_id_${adjustmentAmount}`)) + + // Check priority + expect(task.priority.toNumber()).toBe(0) + + // Check call matches the expected adjustIssuance transaction + expect(task.call.isInline).toBeTruthy() + expect(task.call.asInline.toHex()).toBe(adjustIssuanceTx(adjustmentAmount).method.toHex()) + + // Check origin + expect(task.origin.isSystem).toBeTruthy() + + // Verify lookup storage contains entry for this named task + const lookupResult = await client.api.query.scheduler.lookup(sha256AsU8a(`task_id_${adjustmentAmount}`)) + expect(lookupResult.isSome).toBeTruthy() + const [blockNumber, taskIndex] = lookupResult.unwrap() + expect(blockNumber.toNumber()).toBe(targetBlockNumber) + expect(taskIndex.toNumber()).toBe(index) + } + + // ------------------------------------------------------------------------------------------ + // Step 3: Cancel task with adjustment 5, verify it becomes `None` while tasks 3 and 7 remain + // ------------------------------------------------------------------------------------------ + + let cancelTaskId = sha256AsU8a(`task_id_5`) + let cancelTx = client.api.tx.scheduler.cancelNamed(cancelTaskId) + await scheduleInlineCallWithOrigin(client, cancelTx.method.toHex(), { system: 'Root' }, testConfig.blockProvider) + await client.dev.newBlock() + + // Verify the cancelled task is now None in the agenda + scheduled = await client.api.query.scheduler.agenda(targetBlockNumber) + expect(scheduled.length).toBe(3) // Length stays the same + // Recall that at the start of the test, the target block's agenda is cleared, so these accesses are semantically + // correct. + expect(scheduled[1].isNone).toBeTruthy() // Task at index 1 (adjustment 5) is now None + expect(scheduled[0].isSome).toBeTruthy() // Task at index 0 (adjustment 3) still present + expect(scheduled[2].isSome).toBeTruthy() // Task at index 2 (adjustment 7) still present + + // Verify the cancelled task's lookup entry was removed + let cancelledLookup = await client.api.query.scheduler.lookup(cancelTaskId) + expect(cancelledLookup.isNone).toBeTruthy() + + // ----------------------------------------------------------------------------------------- + // Step 4: Cancel task with adjustment 3, verify slots 0 and 1 are `None` but task 7 remains + // ----------------------------------------------------------------------------------------- + + cancelTaskId = sha256AsU8a(`task_id_3`) + cancelTx = client.api.tx.scheduler.cancelNamed(cancelTaskId) + await scheduleInlineCallWithOrigin(client, cancelTx.method.toHex(), { system: 'Root' }, testConfig.blockProvider) + await client.dev.newBlock() + + // Verify both cancelled tasks are None, but task 7 remains + scheduled = await client.api.query.scheduler.agenda(targetBlockNumber) + expect(scheduled.length).toBe(3) // Length stays the same + expect(scheduled[0].isNone).toBeTruthy() // Task at index 0 (adjustment 3) is now None + expect(scheduled[1].isNone).toBeTruthy() // Task at index 1 (adjustment 5) is still None + expect(scheduled[2].isSome).toBeTruthy() // Task at index 2 (adjustment 7) still present + + // Verify task 7 is correct + const task7 = scheduled[2].unwrap() + expect(task7.maybeId.isSome).toBeTruthy() + expect(task7.maybeId.unwrap().toU8a()).toEqual(sha256AsU8a(`task_id_7`)) + expect(task7.call.isInline).toBeTruthy() + expect(task7.call.asInline.toHex()).toBe(adjustIssuanceTx(7).method.toHex()) + + // Verify task 3's lookup entry was removed + cancelledLookup = await client.api.query.scheduler.lookup(sha256AsU8a(`task_id_3`)) + expect(cancelledLookup.isNone).toBeTruthy() + + // Verify task 7's lookup entry still exists + const lookup7 = await client.api.query.scheduler.lookup(sha256AsU8a(`task_id_7`)) + expect(lookup7.isSome).toBeTruthy() + const [blockNumber7, taskIndex7] = lookup7.unwrap() + expect(blockNumber7.toNumber()).toBe(targetBlockNumber) + expect(taskIndex7.toNumber()).toBe(2) + + // ---------------------------------------------------------------------------------------------- + // Step 5: Cancel the last task with adjustment 7, verify the agenda entry was completely removed + // ---------------------------------------------------------------------------------------------- + + cancelTaskId = sha256AsU8a(`task_id_7`) + cancelTx = client.api.tx.scheduler.cancelNamed(cancelTaskId) + await scheduleInlineCallWithOrigin(client, cancelTx.method.toHex(), { system: 'Root' }, testConfig.blockProvider) + await client.dev.newBlock() + + // Verify the agenda is now empty (all tasks are None, so the vec is cleared) + scheduled = await client.api.query.scheduler.agenda(targetBlockNumber) + expect(scheduled.length).toBe(0) + + // Verify task 7's lookup entry was removed + cancelledLookup = await client.api.query.scheduler.lookup(sha256AsU8a(`task_id_7`)) + expect(cancelledLookup.isNone).toBeTruthy() } /** @@ -585,11 +1085,14 @@ export async function scheduleTaskAfterDelay< }) .exhaustive() - let scheduled = await client.api.query.scheduler.agenda(targetBlock!) - expect(scheduled.length).toBe(1) + // Insert irrelevant task to test robustness + await addDummyTasksToAgenda(client, targetBlock!) - expect(scheduled[0].isSome).toBeTruthy() - await check(scheduled[0].unwrap()).toMatchObject({ + let { task, scheduled } = await findUnnamedScheduledTask(client, targetBlock!, adjustIssuanceTx.method.toHex(), 0) + expect(scheduled.length).toBeGreaterThan(1) + expect(task).toBeDefined() + + await check(task).toMatchObject({ maybeId: null, priority: 0, call: { inline: adjustIssuanceTx.method.toHex() }, @@ -674,11 +1177,17 @@ export async function scheduleNamedTaskAfterDelay< }) .exhaustive() - let scheduled = await client.api.query.scheduler.agenda(targetBlock!) + const { task, scheduled } = await findNamedScheduledTask( + client, + targetBlock!, + adjustIssuanceTx.method.toHex(), + 0, + taskId, + ) expect(scheduled.length).toBe(1) - scheduled = await client.api.query.scheduler.agenda(targetBlock!) - expect(scheduled[0].isSome).toBeTruthy() - await check(scheduled[0].unwrap()).toMatchObject({ + expect(task).toBeDefined() + + await check(task).toMatchObject({ maybeId: `0x${Buffer.from(taskId).toString('hex')}`, priority: 0, call: { inline: adjustIssuanceTx.method.toHex() }, @@ -701,8 +1210,8 @@ export async function scheduleNamedTaskAfterDelay< .toMatchSnapshot('events for scheduled task execution') // Check that the call was removed from the agenda - scheduled = await client.api.query.scheduler.agenda(currBlockNumber + delay + 1) - expect(scheduled.length).toBe(0) + const scheduledAfterExecution = await client.api.query.scheduler.agenda(currBlockNumber + delay + 1) + expect(scheduledAfterExecution.length).toBe(0) // Verify total issuance was increased const newTotalIssuance = await client.api.query.balances.totalIssuance() @@ -752,10 +1261,14 @@ export async function scheduledOverweightCallFails< await client.dev.newBlock() - let scheduled = await client.api.query.scheduler.agenda(targetBlockNumber!) - expect(scheduled.length).toBe(1) + const { task: scheduledTask } = await findUnnamedScheduledTask( + client, + targetBlockNumber!, + withWeightTx.method.toHex(), + 0, + ) + expect(scheduledTask).toBeDefined() - const scheduledTask: PalletSchedulerScheduled = scheduled[0].unwrap() const task = { maybeId: null, priority: 0, @@ -809,9 +1322,9 @@ export async function scheduledOverweightCallFails< } // Check that the call remains in the agenda for the original block it was scheduled in - scheduled = await client.api.query.scheduler.agenda(targetBlockNumber!) - expect(scheduled.length).toBe(1) - await check(scheduled[0].unwrap()).toMatchObject(task) + const afterExecution = await findUnnamedScheduledTask(client, targetBlockNumber!, withWeightTx.method.toHex(), 0) + expect(afterExecution.task).toBeDefined() + await check(afterExecution.task).toMatchObject(task) // If the overweight event is not found, even though the task was overweight, it's likely that other scheduled events // interfered with the agenda's scheduled tasks - even though the test clears the agenda for this block. @@ -880,10 +1393,17 @@ async function scheduleLookupCall< const targetBlock = await nextSchedulableBlockNum(client.api, testConfig.blockProvider) let agenda = await client.api.query.scheduler.agenda(targetBlock) - expect(agenda.length).toBe(1) - assert(agenda[0].isSome) - const scheduledTask = agenda[0].unwrap() - await check(scheduledTask).toMatchObject({ + // Find the unnamed task with priority 0 and lookup call + const scheduledTask = agenda.find((item) => { + if (!item.isSome) return false + const unwrapped = item.unwrap() + return unwrapped.maybeId.isNone && unwrapped.priority.toNumber() === 0 && unwrapped.call.isLookup + }) + + expect(scheduledTask).toBeDefined() + assert(scheduledTask!.isSome) + + await check(scheduledTask!.unwrap()).toMatchObject({ maybeId: null, priority: 0, call: { lookup: { hash: preimageHash.toHex(), len: encodedProposal.encodedLength } }, @@ -976,9 +1496,18 @@ export async function schedulePreimagedCall< }) let scheduled = await client.api.query.scheduler.agenda(targetBlockNumber!) - expect(scheduled.length).toBe(1) - expect(scheduled[0].isSome).toBeTruthy() - expect(scheduled[0].toJSON()).toMatchObject({ + + // Find the unnamed task with priority 0 and lookup call + const scheduledTask = scheduled.find((item) => { + if (!item.isSome) return false + const unwrapped = item.unwrap() + return unwrapped.maybeId.isNone && unwrapped.priority.toNumber() === 0 && unwrapped.call.isLookup + }) + + expect(scheduledTask).toBeDefined() + assert(scheduledTask!.isSome) + + expect(scheduledTask!.toJSON()).toMatchObject({ maybeId: null, priority: 0, call: { @@ -1005,11 +1534,21 @@ export async function schedulePreimagedCall< // Move to execution block await client.dev.newBlock() + await addDummyTasksToAgenda(client, targetBlockNumber!) + scheduled = await client.api.query.scheduler.agenda(targetBlockNumber!) - expect(scheduled.length).toBe(1) - expect(scheduled[0].isSome).toBeTruthy() - expect(scheduled[0].toJSON()).toMatchObject({ + // Find the unnamed task with priority 0 (should still be in agenda after failed execution) + const taskAfterFailedExecution = scheduled.find((item) => { + if (!item.isSome) return false + const unwrapped = item.unwrap() + return unwrapped.maybeId.isNone && unwrapped.priority.toNumber() === 0 + }) + + expect(taskAfterFailedExecution).toBeDefined() + assert(taskAfterFailedExecution!.isSome) + + expect(taskAfterFailedExecution!.toJSON()).toMatchObject({ maybeId: null, priority: 0, }) @@ -1110,11 +1649,15 @@ async function testPeriodicTask< targetBlock = currBlockNumber }) .exhaustive() - let agenda = await client.api.query.scheduler.agenda(targetBlock!) - expect(agenda.length).toBe(1) - expect(agenda[0].isSome).toBeTruthy() - await check(agenda[0].unwrap()).toMatchObject({ + // Find the periodic task with priority 0 and the adjustIssuance call + const periodicTaskResult = taskId + ? await findNamedScheduledTask(client, targetBlock!, adjustIssuanceTx.method.toHex(), 0, taskId) + : await findUnnamedScheduledTask(client, targetBlock!, adjustIssuanceTx.method.toHex(), 0) + + expect(periodicTaskResult.task).toBeDefined() + + await check(periodicTaskResult.task).toMatchObject({ maybeId: taskId ? `0x${Buffer.from(taskId).toString('hex')}` : null, priority: 0, call: { inline: adjustIssuanceTx.method.toHex() }, @@ -1156,10 +1699,13 @@ async function testPeriodicTask< } else { targetBlock = currBlockNumber + period - offset } - agenda = await client.api.query.scheduler.agenda(targetBlock!) - expect(agenda.length).toBe(1) - expect(agenda[0].isSome).toBeTruthy() + // Find the next scheduled periodic task + const nextPeriodicTask = taskId + ? await findNamedScheduledTask(client, targetBlock!, adjustIssuanceTx.method.toHex(), 0, taskId) + : await findUnnamedScheduledTask(client, targetBlock!, adjustIssuanceTx.method.toHex(), 0) + + expect(nextPeriodicTask.task).toBeDefined() let maybePeriodic: [number, number] | null // Recall that the first execution had `REPETITIONS - 1` in this field, when `i` was 1 i.e. first iteration. @@ -1171,7 +1717,7 @@ async function testPeriodicTask< maybePeriodic = [period, REPETITIONS - (i + 1)] } - await check(agenda[0].unwrap()).toMatchObject({ + await check(nextPeriodicTask.task).toMatchObject({ maybeId: taskId ? `0x${Buffer.from(taskId).toString('hex')}` : null, priority: 0, call: { inline: adjustIssuanceTx.method.toHex() }, @@ -1197,8 +1743,12 @@ async function testPeriodicTask< targetBlock = currBlockNumber }) .exhaustive() - agenda = await client.api.query.scheduler.agenda(targetBlock!) - expect(agenda.length).toBe(0) + // Verify the periodic task is no longer in the agenda after all repetitions complete + const completedTaskCheck = taskId + ? await findNamedScheduledTask(client, targetBlock!, adjustIssuanceTx.method.toHex(), 0, taskId) + : await findUnnamedScheduledTask(client, targetBlock!, adjustIssuanceTx.method.toHex(), 0) + + expect(completedTaskCheck.task).toBeUndefined() // Check final issuance - must have been increased by `REPETITIONS * increment == REPETITIONS`. const finalTotalIssuance = await client.api.query.balances.totalIssuance() @@ -1295,6 +1845,8 @@ export async function schedulePriorityWeightedTasks< >(chain: Chain, testConfig: TestConfig) { const [client] = await setupNetworks(chain) + // 1. Create two transactions with weights that exceed half the maximum weight per block + const adjustIssuanceHighTx = client.api.tx.balances.forceAdjustTotalIssuance('Increase', 2) const adjustIssuanceLowTx = client.api.tx.balances.forceAdjustTotalIssuance('Increase', 1) @@ -1321,7 +1873,8 @@ export async function schedulePriorityWeightedTasks< }) .exhaustive() - // Schedule both tasks for the same block with different priorities + // 2. Schedule both tasks for the same block with different priorities + const scheduleHighPriorityTx = client.api.tx.scheduler.schedule( priorityTargetBlock!, null, @@ -1360,17 +1913,22 @@ export async function schedulePriorityWeightedTasks< const initialTotalIssuance = await client.api.query.balances.totalIssuance() + // 3. Move to block of scheduled execution of both tasks, and query/verify state + // Move to block just before scheduled execution await client.dev.newBlock() currBlockNumber += offset - const manuallyScheduledBlock = await nextSchedulableBlockNum(client.api, testConfig.blockProvider) - - // Verify both tasks are in the agenda - let scheduled = await client.api.query.scheduler.agenda(manuallyScheduledBlock) - expect(scheduled.length).toBe(2) - expect(scheduled[0].isSome).toBeTruthy() - expect(scheduled[1].isSome).toBeTruthy() + // Verify both priority-weighted tasks are in the agenda of the block in which they are originally scheduled + const lowPriorityTask = await findUnnamedScheduledTask(client, priorityTargetBlock!, lowPriorityTx.method.toHex(), 1) + const highPriorityTask = await findUnnamedScheduledTask( + client, + priorityTargetBlock!, + highPriorityTx.method.toHex(), + 0, + ) + expect(lowPriorityTask.task).toBeDefined() + expect(highPriorityTask.task).toBeDefined() // Execute first block - should only complete high priority task await client.dev.newBlock() @@ -1399,25 +1957,26 @@ export async function schedulePriorityWeightedTasks< }) .exhaustive() - // Check the agenda for the most recently built block + // Check the agenda for the most recently built block - only the low priority task should still be scheduled. + let mostRecentBlock: number match(testConfig.blockProvider) .with('Local', async () => { - scheduled = await client.api.query.scheduler.agenda(currBlockNumber) + mostRecentBlock = currBlockNumber }) .with('NonLocal', async () => { - scheduled = await client.api.query.scheduler.agenda(currBlockNumber - offset) + mostRecentBlock = currBlockNumber - offset }) .exhaustive() - expect(scheduled.length).toBe(2) - // Expect that both tasks are `Some` - expect(scheduled.every((task) => task.isSome)).toBeTruthy() - // Disambiguate between the two tasks - const lowPriorityTask = scheduled.find((task) => task.unwrap().priority.toNumber() === 1) - const highPriorityTask = scheduled.find((task) => task.unwrap().priority.toNumber() === 0) - - expect(lowPriorityTask).toBeDefined() - await check(lowPriorityTask).toMatchObject({ + // Find the low priority task (priority 1) + const lowPriorityTaskResult = await findUnnamedScheduledTask( + client, + mostRecentBlock!, + lowPriorityTx.method.toHex(), + 1, + ) + expect(lowPriorityTaskResult.task).toBeDefined() + await check(lowPriorityTaskResult.task).toMatchObject({ maybeId: null, priority: 1, call: { inline: lowPriorityTx.method.toHex() }, @@ -1425,14 +1984,14 @@ export async function schedulePriorityWeightedTasks< origin: { system: { root: null } }, }) - expect(highPriorityTask).toBeDefined() - await check(highPriorityTask).toMatchObject({ - maybeId: null, - priority: 0, - call: { inline: highPriorityTx.method.toHex() }, - maybePeriodic: null, - origin: { system: { root: null } }, - }) + // The high priority task should not be in the agenda of the most recently built block + const highPriorityTaskResult = await findUnnamedScheduledTask( + client, + mostRecentBlock!, + highPriorityTx.method.toHex(), + 0, + ) + expect(highPriorityTaskResult.task).toBeUndefined() // Move to the next block, where the lower priority task will execute await client.dev.newBlock() @@ -1456,32 +2015,47 @@ export async function schedulePriorityWeightedTasks< }) .exhaustive() - // Check that the agenda for the block in which the 2 priority tasks were scheduled is empty - scheduled = await client.api.query.scheduler.agenda(priorityTargetBlock!) - expect(scheduled.length).toBe(0) + // Verify both priority-weighted tasks have been executed and removed from the agenda - // The agenda on the block just before the most recently built block should be emoty + // Check the originally scheduled block + const originalLowPriority = await findUnnamedScheduledTask( + client, + priorityTargetBlock!, + lowPriorityTx.method.toHex(), + 1, + ) + const originalHighPriority = await findUnnamedScheduledTask( + client, + priorityTargetBlock!, + highPriorityTx.method.toHex(), + 0, + ) + expect(originalLowPriority.task).toBeUndefined() + expect(originalHighPriority.task).toBeUndefined() + + // Check the previous block (where high priority task was rescheduled after first incomplete execution) + let previousBlock: number match(testConfig.blockProvider) - .with('Local', async () => { - scheduled = await client.api.query.scheduler.agenda(currBlockNumber - 1) + .with('Local', () => { + previousBlock = currBlockNumber - 1 }) - .with('NonLocal', async () => { - scheduled = await client.api.query.scheduler.agenda(currBlockNumber - 2 * offset) + .with('NonLocal', () => { + previousBlock = currBlockNumber - 2 * offset }) .exhaustive() - expect(scheduled.length).toBe(0) + const previousLowPriority = await findUnnamedScheduledTask(client, previousBlock!, lowPriorityTx.method.toHex(), 1) + const previousHighPriority = await findUnnamedScheduledTask(client, previousBlock!, highPriorityTx.method.toHex(), 0) + expect(previousLowPriority.task).toBeUndefined() + expect(previousHighPriority.task).toBeUndefined() - // Check the agenda on the most recently built block - should be empty - match(testConfig.blockProvider) - .with('Local', async () => { - scheduled = await client.api.query.scheduler.agenda(currBlockNumber) - }) - .with('NonLocal', async () => { - scheduled = await client.api.query.scheduler.agenda(currBlockNumber - offset) - }) - .exhaustive() - expect(scheduled.length).toBe(0) + // Check the current block's agenda. + const currentBlock = await getBlockNumber(client.api, testConfig.blockProvider) + + const currentLowPriority = await findUnnamedScheduledTask(client, currentBlock, lowPriorityTx.method.toHex(), 1) + const currentHighPriority = await findUnnamedScheduledTask(client, currentBlock, highPriorityTx.method.toHex(), 0) + expect(currentLowPriority.task).toBeUndefined() + expect(currentHighPriority.task).toBeUndefined() } /** @@ -1499,7 +2073,8 @@ export async function scheduleWithRetryConfig< TInitStorages extends Record> | undefined, >(chain: Chain, testConfig: TestConfig) { const [client] = await setupNetworks(chain) - // Create a task that will fail - remarkWithEvent requires signed origin + // Create a task that will fail - remarkWithEvent requires signed origin, and it will be attempted with a `Root` + // origin const failingTx = client.api.tx.system.remarkWithEvent('will_fail') const initialBlockNumber = await getBlockNumber(client.api, testConfig.blockProvider) @@ -1546,18 +2121,22 @@ export async function scheduleWithRetryConfig< await client.dev.newBlock() // Check initial schedule - let scheduled = await client.api.query.scheduler.agenda(targetBlock!) - expect(scheduled.length).toBe(1) - expect(scheduled[0].isSome).toBeTruthy() - await check(scheduled[0].unwrap()).toMatchObject(baseTask) - - // Set retry configuration - const setRetryTx = client.api.tx.scheduler.setRetry([targetBlock!, 0], retryConfig.totalRetries, retryConfig.period) + const initialTask = await findUnnamedScheduledTask(client, targetBlock!, failingTx.method.toHex(), 1) + expect(initialTask.task).toBeDefined() + expect(initialTask.taskIndex).toBeGreaterThanOrEqual(0) + await check(initialTask.task).toMatchObject(baseTask) + + // Set retry configuration using the actual task index + const setRetryTx = client.api.tx.scheduler.setRetry( + [targetBlock!, initialTask.taskIndex], + retryConfig.totalRetries, + retryConfig.period, + ) await scheduleInlineCallWithOrigin(client, setRetryTx.method.toHex(), { system: 'Root' }, testConfig.blockProvider) await client.dev.newBlock() - let retryOpt = await client.api.query.scheduler.retries([targetBlock!, 0]) + let retryOpt = await client.api.query.scheduler.retries([targetBlock!, initialTask.taskIndex]) assert(retryOpt.isSome) let retry = retryOpt.unwrap() await check(retry).toMatchObject(retryConfig) @@ -1573,18 +2152,19 @@ export async function scheduleWithRetryConfig< .toMatchSnapshot('events for failed anonymous task execution') const rescheduledBlock = targetBlock! + period - // Verify task failed and was rescheduled - scheduled = await client.api.query.scheduler.agenda(targetBlock!) - expect(scheduled.length).toBe(0) - scheduled = await client.api.query.scheduler.agenda(rescheduledBlock!) - expect(scheduled.length).toBe(1) - expect(scheduled[0].isSome).toBeTruthy() - await check(scheduled[0].unwrap()).toMatchObject({ + // Verify task failed and was rescheduled from targetBlock to rescheduledBlock + const taskAtOriginalBlock = await findUnnamedScheduledTask(client, targetBlock!, failingTx.method.toHex(), 1) + expect(taskAtOriginalBlock.task).toBeUndefined() + + const rescheduledTask = await findUnnamedScheduledTask(client, rescheduledBlock!, failingTx.method.toHex(), 1) + expect(rescheduledTask.task).toBeDefined() + expect(rescheduledTask.taskIndex).toBeGreaterThanOrEqual(0) + await check(rescheduledTask.task).toMatchObject({ ...baseTask, maybeId: null, }) - retryOpt = await client.api.query.scheduler.retries([rescheduledBlock!, 0]) + retryOpt = await client.api.query.scheduler.retries([rescheduledBlock!, rescheduledTask.taskIndex]) assert(retryOpt.isSome) retry = retryOpt.unwrap() await check(retry).toMatchObject({ @@ -1592,7 +2172,7 @@ export async function scheduleWithRetryConfig< remaining: retryConfig.remaining - 1, }) - const cancelRetryTx = client.api.tx.scheduler.cancelRetry([rescheduledBlock!, 0]) + const cancelRetryTx = client.api.tx.scheduler.cancelRetry([rescheduledBlock!, rescheduledTask.taskIndex]) await scheduleInlineCallWithOrigin(client, cancelRetryTx.method.toHex(), { system: 'Root' }, testConfig.blockProvider) await client.dev.newBlock() @@ -1607,15 +2187,15 @@ export async function scheduleWithRetryConfig< await client.dev.newBlock() // Verify task is still scheduled but without retry config - scheduled = await client.api.query.scheduler.agenda(rescheduledBlock!) - expect(scheduled.length).toBe(1) - expect(scheduled[0].isSome).toBeTruthy() - await check(scheduled[0].unwrap()).toMatchObject({ + const taskAfterCancelRetry = await findUnnamedScheduledTask(client, rescheduledBlock!, failingTx.method.toHex(), 1) + expect(taskAfterCancelRetry.task).toBeDefined() + expect(taskAfterCancelRetry.taskIndex).toBeGreaterThanOrEqual(0) + await check(taskAfterCancelRetry.task).toMatchObject({ ...baseTask, maybeId: null, }) - retryOpt = await client.api.query.scheduler.retries([rescheduledBlock!, 0]) + retryOpt = await client.api.query.scheduler.retries([rescheduledBlock!, taskAfterCancelRetry.taskIndex]) expect(retryOpt.isNone).toBeTruthy() } @@ -1685,10 +2265,10 @@ export async function scheduleNamedWithRetryConfig< await client.dev.newBlock() // Check initial schedule - let scheduled = await client.api.query.scheduler.agenda(targetBlock!) - expect(scheduled.length).toBe(1) - expect(scheduled[0].isSome).toBeTruthy() - await check(scheduled[0].unwrap()).toMatchObject(baseTask) + const initialTask = await findNamedScheduledTask(client, targetBlock!, failingTx.method.toHex(), 1, taskId) + expect(initialTask.task).toBeDefined() + expect(initialTask.taskIndex).toBeGreaterThanOrEqual(0) + await check(initialTask.task).toMatchObject(baseTask) // Set retry configuration const setRetryTx = client.api.tx.scheduler.setRetryNamed(taskId, retryConfig.totalRetries, retryConfig.period) @@ -1696,7 +2276,7 @@ export async function scheduleNamedWithRetryConfig< await client.dev.newBlock() - let retryOpt = await client.api.query.scheduler.retries([targetBlock!, 0]) + let retryOpt = await client.api.query.scheduler.retries([targetBlock!, initialTask.taskIndex]) assert(retryOpt.isSome) let retry = retryOpt.unwrap() await check(retry).toMatchObject(retryConfig) @@ -1712,19 +2292,20 @@ export async function scheduleNamedWithRetryConfig< .toMatchSnapshot('events for failed named task execution') const rescheduledBlock = targetBlock! + period - // Verify task failed and was rescheduled - scheduled = await client.api.query.scheduler.agenda(targetBlock!) - expect(scheduled.length).toBe(0) - scheduled = await client.api.query.scheduler.agenda(rescheduledBlock!) - expect(scheduled.length).toBe(1) - expect(scheduled[0].isSome).toBeTruthy() - // Retries of named tasks have no id - await check(scheduled[0].unwrap()).toMatchObject({ + // Verify task failed and was rescheduled from targetBlock to rescheduledBlock + const taskAtOriginalBlock = await findNamedScheduledTask(client, targetBlock!, failingTx.method.toHex(), 1, taskId) + expect(taskAtOriginalBlock.task).toBeUndefined() + + // Retries of named tasks have no id, so use findUnnamedScheduledTask + const rescheduledTask = await findUnnamedScheduledTask(client, rescheduledBlock!, failingTx.method.toHex(), 1) + expect(rescheduledTask.task).toBeDefined() + expect(rescheduledTask.taskIndex).toBeGreaterThanOrEqual(0) + await check(rescheduledTask.task).toMatchObject({ ...baseTask, maybeId: null, }) - retryOpt = await client.api.query.scheduler.retries([rescheduledBlock!, 0]) + retryOpt = await client.api.query.scheduler.retries([rescheduledBlock!, rescheduledTask.taskIndex]) assert(retryOpt.isSome) retry = retryOpt.unwrap() await check(retry).toMatchObject({ @@ -1746,18 +2327,23 @@ export async function scheduleNamedWithRetryConfig< await client.dev.newBlock() - // Verify task is still scheduled... - scheduled = await client.api.query.scheduler.agenda(rescheduledBlock!) - expect(scheduled.length).toBe(1) - expect(scheduled[0].isSome).toBeTruthy() + // Verify task is still scheduled (cancelRetryNamed has no effect on unnamed retries) + const taskAfterCancelRetryNamed = await findUnnamedScheduledTask( + client, + rescheduledBlock!, + failingTx.method.toHex(), + 1, + ) + expect(taskAfterCancelRetryNamed.task).toBeDefined() + expect(taskAfterCancelRetryNamed.taskIndex).toBeGreaterThanOrEqual(0) // Once again - retries of named tasks have no id - await check(scheduled[0].unwrap()).toMatchObject({ + await check(taskAfterCancelRetryNamed.task).toMatchObject({ ...baseTask, maybeId: null, }) - // ... *with* a retry config - retryOpt = await client.api.query.scheduler.retries([rescheduledBlock!, 0]) + // ... *with* a retry config (cancelRetryNamed has no effect on unnamed retries) + retryOpt = await client.api.query.scheduler.retries([rescheduledBlock!, taskAfterCancelRetryNamed.taskIndex]) // A named task's retry will be unnamed, so its retry configuration must be cancelled // via `cancelRetry` - `cancelRetryNamed` has no effect. assert(retryOpt.isSome) @@ -1772,21 +2358,19 @@ export async function scheduleNamedWithRetryConfig< const finalRescheduledBlock = rescheduledBlock! + period // In the meantime, the task has been retried a second time, and has scheduled for a third - scheduled = await client.api.query.scheduler.agenda(finalRescheduledBlock!) - expect(scheduled.length).toBeGreaterThan(0) - // Find the task by its call - const taskIndex = scheduled.findIndex((t) => { - if (!t.isSome) return false - const unwrapped = t.unwrap() - return unwrapped.call.isInline && unwrapped.call.asInline.toHex() === failingTx.method.toHex() - }) - expect(taskIndex).toBeGreaterThanOrEqual(0) - expect(scheduled[taskIndex].isSome).toBeTruthy() - await check(scheduled[taskIndex].unwrap()).toMatchObject({ + const finalRescheduledTask = await findUnnamedScheduledTask( + client, + finalRescheduledBlock!, + failingTx.method.toHex(), + 1, + ) + expect(finalRescheduledTask.task).toBeDefined() + expect(finalRescheduledTask.taskIndex).toBeGreaterThanOrEqual(0) + await check(finalRescheduledTask.task).toMatchObject({ ...baseTask, maybeId: null, }) - retryOpt = await client.api.query.scheduler.retries([finalRescheduledBlock!, taskIndex]) + retryOpt = await client.api.query.scheduler.retries([finalRescheduledBlock!, finalRescheduledTask.taskIndex]) assert(retryOpt.isSome) retry = retryOpt.unwrap() await check(retry).toMatchObject({ @@ -1795,31 +2379,373 @@ export async function scheduleNamedWithRetryConfig< }) // Cancel the retry configuration with `cancelRetry` - cancelRetryTx = client.api.tx.scheduler.cancelRetry([finalRescheduledBlock!, taskIndex]) + cancelRetryTx = client.api.tx.scheduler.cancelRetry([finalRescheduledBlock!, finalRescheduledTask.taskIndex]) await scheduleInlineCallWithOrigin(client, cancelRetryTx.method.toHex(), { system: 'Root' }, testConfig.blockProvider) await client.dev.newBlock() - retryOpt = await client.api.query.scheduler.retries([finalRescheduledBlock!, taskIndex]) + retryOpt = await client.api.query.scheduler.retries([finalRescheduledBlock!, finalRescheduledTask.taskIndex]) expect(retryOpt.isNone).toBeTruthy() // Check that the retry config cancellation does not affect the scheduled third try - scheduled = await client.api.query.scheduler.agenda(finalRescheduledBlock!) - expect(scheduled.length).toBeGreaterThan(0) - // Find the task by its call - const finalTaskIndex = scheduled.findIndex((t) => { - if (!t.isSome) return false - const unwrapped = t.unwrap() - return unwrapped.call.isInline && unwrapped.call.asInline.toHex() === failingTx.method.toHex() - }) - expect(finalTaskIndex).toBeGreaterThanOrEqual(0) - expect(scheduled[finalTaskIndex].isSome).toBeTruthy() - await check(scheduled[finalTaskIndex].unwrap()).toMatchObject({ + const taskAfterCancelRetry = await findUnnamedScheduledTask( + client, + finalRescheduledBlock!, + failingTx.method.toHex(), + 1, + ) + expect(taskAfterCancelRetry.task).toBeDefined() + expect(taskAfterCancelRetry.taskIndex).toBeGreaterThanOrEqual(0) + await check(taskAfterCancelRetry.task).toMatchObject({ ...baseTask, maybeId: null, }) } +/** + * Test scheduler agenda capacity limit. + * + * This test verifies that the scheduler enforces its maximum tasks per block limit. It checks that: + * 1. Pick a block far in the future + * 2. Query the network's `MaxScheduledPerBlock` constant + * 3. Fill the target block's agenda to capacity using `setStorage` + * 4. Attempt to schedule one additional task to the full block + */ +export async function scheduleToFullAgenda< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, testConfig: TestConfig) { + const [client] = await setupNetworks(chain) + + // --------------------------------- + // 1. Pick a block far in the future + // --------------------------------- + + const initialBlockNumber = await getBlockNumber(client.api, testConfig.blockProvider) + const offset = blockProviderOffset(testConfig) + const targetBlockNumber = initialBlockNumber + 1000 * offset + + // ---------------------------------------------------------- + // 2. Query the network's maximum schedulable tasks per block + // ---------------------------------------------------------- + + const maxScheduledPerBlock = client.api.consts.scheduler.maxScheduledPerBlock.toNumber() + + // ----------------------------------------------------- + // 3. Fill the target block to capacity using setStorage + // ----------------------------------------------------- + + // Create an array of remark transactions to fill the agenda + const remarkTx = client.api.tx.system.remark('filler') + const agendaItems = Array.from({ length: maxScheduledPerBlock }, () => ({ + call: { Inline: remarkTx.method.toHex() }, + origin: { system: 'Root' }, + })) + + await client.dev.setStorage({ + Scheduler: { + agenda: [[[targetBlockNumber], agendaItems]], + }, + }) + + // Verify the agenda is full + const scheduledAfterFilling = await client.api.query.scheduler.agenda(targetBlockNumber) + expect(scheduledAfterFilling.length).toBe(maxScheduledPerBlock) + + // ------------------------------------------------------ + // 4. Attempt to schedule one more task to the full block + // ------------------------------------------------------ + + const additionalRemarkTx = client.api.tx.system.remark('overflow') + const scheduleAdditionalTx = client.api.tx.scheduler.schedule(targetBlockNumber, null, 0, additionalRemarkTx) + + await scheduleInlineCallWithOrigin( + client, + scheduleAdditionalTx.method.toHex(), + { system: 'Root' }, + testConfig.blockProvider, + ) + + await client.dev.newBlock() + + // Verify that the additional task was not scheduled + const scheduledAfterAttempt = await client.api.query.scheduler.agenda(targetBlockNumber) + expect(scheduledAfterAttempt.length).toBe(maxScheduledPerBlock) + + const events = await client.api.query.system.events() + const [ev] = events.filter((record) => { + const { event } = record + return event.section === 'scheduler' && event.method === 'Dispatched' + }) + + assert(client.api.events.scheduler.Dispatched.is(ev.event)) + const dispatchData = ev.event.data + assert(dispatchData.result.isErr) + const dispatchError = dispatchData.result.asErr + expect(dispatchError.isExhausted).toBe(true) +} + +/** + * Test retry rescheduling of a named task to a full agenda block. + * + * This test verifies the behavior when a named task with retry configuration attempts to reschedule + * to a block whose agenda is already at capacity. It checks that: + * 1. Pick a block far in the future and fill its agenda to capacity + * 2. Schedule a named task with retry config that will fail and reschedule to the full block + * 3. Verify the retry fails and the task is removed (not rescheduled to the full block) + */ +export async function retryReschedulingNamedTaskToFullAgenda< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, testConfig: TestConfig) { + const [client] = await setupNetworks(chain) + + const initialBlockNumber = await getBlockNumber(client.api, testConfig.blockProvider) + const offset = blockProviderOffset(testConfig) + + // --------------------------------------------------- + // 1. Pick a block far in the future and fill agenda + // --------------------------------------------------- + + const fullBlockNumber = initialBlockNumber + 1000 * offset + + // Query the network's maximum schedulable tasks per block + const maxScheduledPerBlock = client.api.consts.scheduler.maxScheduledPerBlock.toNumber() + + // Create an array of remark transactions to fill the agenda + const remarkTx = client.api.tx.system.remark('filler') + const agendaItems = Array.from({ length: maxScheduledPerBlock }, () => ({ + call: { Inline: remarkTx.method.toHex() }, + origin: { system: 'Root' }, + })) + + await client.dev.setStorage({ + Scheduler: { + agenda: [[[fullBlockNumber], agendaItems]], + }, + }) + + // Verify the agenda is full + const scheduledAfterFilling = await client.api.query.scheduler.agenda(fullBlockNumber) + expect(scheduledAfterFilling.length).toBe(maxScheduledPerBlock) + + // ------------------------------------------------------------------------------------ + // 2. Schedule a task with retry config that will fail and reschedule to the full block + // ------------------------------------------------------------------------------------ + + // Create a task that will fail - remarkWithEvent requires signed origin + const failingTx = client.api.tx.system.remarkWithEvent('will_fail') + const taskId = sha256AsU8a('retry_task') + + // Calculate the block where the task will first execute (give us time to set retry config) + let firstExecutionBlock: number + match(testConfig.blockProvider) + .with('Local', () => { + firstExecutionBlock = initialBlockNumber + 3 * offset + }) + .with('NonLocal', () => { + firstExecutionBlock = initialBlockNumber + 2 * offset + }) + .exhaustive() + + // Calculate retry period to reschedule to the full block + const retryPeriod = fullBlockNumber - firstExecutionBlock! + + const retryConfig = { + totalRetries: 3, + remaining: 3, + period: retryPeriod, + } + + const scheduleTx = client.api.tx.scheduler.scheduleNamed(taskId, firstExecutionBlock!, null, 1, failingTx) + + await scheduleInlineCallWithOrigin(client, scheduleTx.method.toHex(), { system: 'Root' }, testConfig.blockProvider) + + await client.dev.newBlock() + + // Find the named task and set retry configuration + const initialTask = await findNamedScheduledTask(client, firstExecutionBlock!, failingTx.method.toHex(), 1, taskId) + expect(initialTask.task).toBeDefined() + expect(initialTask.taskIndex).toBeGreaterThanOrEqual(0) + + const setRetryTx = client.api.tx.scheduler.setRetryNamed(taskId, retryConfig.totalRetries, retryConfig.period) + await scheduleInlineCallWithOrigin(client, setRetryTx.method.toHex(), { system: 'Root' }, testConfig.blockProvider) + + await client.dev.newBlock() + + // Move to execution block + await client.dev.newBlock() + + // ------------------------------------------------------------------------------------- + // 3. Verify the retry fails and the task is removed (not rescheduled to the full block) + // ------------------------------------------------------------------------------------- + + const events = await client.api.query.system.events() + + // Should have a `RetryFailed` event + const retryFailedEvents = events.filter((record) => { + const { event } = record + return event.section === 'scheduler' && event.method === 'RetryFailed' + }) + expect(retryFailedEvents.length).toBe(1) + + const retryFailedEvent = retryFailedEvents[0] + assert(client.api.events.scheduler.RetryFailed.is(retryFailedEvent.event)) + + // Verify task is removed from lookup storage + const lookupAfterFailedRetry = await client.api.query.scheduler.lookup(taskId) + expect(lookupAfterFailedRetry.isNone).toBeTruthy() + + // Verify task is removed from the agenda in the block it was originally scheduled to execute in + const taskAtOriginalBlock = await findNamedScheduledTask( + client, + firstExecutionBlock!, + failingTx.method.toHex(), + 1, + taskId, + ) + expect(taskAtOriginalBlock.task).toBeUndefined() + + // Verify it is not rescheduled (check the full block agenda - should still be exactly `maxScheduledPerBlock` items) + const fullBlockAfterRetry = await client.api.query.scheduler.agenda(fullBlockNumber) + expect(fullBlockAfterRetry.length).toBe(maxScheduledPerBlock) + + // Verify the filled block remains full (no additional tasks) + const remarksInFullBlock = fullBlockAfterRetry.filter((item) => { + if (!item.isSome) return false + const unwrapped = item.unwrap() + return unwrapped.call.isInline && unwrapped.call.asInline.toHex() === remarkTx.method.toHex() + }) + expect(remarksInFullBlock.length).toBe(maxScheduledPerBlock) +} + +/** + * Test retry rescheduling of an anonymous task to a full agenda block. + * + * This test verifies the behavior when an anonymous task with retry configuration attempts to reschedule + * to a block whose agenda is already at capacity. It checks that: + * 1. Pick a block far in the future and fill its agenda to capacity + * 2. Schedule an anonymous task with retry config that will fail and reschedule to the full block + * 3. Verify the retry fails, the task is removed from agenda, and not rescheduled to the full block + */ +export async function retryReschedulingAnonymousTaskToFullAgenda< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>(chain: Chain, testConfig: TestConfig) { + const [client] = await setupNetworks(chain) + + const initialBlockNumber = await getBlockNumber(client.api, testConfig.blockProvider) + const offset = blockProviderOffset(testConfig) + + // --------------------------------------------------- + // 1. Pick a block far in the future and fill agenda + // --------------------------------------------------- + + const fullBlockNumber = initialBlockNumber + 1000 * offset + + // Query the network's maximum schedulable tasks per block + const maxScheduledPerBlock = client.api.consts.scheduler.maxScheduledPerBlock.toNumber() + + // Create an array of remark transactions to fill the agenda + const remarkTx = client.api.tx.system.remark('filler') + const agendaItems = Array.from({ length: maxScheduledPerBlock }, () => ({ + call: { Inline: remarkTx.method.toHex() }, + origin: { system: 'Root' }, + })) + + await client.dev.setStorage({ + Scheduler: { + agenda: [[[fullBlockNumber], agendaItems]], + }, + }) + + // Verify the agenda is full + const scheduledAfterFilling = await client.api.query.scheduler.agenda(fullBlockNumber) + expect(scheduledAfterFilling.length).toBe(maxScheduledPerBlock) + + // ------------------------------------------------------------------------------------ + // 2. Schedule a task with retry config that will fail and reschedule to the full block + // ------------------------------------------------------------------------------------ + + // Create a task that will fail - remarkWithEvent requires signed origin + const failingTx = client.api.tx.system.remarkWithEvent('will_fail') + + // Calculate the block where the task will first execute (give us time to set retry config) + let firstExecutionBlock: number + match(testConfig.blockProvider) + .with('Local', () => { + firstExecutionBlock = initialBlockNumber + 3 * offset + }) + .with('NonLocal', () => { + firstExecutionBlock = initialBlockNumber + 2 * offset + }) + .exhaustive() + + // Calculate retry period to reschedule to the full block + const retryPeriod = fullBlockNumber - firstExecutionBlock! + + const retryConfig = { + totalRetries: 3, + remaining: 3, + period: retryPeriod, + } + + const scheduleTx = client.api.tx.scheduler.schedule(firstExecutionBlock!, null, 1, failingTx) + + await scheduleInlineCallWithOrigin(client, scheduleTx.method.toHex(), { system: 'Root' }, testConfig.blockProvider) + + await client.dev.newBlock() + + // Find the anonymous task and set retry configuration + const initialTask = await findUnnamedScheduledTask(client, firstExecutionBlock!, failingTx.method.toHex(), 1) + expect(initialTask.task).toBeDefined() + expect(initialTask.taskIndex).toBeGreaterThanOrEqual(0) + + const setRetryTx = client.api.tx.scheduler.setRetry( + [firstExecutionBlock!, initialTask.taskIndex], + retryConfig.totalRetries, + retryConfig.period, + ) + await scheduleInlineCallWithOrigin(client, setRetryTx.method.toHex(), { system: 'Root' }, testConfig.blockProvider) + + await client.dev.newBlock() + + // Move to execution block + await client.dev.newBlock() + + // ------------------------------------------------------------------------------------- + // 3. Verify the retry fails and the task is removed (not rescheduled to the full block) + // ------------------------------------------------------------------------------------- + + const events = await client.api.query.system.events() + + // Should have a `RetryFailed` event + const retryFailedEvents = events.filter((record) => { + const { event } = record + return event.section === 'scheduler' && event.method === 'RetryFailed' + }) + expect(retryFailedEvents.length).toBe(1) + + const retryFailedEvent = retryFailedEvents[0] + assert(client.api.events.scheduler.RetryFailed.is(retryFailedEvent.event)) + + // Verify task is removed from the agenda in the block it was originally scheduled to execute in + const taskAtOriginalBlock = await findUnnamedScheduledTask(client, firstExecutionBlock!, failingTx.method.toHex(), 1) + expect(taskAtOriginalBlock.task).toBeUndefined() + + // Verify it is not rescheduled (check the full block agenda - should still be exactly `maxScheduledPerBlock` items) + const fullBlockAfterRetry = await client.api.query.scheduler.agenda(fullBlockNumber) + expect(fullBlockAfterRetry.length).toBe(maxScheduledPerBlock) + + // Verify the filled block remains full (no additional tasks) + const remarksInFullBlock = fullBlockAfterRetry.filter((item) => { + if (!item.isSome) return false + const unwrapped = item.unwrap() + return unwrapped.call.isInline && unwrapped.call.asInline.toHex() === remarkTx.method.toHex() + }) + expect(remarksInFullBlock.length).toBe(maxScheduledPerBlock) +} + export function baseSchedulerE2ETests< TCustom extends Record | undefined, TInitStoragesRelay extends Record> | undefined, @@ -1848,6 +2774,16 @@ export function baseSchedulerE2ETests< label: 'cancelling a named scheduled task is possible', testFn: async () => await cancelScheduledNamedTask(chain, testConfig), }, + { + kind: 'test', + label: 'cancelling a named task with unnamed cancellation function is possible', + testFn: async () => await cancelNamedTaskWithUnnamedCancellation(chain, testConfig), + }, + { + kind: 'test', + label: 'cancelling several named tasks does not damage the agenda', + testFn: async () => await cancelNamedTask(chain, testConfig), + }, { kind: 'test', label: 'scheduling a task after a delay is possible', @@ -1918,6 +2854,21 @@ export function baseSchedulerE2ETests< label: 'setting and canceling retry configuration for named scheduled tasks', testFn: async () => await scheduleNamedWithRetryConfig(chain, testConfig), }, + { + kind: 'test', + label: 'scheduling to a full agenda fails', + testFn: async () => await scheduleToFullAgenda(chain, testConfig), + }, + { + kind: 'test', + label: 'retry rescheduling named task to a full agenda', + testFn: async () => await retryReschedulingNamedTaskToFullAgenda(chain, testConfig), + }, + { + kind: 'test', + label: 'retry rescheduling anonymous task to a full agenda', + testFn: async () => await retryReschedulingAnonymousTaskToFullAgenda(chain, testConfig), + }, ], } }