From 65c54131351563d64af0349272d2edc1d7657d73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Bald=C3=A9?= Date: Fri, 21 Nov 2025 23:01:29 +0000 Subject: [PATCH 01/12] Draft PoC of scheduler fix Also, update block numbers. --- KNOWN_GOOD_BLOCK_NUMBERS_KUSAMA.env | 20 +++++++-------- KNOWN_GOOD_BLOCK_NUMBERS_POLKADOT.env | 20 +++++++-------- packages/shared/src/scheduler.ts | 37 ++++++++++++++++++++++++--- 3 files changed, 54 insertions(+), 23 deletions(-) diff --git a/KNOWN_GOOD_BLOCK_NUMBERS_KUSAMA.env b/KNOWN_GOOD_BLOCK_NUMBERS_KUSAMA.env index 1bd7e00cd..165716d6f 100644 --- a/KNOWN_GOOD_BLOCK_NUMBERS_KUSAMA.env +++ b/KNOWN_GOOD_BLOCK_NUMBERS_KUSAMA.env @@ -1,11 +1,11 @@ -ASSETHUBKUSAMA_BLOCK_NUMBER=11717903 -BASILISK_BLOCK_NUMBER=12067946 -BRIDGEHUBKUSAMA_BLOCK_NUMBER=7001619 -CORETIMEKUSAMA_BLOCK_NUMBER=3935500 -ENCOINTERKUSAMA_BLOCK_NUMBER=11532648 +ASSETHUBKUSAMA_BLOCK_NUMBER=11736841 +BASILISK_BLOCK_NUMBER=12088116 +BRIDGEHUBKUSAMA_BLOCK_NUMBER=7011114 +CORETIMEKUSAMA_BLOCK_NUMBER=3944850 +ENCOINTERKUSAMA_BLOCK_NUMBER=11548820 INTEGRITEEKUSAMA_BLOCK_NUMBER=8897648 -KARURA_BLOCK_NUMBER=10397192 -KUSAMA_BLOCK_NUMBER=31048242 -MOONRIVER_BLOCK_NUMBER=14012622 -PEOPLEKUSAMA_BLOCK_NUMBER=6640275 -SHIDEN_BLOCK_NUMBER=12681396 +KARURA_BLOCK_NUMBER=10405230 +KUSAMA_BLOCK_NUMBER=31068597 +MOONRIVER_BLOCK_NUMBER=14031305 +PEOPLEKUSAMA_BLOCK_NUMBER=6659709 +SHIDEN_BLOCK_NUMBER=12698491 diff --git a/KNOWN_GOOD_BLOCK_NUMBERS_POLKADOT.env b/KNOWN_GOOD_BLOCK_NUMBERS_POLKADOT.env index 261921f3c..d5ce9240a 100644 --- a/KNOWN_GOOD_BLOCK_NUMBERS_POLKADOT.env +++ b/KNOWN_GOOD_BLOCK_NUMBERS_POLKADOT.env @@ -1,11 +1,11 @@ -ACALA_BLOCK_NUMBER=9906281 -ASSETHUBPOLKADOT_BLOCK_NUMBER=10474894 -ASTAR_BLOCK_NUMBER=11146077 -BRIDGEHUBPOLKADOT_BLOCK_NUMBER=6433488 -COLLECTIVESPOLKADOT_BLOCK_NUMBER=7630786 -CORETIMEPOLKADOT_BLOCK_NUMBER=3037810 -HYDRATION_BLOCK_NUMBER=10166009 +ACALA_BLOCK_NUMBER=9916130 +ASSETHUBPOLKADOT_BLOCK_NUMBER=10494116 +ASTAR_BLOCK_NUMBER=11165523 +BRIDGEHUBPOLKADOT_BLOCK_NUMBER=6443323 +COLLECTIVESPOLKADOT_BLOCK_NUMBER=7640559 +CORETIMEPOLKADOT_BLOCK_NUMBER=3047120 +HYDRATION_BLOCK_NUMBER=10186316 INTEGRITEEPOLKADOT_BLOCK_NUMBER=5670011 -MOONBEAM_BLOCK_NUMBER=13415030 -PEOPLEPOLKADOT_BLOCK_NUMBER=3366615 -POLKADOT_BLOCK_NUMBER=28722897 +MOONBEAM_BLOCK_NUMBER=13432146 +PEOPLEPOLKADOT_BLOCK_NUMBER=3375979 +POLKADOT_BLOCK_NUMBER=28743434 diff --git a/packages/shared/src/scheduler.ts b/packages/shared/src/scheduler.ts index c1c8aadc9..ac9288840 100644 --- a/packages/shared/src/scheduler.ts +++ b/packages/shared/src/scheduler.ts @@ -184,10 +184,41 @@ export async function cancelScheduledTaskBadOriginTest< await client.dev.newBlock() + // Add a bogus task to the agenda to test robustness + const currentAgenda = await client.api.query.scheduler.agenda(targetBlockNumber!) + const bogusCall = client.api.tx.system.remarkWithEvent('bogus task').method.toHex() + const modifiedAgenda = [...currentAgenda] + + modifiedAgenda.push( + client.api.createType('Option', { + call: { Inline: bogusCall }, + maybeId: null, + priority: 1, + maybePeriodic: null, + origin: { system: 'Root' }, + }), + ) + + await client.dev.setStorage({ + Scheduler: { + agenda: [[[targetBlockNumber!], modifiedAgenda]], + }, + }) + const scheduled = await client.api.query.scheduler.agenda(targetBlockNumber!) - expect(scheduled.length).toBe(1) - expect(scheduled[0].isSome).toBeTruthy() - await check(scheduled[0].unwrap()).toMatchObject({ + expect(scheduled.length).toBeGreaterThan(0) + // Find our scheduled task (unnamed, priority 0, with our specific call) + const ourTask = scheduled.find((item) => { + if (!item.isSome) return false + const task = item.unwrap() + return ( + task.maybeId.isNone && task.priority.toNumber() === 0 && task.call.isInline && task.call.asInline.toHex() === call + ) + }) + + expect(ourTask).toBeDefined() + expect(ourTask!.isSome).toBeTruthy() + await check(ourTask!.unwrap()).toMatchObject({ maybeId: null, priority: 0, call: { inline: call }, From 363b4fa2f68beecb17b1934f00e6c956a2d56bc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Bald=C3=A9?= Date: Tue, 25 Nov 2025 21:41:36 +0000 Subject: [PATCH 02/12] Avoid `agenda[0]` antipattern in some scheduler tests Specifically, the scheduled task cancellation tests. More to come. --- .../assetHubKusama.scheduler.e2e.test.ts.snap | 2 +- ...ssetHubPolkadot.scheduler.e2e.test.ts.snap | 2 +- ...ectivesPolkadot.scheduler.e2e.test.ts.snap | 2 +- packages/shared/src/scheduler.ts | 212 ++++++++++++++---- 4 files changed, 167 insertions(+), 51 deletions(-) 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..7f3c1d05d 100644 --- a/packages/kusama/src/__snapshots__/assetHubKusama.scheduler.e2e.test.ts.snap +++ b/packages/kusama/src/__snapshots__/assetHubKusama.scheduler.e2e.test.ts.snap @@ -66,7 +66,7 @@ exports[`Kusama Asset Hub Scheduler > cancelling a scheduled task is possible > [ { "data": { - "index": 0, + "index": "(redacted)", "when": "(redacted)", }, "method": "Canceled", 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..c3ea3edc8 100644 --- a/packages/polkadot/src/__snapshots__/assetHubPolkadot.scheduler.e2e.test.ts.snap +++ b/packages/polkadot/src/__snapshots__/assetHubPolkadot.scheduler.e2e.test.ts.snap @@ -66,7 +66,7 @@ exports[`Polkadot Asset Hub Scheduler > cancelling a scheduled task is possible [ { "data": { - "index": 0, + "index": "(redacted)", "when": "(redacted)", }, "method": "Canceled", 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..d6bcd214c 100644 --- a/packages/polkadot/src/__snapshots__/collectivesPolkadot.scheduler.e2e.test.ts.snap +++ b/packages/polkadot/src/__snapshots__/collectivesPolkadot.scheduler.e2e.test.ts.snap @@ -66,7 +66,7 @@ exports[`Collectives Polkadot Scheduler E2E tests > cancelling a scheduled task [ { "data": { - "index": 0, + "index": "(redacted)", "when": "(redacted)", }, "method": "Canceled", diff --git a/packages/shared/src/scheduler.ts b/packages/shared/src/scheduler.ts index ac9288840..6ebf26ba9 100644 --- a/packages/shared/src/scheduler.ts +++ b/packages/shared/src/scheduler.ts @@ -57,6 +57,104 @@ import { /// Helpers /// ------- +/** + * Prepend a task 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. + * + * If the block that the subsequent `schedule.schedule` call runs on contains tasks, then accessing the agenda, during + * tests with `agenda[0]` may fail, as the first task in the agenda may or may not be the one being tested. + * + * This function necessarily *prepends* the task to the block number's agenda. + * By optionally using this in test code, it helps avoid spurious occasional test failures, as the `agenda[0]` access + * antipattern will fail when combined with it. + * @param client + * @param targetBlockNumber + */ +async function prependTaskToAgenda< + 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.remarkWithEvent('bogus task').method.toHex() + const modifiedAgenda = [...currentAgenda] + + // `.unshift()` prepends, `.push()` would append. + modifiedAgenda.unshift( + client.api.createType('Option', { + call: { Inline: bogusCall }, + maybeId: null, + priority: 1, + maybePeriodic: null, + origin: { system: 'Root' }, + }), + ) + + await client.dev.setStorage({ + Scheduler: { + agenda: [[[targetBlockNumber], modifiedAgenda]], + }, + }) +} + +/** + * Find a 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 Optional task ID to match. If provided, only matches tasks with this ID. If `null`, only matches unnamed tasks. + * @returns An object containing the task (if found), its index, and the full scheduled agenda. + */ +async function findScheduledTask< + TCustom extends Record | undefined, + TInitStorages extends Record> | undefined, +>( + client: Client, + blockNumber: number, + callHex: string, + priority: number, + taskId?: Uint8Array | null, +) { + 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() + + // Check if call matches + const callMatches = unwrapped.call.isInline && unwrapped.call.asInline.toHex() === callHex + if (!callMatches) return false + + // Check priority + if (unwrapped.priority.toNumber() !== priority) return false + + // Check task ID if specified + if (taskId === null) { + // Looking for unnamed tasks only + if (unwrapped.maybeId.isSome) return false + } else { + // Looking for a specific named task + if (unwrapped.maybeId.isNone) return false + if (unwrapped.maybeId.unwrap().toU8a().toString() !== taskId!.toString()) 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. * @@ -184,41 +282,13 @@ export async function cancelScheduledTaskBadOriginTest< await client.dev.newBlock() - // Add a bogus task to the agenda to test robustness - const currentAgenda = await client.api.query.scheduler.agenda(targetBlockNumber!) - const bogusCall = client.api.tx.system.remarkWithEvent('bogus task').method.toHex() - const modifiedAgenda = [...currentAgenda] - - modifiedAgenda.push( - client.api.createType('Option', { - call: { Inline: bogusCall }, - maybeId: null, - priority: 1, - maybePeriodic: null, - origin: { system: 'Root' }, - }), - ) - - await client.dev.setStorage({ - Scheduler: { - agenda: [[[targetBlockNumber!], modifiedAgenda]], - }, - }) - const scheduled = await client.api.query.scheduler.agenda(targetBlockNumber!) - expect(scheduled.length).toBeGreaterThan(0) - // Find our scheduled task (unnamed, priority 0, with our specific call) - const ourTask = scheduled.find((item) => { - if (!item.isSome) return false - const task = item.unwrap() - return ( - task.maybeId.isNone && task.priority.toNumber() === 0 && task.call.isInline && task.call.asInline.toHex() === call - ) - }) - - expect(ourTask).toBeDefined() - expect(ourTask!.isSome).toBeTruthy() - await check(ourTask!.unwrap()).toMatchObject({ + 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, priority: 0, call: { inline: call }, @@ -270,6 +340,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/ }) @@ -329,6 +402,9 @@ export async function scheduledCallExecutes< expect(scheduled.length).toBe(1) expect(scheduled[0].isSome).toBeTruthy() + // 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. await check(scheduled[0].unwrap()).toMatchObject({ maybeId: null, priority: 0, @@ -465,11 +541,22 @@ export async function cancelScheduledTask< 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 just before the total issuance change that was scheduled above + await prependTaskToAgenda(client, targetBlockNumber!) - const cancelTx = client.api.tx.scheduler.cancel(targetBlockNumber!, 0) + // Find this test's scheduled task (unnamed, priority 0, with the `adjustIssuance` call) + const adjustIssuanceCall = adjustIssuanceTx.method.toHex() + const { + task, + taskIndex, + scheduled: initialScheduled, + } = await findScheduledTask(client, targetBlockNumber!, adjustIssuanceCall, 0, null) + + expect(initialScheduled.length).toBeGreaterThan(0) + 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) @@ -480,14 +567,14 @@ export async function cancelScheduledTask< // 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/, + redactKeys: /new|old|when|task|index/, }) .toMatchSnapshot('events for scheduled task cancellation') - scheduled = await client.api.query.scheduler.agenda(targetBlockNumber!) - expect(scheduled.length).toBe(0) - await client.dev.newBlock() + + const scheduled = await client.api.query.scheduler.agenda(targetBlockNumber!) + expect(scheduled.length).toBe(0) } /** @@ -531,9 +618,38 @@ 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() + // Add a bogus task to the agenda to test robustness + const currentAgenda = await client.api.query.scheduler.agenda(targetBlockNumber!) + const bogusCall = client.api.tx.system.remarkWithEvent('bogus task').method.toHex() + const modifiedAgenda = [...currentAgenda] + + modifiedAgenda.push( + client.api.createType('Option', { + call: { Inline: bogusCall }, + maybeId: null, + priority: 1, + maybePeriodic: null, + origin: { system: 'Root' }, + }), + ) + + await client.dev.setStorage({ + Scheduler: { + agenda: [[[targetBlockNumber!], modifiedAgenda]], + }, + }) + + // Note: cancelNamed finds the task by ID, so position in the agenda doesn't matter. + // Verification that the named task exists, but no need to find its specific index. + const { task, scheduled: initialScheduled } = await findScheduledTask( + client, + targetBlockNumber!, + adjustIssuanceTx.method.toHex(), + 0, + taskId, + ) + expect(initialScheduled.length).toBeGreaterThan(1) + expect(task).toBeDefined() const cancelTx = client.api.tx.scheduler.cancelNamed(taskId) @@ -547,11 +663,11 @@ export async function cancelScheduledNamedTask< }) .toMatchSnapshot('events for scheduled task cancellation') - scheduled = await client.api.query.scheduler.agenda(targetBlockNumber!) - expect(scheduled.length).toBe(0) - await client.dev.newBlock() + const scheduled = await client.api.query.scheduler.agenda(targetBlockNumber!) + expect(scheduled.length).toBe(0) + const events = await client.api.query.system.events() const filteredEvents = events.filter((ev) => { const { event } = ev From 29e5636dd0ae8341601df6bf3f80bed412a73192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Bald=C3=A9?= Date: Wed, 26 Nov 2025 03:10:28 +0000 Subject: [PATCH 03/12] Bump known good block numbers --- KNOWN_GOOD_BLOCK_NUMBERS_KUSAMA.env | 20 ++++++++++---------- KNOWN_GOOD_BLOCK_NUMBERS_POLKADOT.env | 20 ++++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/KNOWN_GOOD_BLOCK_NUMBERS_KUSAMA.env b/KNOWN_GOOD_BLOCK_NUMBERS_KUSAMA.env index 265dcbc74..209b271a9 100644 --- a/KNOWN_GOOD_BLOCK_NUMBERS_KUSAMA.env +++ b/KNOWN_GOOD_BLOCK_NUMBERS_KUSAMA.env @@ -1,11 +1,11 @@ -ASSETHUBKUSAMA_BLOCK_NUMBER=11753519 -BASILISK_BLOCK_NUMBER=12106424 -BRIDGEHUBKUSAMA_BLOCK_NUMBER=7019756 -CORETIMEKUSAMA_BLOCK_NUMBER=3953331 -ENCOINTERKUSAMA_BLOCK_NUMBER=11562774 +ASSETHUBKUSAMA_BLOCK_NUMBER=11790143 +BASILISK_BLOCK_NUMBER=12146434 +BRIDGEHUBKUSAMA_BLOCK_NUMBER=7038979 +CORETIMEKUSAMA_BLOCK_NUMBER=3972146 +ENCOINTERKUSAMA_BLOCK_NUMBER=11594499 INTEGRITEEKUSAMA_BLOCK_NUMBER=8897648 -KARURA_BLOCK_NUMBER=10412483 -KUSAMA_BLOCK_NUMBER=31087049 -MOONRIVER_BLOCK_NUMBER=14048274 -PEOPLEKUSAMA_BLOCK_NUMBER=6677393 -SHIDEN_BLOCK_NUMBER=12713930 +KARURA_BLOCK_NUMBER=10428409 +KUSAMA_BLOCK_NUMBER=31127475 +MOONRIVER_BLOCK_NUMBER=14085569 +PEOPLEKUSAMA_BLOCK_NUMBER=6716263 +SHIDEN_BLOCK_NUMBER=12747916 diff --git a/KNOWN_GOOD_BLOCK_NUMBERS_POLKADOT.env b/KNOWN_GOOD_BLOCK_NUMBERS_POLKADOT.env index 39bf53b00..d8c414900 100644 --- a/KNOWN_GOOD_BLOCK_NUMBERS_POLKADOT.env +++ b/KNOWN_GOOD_BLOCK_NUMBERS_POLKADOT.env @@ -1,11 +1,11 @@ -ACALA_BLOCK_NUMBER=9925011 -ASSETHUBPOLKADOT_BLOCK_NUMBER=10511453 -ASTAR_BLOCK_NUMBER=11183241 -BRIDGEHUBPOLKADOT_BLOCK_NUMBER=6452247 -COLLECTIVESPOLKADOT_BLOCK_NUMBER=7649409 -CORETIMEPOLKADOT_BLOCK_NUMBER=3055637 -HYDRATION_BLOCK_NUMBER=10204687 +ACALA_BLOCK_NUMBER=9944682 +ASSETHUBPOLKADOT_BLOCK_NUMBER=10550022 +ASTAR_BLOCK_NUMBER=11222060 +BRIDGEHUBPOLKADOT_BLOCK_NUMBER=6471771 +COLLECTIVESPOLKADOT_BLOCK_NUMBER=7668752 +CORETIMEPOLKADOT_BLOCK_NUMBER=3074276 +HYDRATION_BLOCK_NUMBER=10245012 INTEGRITEEPOLKADOT_BLOCK_NUMBER=5670011 -MOONBEAM_BLOCK_NUMBER=13447707 -PEOPLEPOLKADOT_BLOCK_NUMBER=3384369 -POLKADOT_BLOCK_NUMBER=28762022 +MOONBEAM_BLOCK_NUMBER=13481973 +PEOPLEPOLKADOT_BLOCK_NUMBER=3403272 +POLKADOT_BLOCK_NUMBER=28802793 From f672ed8836bcd89f06ac5be78e08cb31f948f5b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Bald=C3=A9?= Date: Thu, 27 Nov 2025 21:06:45 +0000 Subject: [PATCH 04/12] Add more tests to named task cancellation --- .../assetHubKusama.scheduler.e2e.test.ts.snap | 98 ++-- ...ssetHubPolkadot.scheduler.e2e.test.ts.snap | 98 ++-- ...ectivesPolkadot.scheduler.e2e.test.ts.snap | 98 ++-- packages/shared/src/helpers/index.ts | 12 +- packages/shared/src/scheduler.ts | 508 +++++++++++++----- 5 files changed, 539 insertions(+), 275 deletions(-) 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 7f3c1d05d..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": "(redacted)", - "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 c3ea3edc8..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": "(redacted)", - "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 d6bcd214c..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": "(redacted)", - "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 6ebf26ba9..ac7d37449 100644 --- a/packages/shared/src/scheduler.ts +++ b/packages/shared/src/scheduler.ts @@ -18,6 +18,8 @@ import { checkSystemEvents, getBlockNumber, nextSchedulableBlockNum, + scheduleCallListWithOrigin, + scheduleInlineCallListWithSameOrigin, scheduleInlineCallWithOrigin, scheduleLookupCallWithOrigin, type TestConfig, @@ -79,19 +81,19 @@ async function prependTaskToAgenda< TInitStorages extends Record> | undefined, >(client: Client, targetBlockNumber: number): Promise { const currentAgenda = await client.api.query.scheduler.agenda(targetBlockNumber!) - const bogusCall = client.api.tx.system.remarkWithEvent('bogus task').method.toHex() + 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.unshift( - client.api.createType('Option', { - call: { Inline: bogusCall }, - maybeId: null, - priority: 1, - maybePeriodic: null, - origin: { system: 'Root' }, - }), - ) + modifiedAgenda.push(task, task) await client.dev.setStorage({ Scheduler: { @@ -101,25 +103,18 @@ async function prependTaskToAgenda< } /** - * Find a scheduled task in the agenda that matches the specified criteria. + * 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. - * @param taskId Optional task ID to match. If provided, only matches tasks with this ID. If `null`, only matches unnamed tasks. * @returns An object containing the task (if found), its index, and the full scheduled agenda. */ -async function findScheduledTask< +async function findUnnamedScheduledTask< TCustom extends Record | undefined, TInitStorages extends Record> | undefined, ->( - client: Client, - blockNumber: number, - callHex: string, - priority: number, - taskId?: Uint8Array | null, -) { +>(client: Client, blockNumber: number, callHex: string, priority: number) { const scheduled = await client.api.query.scheduler.agenda(blockNumber) let taskIndex = -1 @@ -127,22 +122,46 @@ async function findScheduledTask< if (!item.isSome) return false const unwrapped = item.unwrap() - // Check if call matches - const callMatches = unwrapped.call.isInline && unwrapped.call.asInline.toHex() === callHex - if (!callMatches) return false - - // Check priority + if (unwrapped.maybeId.isSome) return false if (unwrapped.priority.toNumber() !== priority) return false + if (!unwrapped.call.isInline || unwrapped.call.asInline.toHex() !== callHex) return false - // Check task ID if specified - if (taskId === null) { - // Looking for unnamed tasks only - if (unwrapped.maybeId.isSome) return false - } else { - // Looking for a specific named task - if (unwrapped.maybeId.isNone) return false - if (unwrapped.maybeId.unwrap().toU8a().toString() !== taskId!.toString()) 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 @@ -398,23 +417,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 prependTaskToAgenda(client, targetBlockNumber!) - // 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. - 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() @@ -475,20 +493,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 prependTaskToAgenda(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() @@ -524,16 +545,8 @@ export async function cancelScheduledTask< const adjustIssuanceTx = client.api.tx.balances.forceAdjustTotalIssuance('Increase', 1) const initialBlockNumber = await getBlockNumber(client.api, testConfig.blockProvider) - const offset = blockProviderOffset(testConfig) - let targetBlockNumber: number - match(testConfig.blockProvider) - .with('Local', () => { - targetBlockNumber = initialBlockNumber + 3 * offset - }) - .with('NonLocal', () => { - targetBlockNumber = initialBlockNumber + 2 * offset - }) - .exhaustive() + // 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) @@ -541,18 +554,15 @@ export async function cancelScheduledTask< await client.dev.newBlock() - // Insert irrelevant task just before the total issuance change that was scheduled above + // Insert irrelevant tasks just before the total issuance change that was scheduled above await prependTaskToAgenda(client, targetBlockNumber!) // Find this test's scheduled task (unnamed, priority 0, with the `adjustIssuance` call) const adjustIssuanceCall = adjustIssuanceTx.method.toHex() - const { - task, - taskIndex, - scheduled: initialScheduled, - } = await findScheduledTask(client, targetBlockNumber!, adjustIssuanceCall, 0, null) + let { task, taskIndex, scheduled } = await findUnnamedScheduledTask(client, targetBlockNumber!, adjustIssuanceCall, 0) - expect(initialScheduled.length).toBeGreaterThan(0) + const preCancellationAgendaLength = scheduled.length + expect(preCancellationAgendaLength).toBeGreaterThanOrEqual(1) expect(task).toBeDefined() expect(taskIndex).toBeGreaterThanOrEqual(0) @@ -562,28 +572,54 @@ export async function cancelScheduledTask< 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|index/, - }) - .toMatchSnapshot('events for scheduled task cancellation') + // Check events - should have ExtrinsicSuccess and 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' + }) - const scheduled = await client.api.query.scheduler.agenda(targetBlockNumber!) - expect(scheduled.length).toBe(0) + // 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 + * Test cancellation of a named scheduled task. * - * 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 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, @@ -607,6 +643,10 @@ export async function cancelScheduledNamedTask< }) .exhaustive() + // ---------------------- + // 1. Schedule named task + // ---------------------- + const scheduleNamedTx = client.api.tx.scheduler.scheduleNamed(taskId, targetBlockNumber!, null, 0, adjustIssuanceTx) await scheduleInlineCallWithOrigin( @@ -618,65 +658,261 @@ export async function cancelScheduledNamedTask< await client.dev.newBlock() - // Add a bogus task to the agenda to test robustness - const currentAgenda = await client.api.query.scheduler.agenda(targetBlockNumber!) - const bogusCall = client.api.tx.system.remarkWithEvent('bogus task').method.toHex() - const modifiedAgenda = [...currentAgenda] - - modifiedAgenda.push( - client.api.createType('Option', { - call: { Inline: bogusCall }, - maybeId: null, - priority: 1, - maybePeriodic: null, - origin: { system: 'Root' }, - }), - ) + // Insert irrelevant task to test agenda indexing + await prependTaskToAgenda(client, targetBlockNumber!) - await client.dev.setStorage({ - Scheduler: { - agenda: [[[targetBlockNumber!], modifiedAgenda]], - }, - }) + // ----------------------------------------------------------------------------- + // 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. - // Verification that the named task exists, but no need to find its specific index. - const { task, scheduled: initialScheduled } = await findScheduledTask( + // 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(initialScheduled.length).toBeGreaterThan(1) + 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() - await checkSystemEvents(client, 'scheduler', { section: 'balances', method: 'TotalIssuanceForced' }) - .redact({ - redactKeys: /when|task/, - }) - .toMatchSnapshot('events for scheduled task cancellation') + // ------------------------------------------------------------------------------- + // 4. Check data and events: verify `Canceled` event, no execution, lookup removed + // ------------------------------------------------------------------------------- - await client.dev.newBlock() + // Check events - should have `Canceled`, but no `TotalIssuanceForced` + const events = await client.api.query.system.events() - const scheduled = await client.api.query.scheduler.agenda(targetBlockNumber!) - expect(scheduled.length).toBe(0) + const schedulerEvents = events.filter((record) => { + const { event } = record + return event.section === 'scheduler' + }) - 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') + // 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 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 + 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() } /** @@ -732,11 +968,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 prependTaskToAgenda(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() }, @@ -1995,6 +2234,11 @@ export function baseSchedulerE2ETests< label: 'cancelling a named scheduled task is possible', testFn: async () => await cancelScheduledNamedTask(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', From 9985755596d2240646fb222ac58af5a69451a61b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Bald=C3=A9?= Date: Thu, 27 Nov 2025 22:28:27 +0000 Subject: [PATCH 05/12] Remove unsafe agenda access from named delayed task test --- packages/shared/src/scheduler.ts | 42 ++++++++++++++++---------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/shared/src/scheduler.ts b/packages/shared/src/scheduler.ts index ac7d37449..0a0d6cfc9 100644 --- a/packages/shared/src/scheduler.ts +++ b/packages/shared/src/scheduler.ts @@ -18,7 +18,6 @@ import { checkSystemEvents, getBlockNumber, nextSchedulableBlockNum, - scheduleCallListWithOrigin, scheduleInlineCallListWithSameOrigin, scheduleInlineCallWithOrigin, scheduleLookupCallWithOrigin, @@ -60,23 +59,18 @@ import { /// ------- /** - * Prepend a task to the agenda at the given block number. + * 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. * - * If the block that the subsequent `schedule.schedule` call runs on contains tasks, then accessing the agenda, during + * 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. - * - * This function necessarily *prepends* the task to the block number's agenda. - * By optionally using this in test code, it helps avoid spurious occasional test failures, as the `agenda[0]` access - * antipattern will fail when combined with it. - * @param client - * @param targetBlockNumber */ -async function prependTaskToAgenda< +async function addDummyTasksToAgenda< TCustom extends Record | undefined, TInitStorages extends Record> | undefined, >(client: Client, targetBlockNumber: number): Promise { @@ -418,7 +412,7 @@ export async function scheduledCallExecutes< await client.dev.newBlock() // Insert irrelevant task to test agenda indexing - await prependTaskToAgenda(client, targetBlockNumber!) + await addDummyTasksToAgenda(client, targetBlockNumber!) let { task, scheduled } = await findUnnamedScheduledTask( client, @@ -494,7 +488,7 @@ export async function scheduledNamedCallExecutes< await client.dev.newBlock() // Insert irrelevant task to test agenda indexing - await prependTaskToAgenda(client, targetBlockNumber!) + await addDummyTasksToAgenda(client, targetBlockNumber!) let { task, scheduled } = await findNamedScheduledTask( client, @@ -555,7 +549,7 @@ export async function cancelScheduledTask< await client.dev.newBlock() // Insert irrelevant tasks just before the total issuance change that was scheduled above - await prependTaskToAgenda(client, targetBlockNumber!) + await addDummyTasksToAgenda(client, targetBlockNumber!) // Find this test's scheduled task (unnamed, priority 0, with the `adjustIssuance` call) const adjustIssuanceCall = adjustIssuanceTx.method.toHex() @@ -659,7 +653,7 @@ export async function cancelScheduledNamedTask< await client.dev.newBlock() // Insert irrelevant task to test agenda indexing - await prependTaskToAgenda(client, targetBlockNumber!) + await addDummyTasksToAgenda(client, targetBlockNumber!) // ----------------------------------------------------------------------------- // 2. Check data: verify task is in agenda and lookup points to correct location @@ -969,7 +963,7 @@ export async function scheduleTaskAfterDelay< .exhaustive() // Insert irrelevant task to test robustness - await prependTaskToAgenda(client, targetBlock!) + await addDummyTasksToAgenda(client, targetBlock!) let { task, scheduled } = await findUnnamedScheduledTask(client, targetBlock!, adjustIssuanceTx.method.toHex(), 0) expect(scheduled.length).toBeGreaterThan(1) @@ -1060,11 +1054,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() }, @@ -1087,8 +1087,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() From dd452db2e8af03ef0791943b32209c809e7fa2e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Bald=C3=A9?= Date: Fri, 28 Nov 2025 15:53:54 +0000 Subject: [PATCH 06/12] Refactor more scheduler tests to avoid `agenda[0]` antipattern In particular, refactor the priority-weighted test, and add/change existing checks. --- packages/shared/src/scheduler.ts | 213 ++++++++++++++++++++----------- 1 file changed, 139 insertions(+), 74 deletions(-) diff --git a/packages/shared/src/scheduler.ts b/packages/shared/src/scheduler.ts index 0a0d6cfc9..14045ddbe 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' @@ -1138,10 +1138,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, @@ -1195,9 +1199,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. @@ -1266,10 +1270,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 } }, @@ -1362,9 +1373,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: { @@ -1391,11 +1411,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, }) @@ -1496,11 +1526,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() }, @@ -1542,10 +1576,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. @@ -1557,7 +1594,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() }, @@ -1583,8 +1620,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() @@ -1681,6 +1722,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) @@ -1707,7 +1750,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, @@ -1746,17 +1790,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() @@ -1785,25 +1834,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() }, @@ -1811,14 +1861,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() @@ -1842,32 +1892,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 + + // 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() - // The agenda on the block just before the most recently built block should be emoty + // 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() } /** From 830e30db11e71a8aa1c73c0295671689108475db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Bald=C3=A9?= Date: Fri, 28 Nov 2025 16:21:10 +0000 Subject: [PATCH 07/12] Refactor missing scheduler tests: retry & its cancellation --- packages/shared/src/scheduler.ts | 143 ++++++++++++++++--------------- 1 file changed, 75 insertions(+), 68 deletions(-) diff --git a/packages/shared/src/scheduler.ts b/packages/shared/src/scheduler.ts index 14045ddbe..47100c8bd 100644 --- a/packages/shared/src/scheduler.ts +++ b/packages/shared/src/scheduler.ts @@ -1997,18 +1997,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) @@ -2024,18 +2028,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({ @@ -2043,7 +2048,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() @@ -2058,15 +2063,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() } @@ -2136,10 +2141,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) @@ -2147,7 +2152,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) @@ -2163,19 +2168,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({ @@ -2197,18 +2203,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) @@ -2223,21 +2234,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({ @@ -2246,26 +2255,24 @@ 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, }) From 377364f86663c7806c4cc5debcdd5bce029d8a25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Bald=C3=A9?= Date: Fri, 28 Nov 2025 16:37:39 +0000 Subject: [PATCH 08/12] Add test to named task cancellation (anonymously) --- packages/shared/src/scheduler.ts | 126 +++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/packages/shared/src/scheduler.ts b/packages/shared/src/scheduler.ts index 47100c8bd..5c0986c9d 100644 --- a/packages/shared/src/scheduler.ts +++ b/packages/shared/src/scheduler.ts @@ -742,6 +742,127 @@ export async function cancelScheduledNamedTask< expect(afterCancellation.task).toBeUndefined() } +/** + * Test cancellation of a named task using the unnamed cancellation function. + * + * 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 cancelNamedTaskWithUnnamedCancellation< + 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 + match(testConfig.blockProvider) + .with('Local', () => { + targetBlockNumber = initialBlockNumber + 3 * offset + }) + .with('NonLocal', () => { + targetBlockNumber = initialBlockNumber + 2 * offset + }) + .exhaustive() + + // ---------------------- + // 1. Schedule named task + // ---------------------- + + 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() + + // Insert irrelevant task to test agenda indexing + await addDummyTasksToAgenda(client, targetBlockNumber!) + + // ----------------------------------------------------------------------------- + // 2. Check data: verify task is in agenda and lookup points to correct location + // ----------------------------------------------------------------------------- + + 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) + + // 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) + + // ----------------------------------------------------------- + // 3. Cancel the named task using unnamed cancellation function + // ----------------------------------------------------------- + + // 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 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. * @@ -2306,6 +2427,11 @@ 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', From a74be7f5ff35abe3e14202f25d6af6f35349b5fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Bald=C3=A9?= Date: Mon, 1 Dec 2025 00:38:28 +0000 Subject: [PATCH 09/12] Test scheduling with full agenda --- packages/shared/src/scheduler.ts | 91 +++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/scheduler.ts b/packages/shared/src/scheduler.ts index 5c0986c9d..ff09c296c 100644 --- a/packages/shared/src/scheduler.ts +++ b/packages/shared/src/scheduler.ts @@ -2071,7 +2071,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) @@ -2399,6 +2400,89 @@ export async function scheduleNamedWithRetryConfig< }) } +/** + * 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) +} + export function baseSchedulerE2ETests< TCustom extends Record | undefined, TInitStoragesRelay extends Record> | undefined, @@ -2507,6 +2591,11 @@ 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), + }, ], } } From 8105071cf920ba021cf9e3229c0094c0abdaee5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Bald=C3=A9?= Date: Mon, 1 Dec 2025 12:20:09 +0000 Subject: [PATCH 10/12] Test rescheduling to full block --- packages/shared/src/scheduler.ts | 139 +++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/packages/shared/src/scheduler.ts b/packages/shared/src/scheduler.ts index ff09c296c..72d71e53a 100644 --- a/packages/shared/src/scheduler.ts +++ b/packages/shared/src/scheduler.ts @@ -2483,6 +2483,140 @@ export async function scheduleToFullAgenda< expect(dispatchError.isExhausted).toBe(true) } +/** + * Test retry rescheduling to a full agenda block. + * + * This test verifies the behavior when a 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 task with retry config that will fail and reschedule to the full block + * 3. Verify the rescheduling behavior when the target block is full + */ +export async function retryReschedulingToFullAgenda< + 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) +} + export function baseSchedulerE2ETests< TCustom extends Record | undefined, TInitStoragesRelay extends Record> | undefined, @@ -2596,6 +2730,11 @@ export function baseSchedulerE2ETests< label: 'scheduling to a full agenda fails', testFn: async () => await scheduleToFullAgenda(chain, testConfig), }, + { + kind: 'test', + label: 'retry rescheduling to a full agenda', + testFn: async () => await retryReschedulingToFullAgenda(chain, testConfig), + }, ], } } From d0a239803b94d9e97300bfc997b77d1d3faa508f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Bald=C3=A9?= Date: Mon, 1 Dec 2025 12:49:53 +0000 Subject: [PATCH 11/12] test rescheduling of anonymous task to full agenda --- packages/shared/src/scheduler.ts | 146 +++++++++++++++++++++++++++++-- 1 file changed, 139 insertions(+), 7 deletions(-) diff --git a/packages/shared/src/scheduler.ts b/packages/shared/src/scheduler.ts index 72d71e53a..846aeaeed 100644 --- a/packages/shared/src/scheduler.ts +++ b/packages/shared/src/scheduler.ts @@ -2484,15 +2484,15 @@ export async function scheduleToFullAgenda< } /** - * Test retry rescheduling to a full agenda block. + * Test retry rescheduling of a named task to a full agenda block. * - * This test verifies the behavior when a task with retry configuration attempts to reschedule + * 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 task with retry config that will fail and reschedule to the full block - * 3. Verify the rescheduling behavior when the target block is full + * 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 retryReschedulingToFullAgenda< +export async function retryReschedulingNamedTaskToFullAgenda< TCustom extends Record | undefined, TInitStorages extends Record> | undefined, >(chain: Chain, testConfig: TestConfig) { @@ -2617,6 +2617,133 @@ export async function retryReschedulingToFullAgenda< 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, @@ -2732,8 +2859,13 @@ export function baseSchedulerE2ETests< }, { kind: 'test', - label: 'retry rescheduling to a full agenda', - testFn: async () => await retryReschedulingToFullAgenda(chain, testConfig), + 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), }, ], } From a68530194cf8828f3612f032cc6d9cdffcbafced Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Bald=C3=A9?= Date: Mon, 1 Dec 2025 12:56:01 +0000 Subject: [PATCH 12/12] Add missing note to remaining `agenda[0]` accesses --- packages/shared/src/scheduler.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/shared/src/scheduler.ts b/packages/shared/src/scheduler.ts index 846aeaeed..31dfae59b 100644 --- a/packages/shared/src/scheduler.ts +++ b/packages/shared/src/scheduler.ts @@ -970,6 +970,8 @@ export async function cancelNamedTask< // 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