Repository: paritytech/polkadot-sdk
Pull request #7790: pallet-scheduler: Put back postponed tasks into the agenda
Author: login: bkchr
Labels: name: T2-pallets
description: This PR/Issue is related to a particular pallet.
Created: 2025-03-03T22:27:02Z | Merged: 2025-03-05T23:42:42Z

Right now `pallet-scheduler` is not putting back postponed tasks into the agenda when the early weight check is failing. This pull request ensures that these tasks are put back into the agenda and are not just "lost".



Diff:
diff --git a/prdoc/pr_7790.prdoc b/prdoc/pr_7790.prdoc
new file mode 100644
index 0000000000000..09a8ff52c8d13
--- /dev/null
+++ b/prdoc/pr_7790.prdoc
@@ -0,0 +1,9 @@
+title: 'pallet-scheduler: Put back postponed tasks into the agenda'
+doc:
+- audience: Runtime Dev
+  description: "Right now `pallet-scheduler` is not putting back postponed tasks into\
+    \ the agenda when the early weight check is failing. This pull request ensures\
+    \ that these tasks are put back into the agenda and are not just \"lost\".\r\n"
+crates:
+- name: pallet-scheduler
+  bump: patch
diff --git a/substrate/frame/scheduler/src/lib.rs b/substrate/frame/scheduler/src/lib.rs
index 2ad94ec04df47..65099dfd690cf 100644
--- a/substrate/frame/scheduler/src/lib.rs
+++ b/substrate/frame/scheduler/src/lib.rs
@@ -323,6 +323,7 @@ pub mod pallet {
 		type BlockNumberProvider: BlockNumberProvider;
 	}
 
+	/// Block number at which the agenda began incomplete execution.
 	#[pallet::storage]
 	pub type IncompleteSince<T: Config> = StorageValue<_, BlockNumberFor<T>>;
 
@@ -386,6 +387,8 @@ pub mod pallet {
 		RetryFailed { task: TaskAddress<BlockNumberFor<T>>, id: Option<TaskName> },
 		/// The given task can never be executed since it is overweight.
 		PermanentlyOverweight { task: TaskAddress<BlockNumberFor<T>>, id: Option<TaskName> },
+		/// Agenda is incomplete from `when`.
+		AgendaIncomplete { when: BlockNumberFor<T> },
 	}
 
 	#[pallet::error]
@@ -1202,6 +1205,7 @@ impl<T: Config> Pallet<T> {
 		}
 		incomplete_since = incomplete_since.min(when);
 		if incomplete_since <= now {
+			Self::deposit_event(Event::AgendaIncomplete { when: incomplete_since });
 			IncompleteSince::<T>::put(incomplete_since);
 		}
 	}
