Skip to content

feat(core): executor side-channel for progress, logs and middleware toggles - #599

Merged
kartikeya-27 merged 11 commits into
ByteVeda:masterfrom
stromanni:feat/589-side-channel-core
Aug 1, 2026
Merged

feat(core): executor side-channel for progress, logs and middleware toggles#599
kartikeya-27 merged 11 commits into
ByteVeda:masterfrom
stromanni:feat/589-side-channel-core

Conversation

@stromanni

@stromanni stromanni commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Part 1 of #589: the wire protocol and the scheduler-side plumbing that lets an attached executor report progress, task logs and published partials, and receive its middleware toggles, without holding a database connection of its own. The SDK sides (Python, Node, Java) follow in their own PRs on top of this one.

What changes

Protocol (worker/protocol.rs)

  • SchedulerMessage::HelloAck grows a capabilities: Vec<String> list — #[serde(default)], so a legacy ack still parses as "no capabilities". PROTOCOL_VERSION is untouched on purpose: bumping it forces scheduler and executors to upgrade in lockstep, which is the coupling attached executors exist to remove. CAP_SIDE_CHANNEL is the one capability defined here.
  • SchedulerMessage::Job carries disabled_middleware and metadata alongside the dispatch. These are reads the executor needs at dispatch time and the scheduler is already reading task config when it dispatches, so they ride along instead of costing a round trip. Both are additive and defaulted, so an older executor ignores them.
  • ExecutorMessage::Progress and ExecutorMessage::TaskLog are the two new fire-and-forget frames. publish is a TaskLog at level result, so one frame covers streamed partials too.

Scheduler side (worker/side_channel.rs, worker/remote.rs)

  • SideChannel is the narrow trait RemoteDispatcher needs: update_progress, write_task_log, disabled_middleware. Every method is infallible — a task that only wanted to report progress must not fail because the database was briefly unhappy. A trait rather than an Arc<dyn Storage> so the dispatcher is testable against a fake, and so the settings key toggles live under is spelled once.
  • StorageSideChannel is the implementation a real deployment installs. Toggle lists are cached with a 5s TTL, matching the SDK worker caches (_MW_CHAIN_TTL in the Python shell): a dashboard toggle is rare, a dispatch is hot.
  • RemoteDispatcher handles the new frames in handle_frame in arms placed before into_job_result — they are not results, and taking the in-flight entry would break the exactly-once accounting that entry exists for. Applying them is queued off the reader thread so a database write never delays a result, and a frame naming a job this executor is not running is dropped.

Executor side (worker/executor.rs)

  • ExecutorSideChannel is what a task body reports through. Tasks never touch the shared Mutex<FrameWriter> directly; everything goes through a bounded outbound queue drained by the existing result thread, so a task in a progress loop can neither park on the socket nor flood the scheduler. Progress coalesces (idempotent-latest, only the newest value per job need survive a backlog); logs cannot coalesce, so the queue is bounded and drops oldest with a counter. Silent entirely when the scheduler advertised no side_channel capability.

Wiring

  • taskito-server installs a StorageSideChannel over the backend it already holds — this process has the connection an executor deliberately does not.
  • The PyO3 prefork pool advertises no capabilities: a child of an in-process worker holds real storage and writes its own progress and logs.

Tests

New coverage across protocol.rs, side_channel.rs, tests/rust/executor_tests.rs, tests/rust/remote_tests.rs and taskito-server/tests/attach_e2e.rs:

  • frames from a peer that predates the side-channel still parse; side-channel frames round-trip and are never mistaken for results; a log without extra stays distinct from one with empty extra
  • toggle resolution: unset is empty rather than an error, malformed disables nothing, a stored list is decoded and then cached, and the key matches what the dashboard writes
  • capability negotiation both ways — a scheduler with storage advertises it, one without advertises nothing, and an executor sent nothing stays silent
  • a flood of progress neither blocks the task nor grows without bound
  • progress and logs from a running job are applied; an executor cannot write against a job it is not running; a side-channel frame never settles the job it names
  • end to end over a real attach: an executor reports progress and logs through the scheduler, and a dashboard toggle rides the dispatch frame

