Skip to content

Commit ff06b5e

Browse files
feat(pulse-scheduler): optimize gas when querying active subscriptions (#2604)
* feat: optimize gas when querying active subscriptions * test: expand testActivateDeactivateSubscription * doc: update docs for getActiveSubscriptions
1 parent 1451636 commit ff06b5e

File tree

4 files changed

+222
-64
lines changed

4 files changed

+222
-64
lines changed

target_chains/ethereum/contracts/contracts/pulse/scheduler/IScheduler.sol

+9-6
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ interface IScheduler is SchedulerEvents {
4848

4949
/**
5050
* @notice Updates price feeds for a subscription.
51-
* Verifies the updateData using the Pyth contract and validates that all feeds have the same timestamp.
51+
* @dev Internally, this verifies the updateData using the Pyth contract and validates update conditions.
5252
* @param subscriptionId The ID of the subscription
5353
* @param updateData The price update data from Pyth
5454
* @param priceIds The IDs of the price feeds to update
@@ -73,7 +73,8 @@ interface IScheduler is SchedulerEvents {
7373
bytes32[] calldata priceIds
7474
) external view returns (PythStructs.Price[] memory prices);
7575

76-
/** @notice Returns the exponentially-weighted moving average price of a price feed without any sanity checks.
76+
/**
77+
* @notice Returns the exponentially-weighted moving average price of a price feed without any sanity checks.
7778
* @dev This function returns the same price as `getEmaPrice` in the case where the price is available.
7879
* However, if the price is not recent this function returns the latest available price.
7980
*
@@ -114,10 +115,12 @@ interface IScheduler is SchedulerEvents {
114115
) external view returns (uint256 minimumBalanceInWei);
115116

116117
/**
117-
* @notice Gets all active subscriptions with their parameters
118-
* @dev This function has no access control to allow keepers to discover active subscriptions
119-
* @param startIndex The starting index for pagination
120-
* @param maxResults The maximum number of results to return
118+
* @notice Gets all active subscriptions with their parameters, paginated.
119+
* @dev This function has no access control to allow keepers to discover active subscriptions.
120+
* @dev Note that the order of subscription IDs returned may not be sequential and can change
121+
* when subscriptions are deactivated or reactivated.
122+
* @param startIndex The starting index within the list of active subscriptions (NOT the subscription ID).
123+
* @param maxResults The maximum number of results to return starting from startIndex.
121124
* @return subscriptionIds Array of active subscription IDs
122125
* @return subscriptionParams Array of subscription parameters for each active subscription
123126
* @return totalCount Total number of active subscriptions

target_chains/ethereum/contracts/contracts/pulse/scheduler/Scheduler.sol

+55-27
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ abstract contract Scheduler is IScheduler, SchedulerState {
5656
// Map subscription ID to manager
5757
_state.subscriptionManager[subscriptionId] = msg.sender;
5858

59+
_addToActiveSubscriptions(subscriptionId);
60+
5961
emit SubscriptionCreated(subscriptionId, msg.sender);
6062
return subscriptionId;
6163
}
@@ -102,10 +104,12 @@ abstract contract Scheduler is IScheduler, SchedulerState {
102104
}
103105

104106
currentParams.isActive = true;
107+
_addToActiveSubscriptions(subscriptionId);
105108
emit SubscriptionActivated(subscriptionId);
106109
} else if (wasActive && !willBeActive) {
107110
// Deactivating a subscription
108111
currentParams.isActive = false;
112+
_removeFromActiveSubscriptions(subscriptionId);
109113
emit SubscriptionDeactivated(subscriptionId);
110114
}
111115

@@ -583,14 +587,7 @@ abstract contract Scheduler is IScheduler, SchedulerState {
583587
uint256 totalCount
584588
)
585589
{
586-
// Count active subscriptions first to determine total count
587-
// TODO: Optimize this. store numActiveSubscriptions or something.
588-
totalCount = 0;
589-
for (uint256 i = 1; i < _state.subscriptionNumber; i++) {
590-
if (_state.subscriptionParams[i].isActive) {
591-
totalCount++;
592-
}
593-
}
590+
totalCount = _state.activeSubscriptionIds.length;
594591

595592
// If startIndex is beyond the total count, return empty arrays
596593
if (startIndex >= totalCount) {
@@ -607,25 +604,13 @@ abstract contract Scheduler is IScheduler, SchedulerState {
607604
subscriptionIds = new uint256[](resultCount);
608605
subscriptionParams = new SubscriptionParams[](resultCount);
609606

610-
// Find and populate the requested page of active subscriptions
611-
uint256 activeIndex = 0;
612-
uint256 resultIndex = 0;
613-
614-
for (
615-
uint256 i = 1;
616-
i < _state.subscriptionNumber && resultIndex < resultCount;
617-
i++
618-
) {
619-
if (_state.subscriptionParams[i].isActive) {
620-
if (activeIndex >= startIndex) {
621-
subscriptionIds[resultIndex] = i;
622-
subscriptionParams[resultIndex] = _state.subscriptionParams[
623-
i
624-
];
625-
resultIndex++;
626-
}
627-
activeIndex++;
628-
}
607+
// Populate the arrays with the requested page of active subscriptions
608+
for (uint256 i = 0; i < resultCount; i++) {
609+
uint256 subscriptionId = _state.activeSubscriptionIds[
610+
startIndex + i
611+
];
612+
subscriptionIds[i] = subscriptionId;
613+
subscriptionParams[i] = _state.subscriptionParams[subscriptionId];
629614
}
630615

631616
return (subscriptionIds, subscriptionParams, totalCount);
@@ -682,4 +667,47 @@ abstract contract Scheduler is IScheduler, SchedulerState {
682667
}
683668
_;
684669
}
670+
671+
/**
672+
* @notice Adds a subscription to the active subscriptions list.
673+
* @param subscriptionId The ID of the subscription to add.
674+
*/
675+
function _addToActiveSubscriptions(uint256 subscriptionId) internal {
676+
// Only add if not already in the list
677+
if (_state.activeSubscriptionIndex[subscriptionId] == 0) {
678+
_state.activeSubscriptionIds.push(subscriptionId);
679+
680+
// Store the index as 1-based, 0 means not in the list
681+
_state.activeSubscriptionIndex[subscriptionId] = _state
682+
.activeSubscriptionIds
683+
.length;
684+
}
685+
}
686+
687+
/**
688+
* @notice Removes a subscription from the active subscriptions list.
689+
* @param subscriptionId The ID of the subscription to remove.
690+
*/
691+
function _removeFromActiveSubscriptions(uint256 subscriptionId) internal {
692+
uint256 index = _state.activeSubscriptionIndex[subscriptionId];
693+
694+
// Only remove if it's in the list
695+
if (index > 0) {
696+
// Adjust index to be 0-based instead of 1-based
697+
index = index - 1;
698+
699+
// If it's not the last element, move the last element to its position
700+
if (index < _state.activeSubscriptionIds.length - 1) {
701+
uint256 lastId = _state.activeSubscriptionIds[
702+
_state.activeSubscriptionIds.length - 1
703+
];
704+
_state.activeSubscriptionIds[index] = lastId;
705+
_state.activeSubscriptionIndex[lastId] = index + 1; // 1-based index
706+
}
707+
708+
// Remove the last element
709+
_state.activeSubscriptionIds.pop();
710+
_state.activeSubscriptionIndex[subscriptionId] = 0;
711+
}
712+
}
685713
}

target_chains/ethereum/contracts/contracts/pulse/scheduler/SchedulerState.sol

+6
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ contract SchedulerState {
3131
mapping(uint256 => mapping(bytes32 => PythStructs.PriceFeed)) priceUpdates;
3232
/// Sub ID -> manager address
3333
mapping(uint256 => address) subscriptionManager;
34+
/// Array of active subscription IDs.
35+
/// Gas optimization to avoid scanning through all subscriptions when querying for all active ones.
36+
uint256[] activeSubscriptionIds;
37+
/// Sub ID -> index in activeSubscriptionIds array + 1 (0 means not in array).
38+
/// This lets us avoid a linear scan of `activeSubscriptionIds` when deactivating a subscription.
39+
mapping(uint256 => uint256) activeSubscriptionIndex;
3440
}
3541
State internal _state;
3642

target_chains/ethereum/contracts/forge-test/PulseScheduler.t.sol

+152-31
Original file line numberDiff line numberDiff line change
@@ -331,51 +331,172 @@ contract SchedulerTest is Test, SchedulerEvents, PulseSchedulerTestUtils {
331331
}
332332

333333
function testActivateDeactivateSubscription() public {
334-
uint256 subscriptionId = addTestSubscription(
335-
scheduler,
336-
address(reader)
337-
);
338-
339-
// Get current params
340-
(SchedulerState.SubscriptionParams memory params, ) = scheduler
341-
.getSubscription(subscriptionId);
342-
343-
// Deactivate subscription using updateSubscription
344-
params.isActive = false;
334+
// Add multiple subscriptions
335+
uint256 subId1 = addTestSubscription(scheduler, address(reader)); // ID 1
336+
uint256 subId2 = addTestSubscription(scheduler, address(reader)); // ID 2
337+
uint256 subId3 = addTestSubscription(scheduler, address(reader)); // ID 3
345338

339+
// --- Verify initial state ---
340+
(uint256[] memory activeIds, , uint256 totalCount) = scheduler
341+
.getActiveSubscriptions(0, 10);
342+
assertEq(totalCount, 3, "Initial: Total count should be 3");
343+
assertEq(activeIds.length, 3, "Initial: Active IDs length should be 3");
344+
assertEq(activeIds[0], subId1, "Initial: ID 1 should be active");
345+
assertEq(activeIds[1], subId2, "Initial: ID 2 should be active");
346+
assertEq(activeIds[2], subId3, "Initial: ID 3 should be active");
347+
348+
// --- Deactivate the middle subscription (ID 2) ---
349+
(SchedulerState.SubscriptionParams memory params2, ) = scheduler
350+
.getSubscription(subId2);
351+
params2.isActive = false;
346352
vm.expectEmit();
347-
emit SubscriptionDeactivated(subscriptionId);
353+
emit SubscriptionDeactivated(subId2);
348354
vm.expectEmit();
349-
emit SubscriptionUpdated(subscriptionId);
355+
emit SubscriptionUpdated(subId2);
356+
scheduler.updateSubscription(subId2, params2);
350357

351-
scheduler.updateSubscription(subscriptionId, params);
358+
// Verify state after deactivating ID 2
359+
(activeIds, , totalCount) = scheduler.getActiveSubscriptions(0, 10);
360+
assertEq(totalCount, 2, "After Deact 2: Total count should be 2");
361+
assertEq(
362+
activeIds.length,
363+
2,
364+
"After Deact 2: Active IDs length should be 2"
365+
);
366+
assertEq(activeIds[0], subId1, "After Deact 2: ID 1 should be active");
367+
assertEq(
368+
activeIds[1],
369+
subId3,
370+
"After Deact 2: ID 3 should be active (moved)"
371+
); // ID 3 takes the place of ID 2
372+
373+
// --- Deactivate the last subscription (ID 3, now at index 1) ---
374+
(SchedulerState.SubscriptionParams memory params3, ) = scheduler
375+
.getSubscription(subId3);
376+
params3.isActive = false;
377+
vm.expectEmit();
378+
emit SubscriptionDeactivated(subId3);
379+
vm.expectEmit();
380+
emit SubscriptionUpdated(subId3);
381+
scheduler.updateSubscription(subId3, params3);
352382

353-
// Verify subscription was deactivated
354-
(
355-
SchedulerState.SubscriptionParams memory storedParams,
356-
SchedulerState.SubscriptionStatus memory status
357-
) = scheduler.getSubscription(subscriptionId);
383+
// Verify state after deactivating ID 3
384+
(activeIds, , totalCount) = scheduler.getActiveSubscriptions(0, 10);
385+
assertEq(totalCount, 1, "After Deact 3: Total count should be 1");
386+
assertEq(
387+
activeIds.length,
388+
1,
389+
"After Deact 3: Active IDs length should be 1"
390+
);
391+
assertEq(
392+
activeIds[0],
393+
subId1,
394+
"After Deact 3: Only ID 1 should be active"
395+
);
358396

359-
assertFalse(storedParams.isActive, "Subscription should be inactive");
397+
// --- Reactivate the middle subscription (ID 2) ---
398+
params2.isActive = true; // Use the params struct from earlier
399+
vm.expectEmit();
400+
emit SubscriptionActivated(subId2);
401+
vm.expectEmit();
402+
emit SubscriptionUpdated(subId2);
403+
scheduler.updateSubscription(subId2, params2);
360404

361-
// Reactivate subscription using updateSubscription
362-
params.isActive = true;
405+
// Verify state after reactivating ID 2
406+
(activeIds, , totalCount) = scheduler.getActiveSubscriptions(0, 10);
407+
assertEq(totalCount, 2, "After React 2: Total count should be 2");
408+
assertEq(
409+
activeIds.length,
410+
2,
411+
"After React 2: Active IDs length should be 2"
412+
);
413+
assertEq(activeIds[0], subId1, "After React 2: ID 1 should be active");
414+
assertEq(activeIds[1], subId2, "After React 2: ID 2 should be active"); // ID 2 is added back to the end
363415

416+
// --- Reactivate the last subscription (ID 3) ---
417+
params3.isActive = true; // Use the params struct from earlier
364418
vm.expectEmit();
365-
emit SubscriptionActivated(subscriptionId);
419+
emit SubscriptionActivated(subId3);
366420
vm.expectEmit();
367-
emit SubscriptionUpdated(subscriptionId);
421+
emit SubscriptionUpdated(subId3);
422+
scheduler.updateSubscription(subId3, params3);
423+
424+
// Verify final state (all active)
425+
(activeIds, , totalCount) = scheduler.getActiveSubscriptions(0, 10);
426+
assertEq(totalCount, 3, "Final: Total count should be 3");
427+
assertEq(activeIds.length, 3, "Final: Active IDs length should be 3");
428+
assertEq(activeIds[0], subId1, "Final: ID 1 should be active");
429+
assertEq(activeIds[1], subId2, "Final: ID 2 should be active");
430+
assertEq(activeIds[2], subId3, "Final: ID 3 should be active"); // ID 3 is added back to the end
431+
432+
// --- Deactivate all remaining subscriptions ---
433+
// Deactivate ID 1 (first element)
434+
(SchedulerState.SubscriptionParams memory params1, ) = scheduler
435+
.getSubscription(subId1);
436+
params1.isActive = false;
437+
vm.expectEmit();
438+
emit SubscriptionDeactivated(subId1);
439+
vm.expectEmit();
440+
emit SubscriptionUpdated(subId1);
441+
scheduler.updateSubscription(subId1, params1);
368442

369-
scheduler.updateSubscription(subscriptionId, params);
443+
// Verify state after deactivating ID 1
444+
(activeIds, , totalCount) = scheduler.getActiveSubscriptions(0, 10);
445+
assertEq(totalCount, 2, "After Deact 1: Total count should be 2");
446+
assertEq(
447+
activeIds.length,
448+
2,
449+
"After Deact 1: Active IDs length should be 2"
450+
);
451+
assertEq(
452+
activeIds[0],
453+
subId3,
454+
"After Deact 1: ID 3 should be at index 0"
455+
); // ID 3 moved to front
456+
assertEq(
457+
activeIds[1],
458+
subId2,
459+
"After Deact 1: ID 2 should be at index 1"
460+
);
370461

371-
// Verify subscription was reactivated
372-
(storedParams, status) = scheduler.getSubscription(subscriptionId);
462+
// Deactivate ID 2 (now last element)
463+
params2.isActive = false; // Use existing params struct
464+
vm.expectEmit();
465+
emit SubscriptionDeactivated(subId2);
466+
vm.expectEmit();
467+
emit SubscriptionUpdated(subId2);
468+
scheduler.updateSubscription(subId2, params2);
373469

374-
assertTrue(storedParams.isActive, "Subscription should be active");
375-
assertTrue(
376-
storedParams.isActive,
377-
"Subscription params should show active"
470+
// Verify state after deactivating ID 2
471+
(activeIds, , totalCount) = scheduler.getActiveSubscriptions(0, 10);
472+
assertEq(
473+
totalCount,
474+
1,
475+
"After Deact 2 (again): Total count should be 1"
476+
);
477+
assertEq(
478+
activeIds.length,
479+
1,
480+
"After Deact 2 (again): Active IDs length should be 1"
481+
);
482+
assertEq(
483+
activeIds[0],
484+
subId3,
485+
"After Deact 2 (again): Only ID 3 should be active"
378486
);
487+
488+
// Deactivate ID 3 (last remaining element)
489+
params3.isActive = false; // Use existing params struct
490+
vm.expectEmit();
491+
emit SubscriptionDeactivated(subId3);
492+
vm.expectEmit();
493+
emit SubscriptionUpdated(subId3);
494+
scheduler.updateSubscription(subId3, params3);
495+
496+
// Verify final empty state
497+
(activeIds, , totalCount) = scheduler.getActiveSubscriptions(0, 10);
498+
assertEq(totalCount, 0, "Empty: Total count should be 0");
499+
assertEq(activeIds.length, 0, "Empty: Active IDs length should be 0");
379500
}
380501

381502
function testAddFunds() public {

0 commit comments

Comments
 (0)