@@ -1235,10 +1239,7 @@ impl<T: Config> Pallet<T> {
 		let mut dropped = 0;
 
 		for (agenda_index, _) in ordered.into_iter().take(max as usize) {
-			let task = match agenda[agenda_index as usize].take() {
-				None => continue,
-				Some(t) => t,
-			};
+			let Some(task) = agenda[agenda_index as usize].take() else { continue };
 			let base_weight = T::WeightInfo::service_task(
 				task.call.lookup_len().map(|x| x as usize),
 				task.maybe_id.is_some(),
@@ -1246,6 +1247,7 @@ impl<T: Config> Pallet<T> {
 			);
 			if !weight.can_consume(base_weight) {
 				postponed += 1;
+				agenda[agenda_index as usize] = Some(task);
 				break
 			}
 			let result = Self::service_task(weight, now, when, agenda_index, *executed == 0, task);
diff --git a/substrate/frame/scheduler/src/mock.rs b/substrate/frame/scheduler/src/mock.rs
index a9aea97542acd..28b5ee6d6083c 100644
--- a/substrate/frame/scheduler/src/mock.rs
+++ b/substrate/frame/scheduler/src/mock.rs
@@ -212,7 +212,7 @@ impl WeightInfo for TestWeightInfo {
 	}
 }
 parameter_types! {
-	pub MaximumSchedulerWeight: Weight = Perbill::from_percent(80) *
+	pub storage MaximumSchedulerWeight: Weight = Perbill::from_percent(80) *
 		BlockWeights::get().max_block;
 }
 
diff --git a/substrate/frame/scheduler/src/tests.rs b/substrate/frame/scheduler/src/tests.rs
index d0a3acc05ac7e..1b7739e855ea7 100644
--- a/substrate/frame/scheduler/src/tests.rs
+++ b/substrate/frame/scheduler/src/tests.rs
@@ -1264,8 +1264,8 @@ fn cancel_named_periodic_scheduling_works() {
 
 #[test]
 fn scheduler_respects_weight_limits() {
-	let max_weight: Weight = <Test as Config>::MaximumWeight::get();
 	new_test_ext().execute_with(|| {
+		let max_weight: Weight = <Test as Config>::MaximumWeight::get();
 		let call = RuntimeCall::Logger(LoggerCall::log { i: 42, weight: max_weight / 3 * 2 });
 		assert_ok!(Scheduler::do_schedule(
 			DispatchTime::At(4),
@@ -1292,8 +1292,8 @@ fn scheduler_respects_weight_limits() {
 
 #[test]
 fn retry_respects_weight_limits() {
-	let max_weight: Weight = <Test as Config>::MaximumWeight::get();
 	new_test_ext().execute_with(|| {
+		let max_weight: Weight = <Test as Config>::MaximumWeight::get();
 		// schedule 42
 		let call = RuntimeCall::Logger(LoggerCall::log { i: 42, weight: max_weight / 3 * 2 });
 		assert_ok!(Scheduler::do_schedule(
@@ -1344,8 +1344,8 @@ fn retry_respects_weight_limits() {
 
 #[test]
 fn try_schedule_retry_respects_weight_limits() {
-	let max_weight: Weight = <Test as Config>::MaximumWeight::get();
 	new_test_ext().execute_with(|| {
+		let max_weight: Weight = <Test as Config>::MaximumWeight::get();
 		let service_agendas_weight = <Test as Config>::WeightInfo::service_agendas_base();
 		let service_agenda_weight = <Test as Config>::WeightInfo::service_agenda_base(
 			<Test as Config>::MaxScheduledPerBlock::get(),
@@ -1404,8 +1404,8 @@ fn try_schedule_retry_respects_weight_limits() {
 /// Permanently overweight calls are not deleted but also not executed.
 #[test]
 fn scheduler_does_not_delete_permanently_overweight_call() {
-	let max_weight: Weight = <Test as Config>::MaximumWeight::get();
 	new_test_ext().execute_with(|| {
+		let max_weight: Weight = <Test as Config>::MaximumWeight::get();
 		let call = RuntimeCall::Logger(LoggerCall::log { i: 42, weight: max_weight });
 		assert_ok!(Scheduler::do_schedule(
 			DispatchTime::At(4),
@@ -1430,10 +1430,10 @@ fn scheduler_does_not_delete_permanently_overweight_call() {
 
 #[test]
 fn scheduler_handles_periodic_failure() {
-	let max_weight: Weight = <Test as Config>::MaximumWeight::get();
-	let max_per_block = <Test as Config>::MaxScheduledPerBlock::get();
-
 	new_test_ext().execute_with(|| {
+		let max_weight: Weight = <Test as Config>::MaximumWeight::get();
+		let max_per_block = <Test as Config>::MaxScheduledPerBlock::get();
+
 		let call = RuntimeCall::Logger(LoggerCall::log { i: 42, weight: (max_weight / 3) * 2 });
 		let bound = Preimage::bound(call).unwrap();
 
@@ -1472,9 +1472,9 @@ fn scheduler_handles_periodic_failure() {
 
 #[test]
 fn scheduler_handles_periodic_unavailable_preimage() {
-	let max_weight: Weight = <Test as Config>::MaximumWeight::get();
-
 	new_test_ext().execute_with(|| {
+		let max_weight: Weight = <Test as Config>::MaximumWeight::get();
+
 		let call = RuntimeCall::Logger(LoggerCall::log { i: 42, weight: (max_weight / 3) * 2 });
 		let hash = <Test as frame_system::Config>::Hashing::hash_of(&call);
 		let len = call.using_encoded(|x| x.len()) as u32;
@@ -1518,8 +1518,8 @@ fn scheduler_handles_periodic_unavailable_preimage() {
 
 #[test]
 fn scheduler_respects_priority_ordering() {
-	let max_weight: Weight = <Test as Config>::MaximumWeight::get();
 	new_test_ext().execute_with(|| {
+		let max_weight: Weight = <Test as Config>::MaximumWeight::get();
 		let call = RuntimeCall::Logger(LoggerCall::log { i: 42, weight: max_weight / 3 });
 		assert_ok!(Scheduler::do_schedule(
 			DispatchTime::At(4),
@@ -3039,3 +3039,40 @@ fn unavailable_call_is_detected() {
 		assert!(!Preimage::is_requested(&hash));
 	});
 }
+
+#[test]
+fn postponed_task_is_still_available() {
+	new_test_ext().execute_with(|| {
+		let service_agendas_weight = <Test as Config>::WeightInfo::service_agendas_base();
+		let service_agenda_weight = <Test as Config>::WeightInfo::service_agenda_base(
+			<Test as Config>::MaxScheduledPerBlock::get(),
+		);
+
+		assert_ok!(Scheduler::schedule(
+			RuntimeOrigin::root(),
+			4,
+			None,
+			128,
+			Box::new(RuntimeCall::from(frame_system::Call::remark {
+				remark: vec![0u8; 3 * 1024 * 1024],
+			}))
+		));
+		System::run_to_block::<AllPalletsWithSystem>(3);
+		// Scheduled calls are in the agenda.
+		assert_eq!(Agenda::<Test>::get(4).len(), 1);
+
+		let old_weight = MaximumSchedulerWeight::get();
+		MaximumSchedulerWeight::set(&service_agenda_weight.saturating_add(service_agendas_weight));
+
+		System::run_to_block::<AllPalletsWithSystem>(4);
+
+		// The task should still be there.
+		assert_eq!(Agenda::<Test>::get(4).iter().filter(|a| a.is_some()).count(), 1);
+		System::assert_last_event(crate::Event::AgendaIncomplete { when: 4 }.into());
+
+		// Now it should get executed
+		MaximumSchedulerWeight::set(&old_weight);
+		System::run_to_block::<AllPalletsWithSystem>(5);
+		assert!(Agenda::<Test>::get(4).is_empty());
+	});
+}


Reviews:
user: login: rockbmb
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T01:56:59Z
user: login: rockbmb
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T02:09:20Z
user: login: gui1117
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T05:43:35Z
user: login: gui1117
body: The change looks good, but I don't understand another part of the code,

if `service_task` fails, we put back the task into agenda, even when error is `Unavailable`.
https://github.com/paritytech/polkadot-sdk/blob/5439b5fc8fb7d64e6e1d9748b64f05bdbe5fe141/substrate/frame/scheduler/src/lib.rs#L1254-L1267

But in `service_task` we do return some task with error `Unavailable` when:
* getting the preimage failed
* the execution fails because it is overweight and it is the first task

If any of these case happen, the task will stay forever on the agenda of the block and brick the agenda no?

It feels like it should be dropped instead.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T06:12:51Z
user: login: bkchr
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T07:05:49Z
user: login: bkchr
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-04T07:06:22Z

Review comments:
diff_hunk: @@ -323,6 +323,7 @@ pub mod pallet {
 		type BlockNumberProvider: BlockNumberProvider;
 	}
 
+	/// Block number from where the agenda is incompletely executed.
path: substrate/frame/scheduler/src/lib.rs
user: login: rockbmb
body: ```suggestion
	/// Block number at which the agenda began incomplete execution.
```
created_at: 2025-03-04T00:22:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,9 @@
+title: 'pallet-scheduler: Put back postponed tasks into the agenda'
+doc:
+- audience: Runtime Dev
+  description: "Right now `pallet-scheduler` is not putting back postponed tasks into\
+    \ the agenda when the early weight check is failing. This pull request ensures\
+    \ that these taks are put back into the agenda and are not just \"lost\".\r\n"
path: prdoc/pr_7790.prdoc
user: login: rockbmb
body: ```suggestion
    \ that these tasks are put back into the agenda and are not just \"lost\".\r\n"
```
created_at: 2025-03-04T00:33:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1235,17 +1239,15 @@ impl<T: Config> Pallet<T> {
 		let mut dropped = 0;
 
 		for (agenda_index, _) in ordered.into_iter().take(max as usize) {
-			let task = match agenda[agenda_index as usize].take() {
-				None => continue,
-				Some(t) => t,
-			};
+			let Some(task) = agenda[agenda_index as usize].take() else { continue };
 			let base_weight = T::WeightInfo::service_task(
 				task.call.lookup_len().map(|x| x as usize),
 				task.maybe_id.is_some(),
 				task.maybe_periodic.is_some(),
 			);
 			if !weight.can_consume(base_weight) {
 				postponed += 1;
+				agenda[agenda_index as usize] = Some(task);
path: substrate/frame/scheduler/src/lib.rs
user: login: rockbmb
body: I see, so this is what was missing from before: the task would have been `take`n above, and not put back in the agenda.
created_at: 2025-03-04T01:56:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3039,3 +3039,40 @@ fn unavailable_call_is_detected() {
 		assert!(!Preimage::is_requested(&hash));
 	});
 }
+
+#[test]
+fn postponed_task_is_still_available() {
+	new_test_ext().execute_with(|| {
+		let service_agendas_weight = <Test as Config>::WeightInfo::service_agendas_base();
+		let service_agenda_weight = <Test as Config>::WeightInfo::service_agenda_base(
+			<Test as Config>::MaxScheduledPerBlock::get(),
+		);
+
+		assert_ok!(Scheduler::schedule(
+			RuntimeOrigin::root(),
+			4,
+			None,
+			128,
+			Box::new(RuntimeCall::from(frame_system::Call::remark {
+				remark: vec![0u8; 3 * 1024 * 1024],
+			}))
+		));
+		System::run_to_block::<AllPalletsWithSystem>(3);
+		// Scheduled calls are in the agenda.
+		assert_eq!(Agenda::<Test>::get(4).len(), 1);
+
+		let old_weight = MaximumSchedulerWeight::get();
+		MaximumSchedulerWeight::set(&service_agenda_weight.saturating_add(service_agendas_weight));
+
+		System::run_to_block::<AllPalletsWithSystem>(4);
+
+		// The big task should still be there and the small one should have been executed.
path: substrate/frame/scheduler/src/tests.rs
user: login: rockbmb
body: Which small one? Above, the test only manually schedules one, and if I understood correctly, the test harness doesn't have anything scheduled on startup, right?
created_at: 2025-03-04T02:09:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3039,3 +3039,40 @@ fn unavailable_call_is_detected() {
 		assert!(!Preimage::is_requested(&hash));
 	});
 }
+
+#[test]
+fn postponed_task_is_still_available() {
+	new_test_ext().execute_with(|| {
+		let service_agendas_weight = <Test as Config>::WeightInfo::service_agendas_base();
+		let service_agenda_weight = <Test as Config>::WeightInfo::service_agenda_base(
+			<Test as Config>::MaxScheduledPerBlock::get(),
+		);
+
+		assert_ok!(Scheduler::schedule(
+			RuntimeOrigin::root(),
+			4,
+			None,
+			128,
+			Box::new(RuntimeCall::from(frame_system::Call::remark {
+				remark: vec![0u8; 3 * 1024 * 1024],
+			}))
+		));
+		System::run_to_block::<AllPalletsWithSystem>(3);
+		// Scheduled calls are in the agenda.
+		assert_eq!(Agenda::<Test>::get(4).len(), 1);
+
+		let old_weight = MaximumSchedulerWeight::get();
+		MaximumSchedulerWeight::set(&service_agenda_weight.saturating_add(service_agendas_weight));
+
+		System::run_to_block::<AllPalletsWithSystem>(4);
+
+		// The big task should still be there and the small one should have been executed.
path: substrate/frame/scheduler/src/tests.rs
user: login: gui1117
body: Yes I think the comment is outdated
```suggestion
		// The task should still be there.
```
created_at: 2025-03-04T05:43:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3039,3 +3039,40 @@ fn unavailable_call_is_detected() {
 		assert!(!Preimage::is_requested(&hash));
 	});
 }
+
+#[test]
+fn postponed_task_is_still_available() {
+	new_test_ext().execute_with(|| {
+		let service_agendas_weight = <Test as Config>::WeightInfo::service_agendas_base();
+		let service_agenda_weight = <Test as Config>::WeightInfo::service_agenda_base(
+			<Test as Config>::MaxScheduledPerBlock::get(),
+		);
+
+		assert_ok!(Scheduler::schedule(
+			RuntimeOrigin::root(),
+			4,
+			None,
+			128,
+			Box::new(RuntimeCall::from(frame_system::Call::remark {
+				remark: vec![0u8; 3 * 1024 * 1024],
+			}))
+		));
+		System::run_to_block::<AllPalletsWithSystem>(3);
+		// Scheduled calls are in the agenda.
+		assert_eq!(Agenda::<Test>::get(4).len(), 1);
+
+		let old_weight = MaximumSchedulerWeight::get();
+		MaximumSchedulerWeight::set(&service_agenda_weight.saturating_add(service_agendas_weight));
+
+		System::run_to_block::<AllPalletsWithSystem>(4);
+
+		// The big task should still be there and the small one should have been executed.
path: substrate/frame/scheduler/src/tests.rs
user: login: bkchr
body: Yeah, I had one before but realized I don't need it 😁
created_at: 2025-03-04T07:05:49Z
author_association: MEMBER
diff_hunk: @@ -1235,17 +1239,15 @@ impl<T: Config> Pallet<T> {
 		let mut dropped = 0;
 
 		for (agenda_index, _) in ordered.into_iter().take(max as usize) {
-			let task = match agenda[agenda_index as usize].take() {
-				None => continue,
-				Some(t) => t,
-			};
+			let Some(task) = agenda[agenda_index as usize].take() else { continue };
 			let base_weight = T::WeightInfo::service_task(
 				task.call.lookup_len().map(|x| x as usize),
 				task.maybe_id.is_some(),
 				task.maybe_periodic.is_some(),
 			);
 			if !weight.can_consume(base_weight) {
 				postponed += 1;
+				agenda[agenda_index as usize] = Some(task);
path: substrate/frame/scheduler/src/lib.rs
user: login: bkchr
body: Yes 
created_at: 2025-03-04T07:06:22Z
author_association: MEMBER

PR comments:
user: login: bkchr
created_at: 2025-03-03T22:27:25Z
body: /cmd prdoc --audience runtime-dev --bump patch
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2025-03-03T22:29:33Z
body: Command "prdoc --audience runtime-dev --bump patch" has failed ❌! [See logs here](https://github.com/paritytech/polkadot-sdk/actions/runs/13641716940/job/38132858773)
author_association: CONTRIBUTOR
user: login: bkchr
created_at: 2025-03-03T22:43:11Z
body: /cmd prdoc --audience runtime_dev --bump patch
author_association: MEMBER
user: login: bkchr
created_at: 2025-03-04T09:50:19Z
body: > If any of these case happen, the task will stay forever on the agenda of the block and brick the agenda no?

I have to admit, every time I take a close look at the scheduler, I find something weird :) However, what you said should be fine in most of the cases.

- Unavailable pre-image could be uploaded later
- Something not being able to be executed in the current block, doesn't mean this assumption is true in the next block, because we maybe already executed something before.
author_association: MEMBER
user: login: paritytech-workflow-stopper[bot]
created_at: 2025-03-05T19:18:11Z
body: 
All GitHub workflows were cancelled due to failure one of the required jobs.
**Failed workflow url**: https://github.com/paritytech/polkadot-sdk/actions/runs/13683530253
**Failed job name**: test-doc

author_association: NONE