cargo test --workspace, cargo clippy --workspace --all-targets, cargo fmt --check, and cargo check under --features postgres and --features redis are all clean.

Refs #589.

Summary by CodeRabbit

  • New Features
    • Added executor side-channel support for reporting task progress and structured logs.
    • Added capability negotiation so unsupported workers continue operating safely.
    • Added storage-backed collection of progress updates and task logs.
    • Added propagation of disabled middleware settings and task metadata during dispatch.
    • Added bounded buffering, progress coalescing, and dropped-log reporting.
    • Added end-to-end coverage confirming updates reach storage and jobs complete successfully.

Optional fields and a hello_ack capability list rather than a protocol
bump, so a scheduler and its executors upgrade independently.
Queued off the reader thread so a database write never delays a result,
and dropped unless the executor is actually running the job it names.
Queued and coalesced, so a task in a progress loop never parks on the
socket, and silent when the scheduler advertised no support.
@github-actions github-actions Bot added the rust label Aug 1, 2026
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@stromanni, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 13 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6c1755b5-33d7-4d7b-90aa-e84a1099481e

📥 Commits

Reviewing files that changed from the base of the PR and between a8a55db and 5bade2c.

📒 Files selected for processing (5)
  • crates/taskito-core/src/worker/executor.rs
  • crates/taskito-core/src/worker/remote.rs
  • crates/taskito-core/src/worker/side_channel.rs
  • crates/taskito-core/tests/rust/executor_tests.rs
  • crates/taskito-core/tests/rust/remote_tests.rs
📝 Walkthrough

Walkthrough

The worker protocol now negotiates side-channel capabilities and carries progress, task logs, metadata, and disabled middleware. Executors buffer and send reports. Remote dispatchers validate and forward reports to storage.

Changes

Executor side-channel

Layer / File(s) Summary
Protocol contracts and public exports
crates/taskito-core/src/lib.rs, crates/taskito-core/src/worker/{mod.rs,protocol.rs}, crates/taskito-python/src/prefork/child.rs
The protocol adds capability negotiation, dispatch metadata, progress frames, and task-log frames. Public worker exports and legacy handshake compatibility are updated.
Executor reporting and lifecycle
crates/taskito-core/src/worker/executor.rs, crates/taskito-core/tests/rust/executor_tests.rs
The executor exposes side-channel reporting, coalesces progress, bounds logs, tracks middleware toggles, and separates side-channel frames from job results.
Remote dispatch and storage forwarding
crates/taskito-core/src/worker/{remote.rs,side_channel.rs}, crates/taskito-core/tests/rust/remote_tests.rs, crates/taskito-server/src/runtime/mod.rs
The dispatcher negotiates support, resolves disabled middleware, validates job ownership, and forwards progress and logs through a storage-backed drain.
Server wiring and end-to-end validation
crates/taskito-server/tests/attach_e2e.rs
Attach tests verify capability advertisement, persisted progress and logs, dispatch metadata, disabled middleware, and successful job completion.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Executor
  participant RemoteDispatcher
  participant Storage
  Executor->>RemoteDispatcher: Negotiate side-channel capability
  RemoteDispatcher->>Executor: Dispatch job with metadata and disabled middleware
  Executor->>RemoteDispatcher: Send progress and task-log frames
  RemoteDispatcher->>Storage: Persist progress and task logs
  Executor->>RemoteDispatcher: Send job result
Loading

Possibly related issues

Possibly related PRs

  • ByteVeda/taskito#583 — Introduced the shared worker protocol and remote dispatcher interfaces extended here.
  • ByteVeda/taskito#595 — Introduced executor APIs extended here for side-channel negotiation and frame processing.

Suggested labels: storage, scheduler, tests, enhancement

