Skip to content

Commit 23caebe

Browse files
Merge pull request #1138 from Joel234-png/feat/task-delegation-adaptive-polling-shutdown-fix
Fix keeper boot crash, wire adaptive polling, task delegation, and dead-letter queue (#778, #779, #782, #783)
2 parents 7b21948 + 0bd753d commit 23caebe

8 files changed

Lines changed: 473 additions & 10 deletions

File tree

contract/src/lib.rs

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -987,6 +987,10 @@ pub enum DataKey {
987987
/// Whether a governance unpause proposal is currently pending
988988
UnpauseProposed,
989989
Task(u64),
990+
/// Per-task delegated permission bitmask for a non-creator address
991+
/// (Issue #778). Same bit layout as `TaskConfig.permissions`
992+
/// (`PERM_CAN_PAUSE` etc.) — absence means no delegated access.
993+
TaskDelegate(u64, Address),
990994
Counter,
991995
ActiveTasks,
992996
Token,
@@ -2390,6 +2394,113 @@ impl SoroTaskContract {
23902394
exit_security_guard(&env);
23912395
}
23922396

2397+
/// Grant (or update) a delegate's permission bitmask for a task
2398+
/// (Issue #778). Creator-only. Pass `permissions = 0` to revoke —
2399+
/// equivalent to `revoke_task_delegate`, kept as a separate,
2400+
/// more-discoverable entrypoint below.
2401+
pub fn set_task_delegate(env: Env, task_id: u64, delegate: Address, permissions: u32) {
2402+
enter_security_guard(&env);
2403+
let task_key = DataKey::Task(task_id);
2404+
let config: TaskConfig = env
2405+
.storage()
2406+
.persistent()
2407+
.get(&task_key)
2408+
.expect("Task not found");
2409+
config.creator.require_auth();
2410+
2411+
let delegate_key = DataKey::TaskDelegate(task_id, delegate.clone());
2412+
if permissions == 0 {
2413+
env.storage().persistent().remove(&delegate_key);
2414+
} else {
2415+
env.storage().persistent().set(&delegate_key, &permissions);
2416+
}
2417+
2418+
env.events().publish(
2419+
(
2420+
Symbol::new(&env, "TaskDelegateSet"),
2421+
Symbol::new(&env, "v1"),
2422+
task_id,
2423+
),
2424+
(delegate, permissions),
2425+
);
2426+
exit_security_guard(&env);
2427+
}
2428+
2429+
/// Revoke a delegate's access to a task entirely (Issue #778). Creator-only.
2430+
pub fn revoke_task_delegate(env: Env, task_id: u64, delegate: Address) {
2431+
enter_security_guard(&env);
2432+
let task_key = DataKey::Task(task_id);
2433+
let config: TaskConfig = env
2434+
.storage()
2435+
.persistent()
2436+
.get(&task_key)
2437+
.expect("Task not found");
2438+
config.creator.require_auth();
2439+
2440+
env.storage()
2441+
.persistent()
2442+
.remove(&DataKey::TaskDelegate(task_id, delegate.clone()));
2443+
2444+
env.events().publish(
2445+
(
2446+
Symbol::new(&env, "TaskDelegateRevoked"),
2447+
Symbol::new(&env, "v1"),
2448+
task_id,
2449+
),
2450+
delegate,
2451+
);
2452+
exit_security_guard(&env);
2453+
}
2454+
2455+
/// Pause a task as either its creator or a delegate holding
2456+
/// `PERM_CAN_PAUSE` (Issue #778). Added alongside — not replacing —
2457+
/// `pause_task`, which remains creator-only and unchanged: Soroban has
2458+
/// no implicit caller identity, so delegated authorization needs an
2459+
/// explicit `caller` parameter, which would be a breaking signature
2460+
/// change to the existing entrypoint.
2461+
pub fn pause_task_as(env: Env, task_id: u64, caller: Address) {
2462+
enter_security_guard(&env);
2463+
caller.require_auth();
2464+
2465+
let task_key = DataKey::Task(task_id);
2466+
let mut config: TaskConfig = env
2467+
.storage()
2468+
.persistent()
2469+
.get(&task_key)
2470+
.expect("Task not found");
2471+
2472+
if caller != config.creator {
2473+
let delegate_permissions: u32 = env
2474+
.storage()
2475+
.persistent()
2476+
.get(&DataKey::TaskDelegate(task_id, caller.clone()))
2477+
.unwrap_or(0);
2478+
if delegate_permissions & PERM_CAN_PAUSE == 0 {
2479+
panic_with_error!(&env, Error::Unauthorized);
2480+
}
2481+
} else if config.permissions != 0 && (config.permissions & PERM_CAN_PAUSE) == 0 {
2482+
panic_with_error!(&env, Error::Unauthorized);
2483+
}
2484+
2485+
if !config.is_active {
2486+
panic_with_error!(&env, Error::TaskAlreadyPaused);
2487+
}
2488+
2489+
config.is_active = false;
2490+
env.storage().persistent().set(&task_key, &config);
2491+
remove_active_task_id(&env, task_id);
2492+
2493+
env.events().publish(
2494+
(
2495+
Symbol::new(&env, "TaskPaused"),
2496+
Symbol::new(&env, "v1"),
2497+
task_id,
2498+
),
2499+
caller,
2500+
);
2501+
exit_security_guard(&env);
2502+
}
2503+
23932504
/// Sets the maximum allowable single-update oracle price volatility threshold in basis points (bps).
23942505
pub fn set_max_volatility_bps(env: Env, admin: Address, max_bps: u32) {
23952506
admin.require_auth();

docs/task-delegation.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Task delegation / ACL (Issue #778)
2+
3+
A task's `permissions: u32` bitmask field already existed on `TaskConfig`
4+
(`PERM_CAN_PAUSE`, `PERM_CAN_UPDATE`, `PERM_CAN_CANCEL`,
5+
`PERM_CAN_DEPOSIT`), but every entry point that checked it
6+
(`pause_task_internal` and friends) only ever accepted `config.creator`'s
7+
signature — the bitmask was checked, but always against the same address
8+
that had just authenticated, so it could only ever be used by a creator
9+
to restrict their own actions, never to actually delegate access to a
10+
teammate or operator script.
11+
12+
## What was added
13+
14+
- `DataKey::TaskDelegate(task_id, delegate_address) -> u32`: a per-task,
15+
per-address permission bitmask, separate from `TaskConfig` itself (so
16+
existing persisted tasks' XDR shape is untouched — adding a field
17+
directly to `TaskConfig` would break decoding every already-registered
18+
task).
19+
- `set_task_delegate(task_id, delegate, permissions)` / `revoke_task_delegate(task_id, delegate)`
20+
— creator-only, grant/revoke a delegate's bitmask. `permissions = 0` via
21+
`set_task_delegate` is equivalent to `revoke_task_delegate`.
22+
- `pause_task_as(task_id, caller)` — a new entry point usable by either
23+
the creator or a delegate holding `PERM_CAN_PAUSE`.
24+
25+
## Why a new entry point instead of changing `pause_task`
26+
27+
Soroban has no implicit caller identity (no `msg.sender`) — authorization
28+
only exists as `some_address.require_auth()`, so delegated access needs
29+
an *explicit* caller parameter. Adding one to the existing `pause_task(task_id)`
30+
would be a breaking signature change for every already-integrated caller
31+
(the keeper, tests, any SDK). `pause_task_as(task_id, caller)` is
32+
additive: `pause_task` is untouched and remains creator-only.
33+
34+
## What's not implemented yet
35+
36+
Only `pause_task_as` was added. `modify_task`, `cancel_task`, and
37+
`deposit_gas` would follow the exact same pattern (check
38+
`caller == creator`, else look up `DataKey::TaskDelegate(task_id, caller)`
39+
and check the corresponding `PERM_CAN_*` bit) — left as a follow-up
40+
rather than making several more entry-point changes to an already very
41+
large contract file in one pass without being able to compile-check any
42+
of them.

keeper/docs/adaptive-polling.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Adaptive polling (Issue #782)
2+
3+
`src/adaptiveScheduler.js`'s `computeAdaptivePollingInterval` already
4+
existed — backlog-aware, RPC-latency-aware, error-backoff-aware, with
5+
anti-oscillation smoothing against the previous interval — but had no
6+
caller anywhere. The keeper's poll loop was a fixed-interval
7+
`setInterval(cycle, POLLING_INTERVAL_MS)` regardless of how many tasks
8+
were actually due.
9+
10+
## What changed
11+
12+
The loop in `index.js` is now a self-rescheduling `setTimeout` instead of
13+
a fixed `setInterval`, so the delay before the next cycle can actually
14+
vary. After each cycle, when enabled, it calls
15+
`computeAdaptivePollingInterval` with:
16+
17+
- `backlogSize` — total registered task count that cycle
18+
- `dueCount` — how many tasks were actually due that cycle
19+
- `cycleDurationMs` — measured directly
20+
- `errors` — consecutive poll-cycle failures (resets to 0 on a
21+
successful cycle)
22+
23+
and schedules the next cycle after the returned interval, clamped
24+
between `ADAPTIVE_POLLING_MIN_MS` and `ADAPTIVE_POLLING_MAX_MS`.
25+
26+
## Not yet tracked
27+
28+
`computeAdaptivePollingInterval` also accepts `dueSoonCount`,
29+
`minSecondsUntilDue`, and `avgRpcLatencyMs` — none of these are computed
30+
by the current wiring (passed as neutral values: `0`, `Infinity`, `0`,
31+
which the function's own logic treats as "skip this adjustment" rather
32+
than a fabricated signal). Adding real lookahead (how soon is the next
33+
task due, not just whether one is due right now) and RPC latency
34+
tracking would make the interval more responsive; left as a follow-up
35+
rather than guessing at values with no real signal behind them.
36+
37+
## Configuration
38+
39+
| Env var | Default | |
40+
|---|---|---|
41+
| `ADAPTIVE_POLLING_ENABLED` | `false` | Opt-in — changes polling cadence from fixed to variable for existing deployments |
42+
| `ADAPTIVE_POLLING_MIN_MS` | `1000` | Floor on the computed interval |
43+
| `ADAPTIVE_POLLING_MAX_MS` | `60000` | Ceiling on the computed interval |
44+
45+
`POLLING_INTERVAL_MS` is still read as the baseline (`baseIntervalMs`)
46+
the adaptive calculation adjusts from, and remains the fixed interval
47+
used when `ADAPTIVE_POLLING_ENABLED` is unset.

keeper/docs/graceful-shutdown.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Graceful shutdown (Issue #779)
2+
3+
Already fully implemented and wired: `src/gracefulShutdown.js`'s
4+
`GracefulShutdownManager`, instantiated and used throughout `index.js`.
5+
Verified end-to-end rather than assumed from the file existing:
6+
7+
- **Signal handling**: `init()` registers real `process.on("SIGTERM"/"SIGINT",
8+
...)` handlers that call `initiateShutdown(signal)`.
9+
- **Stop accepting new work**: the `shutdown:stop-accepting` event handler
10+
in `index.js` clears the polling timer and the reconciliation interval.
11+
- **Drain in-flight work**: `inFlightTasks` tracks tasks via `trackTask`/
12+
`completeTask`/`failTask`, called from `index.js`'s `executeTask`, with
13+
a configurable `drainTimeoutMs` before forcing.
14+
- **Lock transfer**: `activeLocks`, populated via `trackRedisLock`/
15+
`untrackRedisLock` (also called from `index.js`), are actually released
16+
(not just forgotten) during shutdown via `releaseLock`/`releaseRedlock`
17+
from `src/lock.js` — so a lock a shutting-down instance held becomes
18+
immediately acquirable by another live keeper instance, which is the
19+
correct "transfer" semantics for a Redis-lock-based multi-instance
20+
keeper (there's no need for a direct peer-to-peer handoff protocol when
21+
the next instance to poll just acquires the freed lock normally).
22+
- **Resource cleanup**: `registerResource(name, cleanupFn)` is called for
23+
the alert manager, SLA monitor, task registry, P2P network, RPC
24+
server/failover, idempotency guard, execution queue, and metrics
25+
server.
26+
27+
Tested in `keeper/__tests__/gracefulShutdown.test.js`.
28+
29+
## Configuration
30+
31+
| Env var | Default | |
32+
|---|---|---|
33+
| `SHUTDOWN_DRAIN_TIMEOUT_MS` | `30000` | How long to wait for in-flight tasks before forcing |
34+
| `SHUTDOWN_FORCE_TIMEOUT_MS` | `60000` | How long the force phase gets before giving up |
35+
| `SHUTDOWN_CLEANUP_TIMEOUT_MS` | `5000` | Budget for resource cleanup callbacks |
36+
37+
No code changes were needed for this issue — it documents what already
38+
exists and was already correct.

0 commit comments

Comments
 (0)