Suggested reviewers: kartikeya-27, pratyush618

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 79.59% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main side-channel changes for executor progress, logs, and middleware toggles.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/taskito-core/src/worker/executor.rs (1)

899-916: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Release the toggle entry when a job is declined.

accept_job calls shared.remember_toggles before it can call decline. decline frames a Failure directly and never calls forget_toggles, so a declined job with a non-empty disable list leaves an entry in Shared::toggles for the life of the session. The doc comment on toggles states that entries are released when the job reports, which does not hold on this path.

Under sustained saturation the map grows once per declined job.

🐛 Proposed fix
 fn decline(shared: &Arc<Shared>, job: &Job, reason: &str) {
     log::warn!(
         "[taskito] executor {} declining job {}: {reason}",
         shared.executor_id,
         job.id
     );
+    // The job reports here rather than through `send_result`, so its toggle
+    // entry has to be released on this path too.
+    shared.forget_toggles(&job.id);
     let (frame, payload) = ExecutorMessage::from_job_result(JobResult::Failure {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-core/src/worker/executor.rs` around lines 899 - 916, Update
decline to release the job’s toggle entry before sending its failure result by
calling the existing Shared::forget_toggles mechanism for the declined job.
Preserve the current warning, failure payload, and retry behavior while ensuring
declined jobs with disable lists do not remain in Shared::toggles.
🧹 Nitpick comments (2)
crates/taskito-core/src/worker/remote.rs (2)

186-208: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Consider tying the drain thread to Shared's lifetime.

SideChannelPump::start spawns the drain thread in RemoteDispatcher::new, but only Shared::run stops it through stop_side_channel. A dispatcher that is built and then dropped without run leaves the thread alive for the life of the process. The new capability tests in crates/taskito-core/tests/rust/remote_tests.rs do exactly that.

A Drop implementation on Shared that calls stop_side_channel makes the teardown self-contained and keeps stop_side_channel idempotent, since it already takes the handle out of the Mutex.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-core/src/worker/remote.rs` around lines 186 - 208, Implement
Drop for Shared so dropping it calls stop_side_channel, ensuring the
SideChannelPump drain thread is stopped even when RemoteDispatcher::run is never
invoked. Reuse the existing idempotent stop_side_channel method and its
side_channel_drain handle rather than adding separate teardown logic.

986-1001: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Use extra_len to tell an empty extra from an absent one.

handle_frame discards extra_len (extra_len: _), so apply_task_log infers absence from an empty payload. The protocol keeps Some(0) and None distinct, and crates/taskito-core/src/worker/protocol.rs has a test named a_log_without_extra_is_distinct_from_one_with_empty_extra that pins that distinction. Here both collapse to None, so a stored extra of "" becomes NULL.

Pass extra_len through and branch on it to keep the wire contract and the stored row in agreement.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-core/src/worker/remote.rs` around lines 986 - 1001, Update
handle_frame and apply_task_log to preserve the distinction between absent extra
data and an explicitly empty extra value: pass extra_len through instead of
discarding it, use it to determine None versus Some(""), and only decode the
payload when present. Keep the existing invalid UTF-8 warning and dropping
behavior for non-empty invalid blobs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/taskito-core/src/worker/remote.rs`:
- Around line 793-812: Update the dispatch flow around dispatch_to and its place
caller so disabled_middleware is not executed synchronously on the shared Tokio
runtime. Resolve the toggle list through the pump’s dedicated thread and cache
it in the dispatcher, or use tokio::task::spawn_blocking at the place call site,
then pass the resolved list to write_job_with while preserving the existing
behavior.

In `@crates/taskito-core/src/worker/side_channel.rs`:
- Around line 115-119: Validate the progress value in update_progress before
calling storage.update_progress, rejecting or clamping values outside the
documented 0..=100 range so attached SDK requests match in-process behavior.
Preserve the existing storage error warning for valid values.

---

Outside diff comments:
In `@crates/taskito-core/src/worker/executor.rs`:
- Around line 899-916: Update decline to release the job’s toggle entry before
sending its failure result by calling the existing Shared::forget_toggles
mechanism for the declined job. Preserve the current warning, failure payload,
and retry behavior while ensuring declined jobs with disable lists do not remain
in Shared::toggles.

---

Nitpick comments:
In `@crates/taskito-core/src/worker/remote.rs`:
- Around line 186-208: Implement Drop for Shared so dropping it calls
stop_side_channel, ensuring the SideChannelPump drain thread is stopped even
when RemoteDispatcher::run is never invoked. Reuse the existing idempotent
stop_side_channel method and its side_channel_drain handle rather than adding
separate teardown logic.
- Around line 986-1001: Update handle_frame and apply_task_log to preserve the
distinction between absent extra data and an explicitly empty extra value: pass
extra_len through instead of discarding it, use it to determine None versus
Some(""), and only decode the payload when present. Keep the existing invalid
UTF-8 warning and dropping behavior for non-empty invalid blobs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 55279bca-261c-4f87-9974-5a041b0c7590

📥 Commits

Reviewing files that changed from the base of the PR and between 89a1aa3 and a8a55db.

📒 Files selected for processing (11)
  • crates/taskito-core/src/lib.rs
  • crates/taskito-core/src/worker/executor.rs
  • crates/taskito-core/src/worker/mod.rs
  • crates/taskito-core/src/worker/protocol.rs
  • crates/taskito-core/src/worker/remote.rs
  • crates/taskito-core/src/worker/side_channel.rs
  • crates/taskito-core/tests/rust/executor_tests.rs
  • crates/taskito-core/tests/rust/remote_tests.rs
  • crates/taskito-python/src/prefork/child.rs
  • crates/taskito-server/src/runtime/mod.rs
  • crates/taskito-server/tests/attach_e2e.rs
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • ByteVeda/taskito (manual)

Comment thread crates/taskito-core/src/worker/remote.rs
Comment thread crates/taskito-core/src/worker/side_channel.rs
@stromanni

Copy link
Copy Markdown
Contributor Author

All five findings were valid against the current code and all five are fixed, one commit each.

Finding Commit Test
Toggle list resolved on the shared runtime 378054d resolving_toggles_does_not_stall_the_runtime_the_scheduler_shares
Out-of-range progress forwarded unchecked 1ce86f7 progress_outside_the_documented_range_is_dropped
Declined job leaks its toggle entry 5bade2c a_declined_job_releases_the_toggle_list_it_arrived_with
Drain thread outlives a dispatcher that never ran 9a2db69 dropping_a_dispatcher_that_never_ran_stops_the_drain_thread
extra_len discarded, Some("") collapsed to None 75c4f89 an_empty_extra_is_stored_as_empty_rather_than_absent

Toggle resolution. dispatch_to no longer resolves the list. place calls Shared::resolve_toggles, which hands the read to tokio::task::spawn_blocking and passes the result down. The pump's 5s cache means most dispatches answer from memory and never reach the blocking pool.

Declined jobs. decline now calls forget_toggles before framing the failure, so the toggles doc comment holds on that path too — it reports outside send_result, which is what normally releases the entry.

Drain thread. impl Drop for Shared calls stop_side_channel. It already takes the handle out of the mutex, so the call run makes on the way out and the one from Drop compose without a second teardown path.

Empty vs absent extra. handle_frame passes extra_len through as Option<Vec<u8>>; apply_task_log branches on presence rather than on emptiness, so Some("") stores "" and only a genuinely absent blob stores NULL. The invalid-UTF-8 warn-and-drop behaviour is unchanged.

The two runtime findings have regression tests that were checked to fail with the fix reverted — the starvation test panics with "a parked settings read starved the runtime", and the drain test reports a strong count of 2.

cargo test --workspace, cargo clippy --workspace --all-targets, cargo fmt --check, and cargo check under --features postgres and --features redis are clean, and each of the five commits compiles on its own.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants