-
Notifications
You must be signed in to change notification settings - Fork 1
feat(sdks): taskito executor subcommand for Python, Node and Java #595
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
kartikeya-27
merged 46 commits into
ByteVeda:master
from
stromanni:feat/546-executor-attach-sdks
Aug 1, 2026
Merged
Changes from all commits
Commits
Show all changes
46 commits
Select commit
Hold shift + click to select a range
15d8fe3
feat(core): invert the job and result frame mappings
stromanni 2695ca5
feat(core): executor-side attach client
stromanni f7bdfb3
feat(core): shared attach-address dialer
stromanni 25fb04d
feat(core): shared cancel source for dispatchers
stromanni ee8388c
feat(python): executor binding over the prefork pool
stromanni 0a76da4
feat(python): taskito executor subcommand
stromanni ad05c0f
test(python): cover the executor attach end to end
stromanni de17b86
feat(core): observable executor session
stromanni 7601ce3
refactor(node): extract the task callback
stromanni fc4ee55
refactor(node): share the CLI app loader
stromanni e7da425
feat(node): executor binding over the node dispatcher
stromanni 8db36f2
feat(node): runExecutor on the queue
stromanni 17dbfee
feat(node): taskito executor subcommand
stromanni f7c45fe
test(node): cover the executor attach end to end
stromanni af1594f
fix(core): end the session on a local drain
stromanni fecda3b
feat(java): emit discoverable handler providers
stromanni b00503d
feat(java): executor binding over the java dispatcher
stromanni ed0b6c0
feat(java): Executor over an attached scheduler
stromanni c7b286f
feat(java): executor subcommand
stromanni 4884feb
test(java): cover the executor attach end to end
stromanni 7009304
feat(python): executor opens no storage
stromanni 3b2eafc
test(python): cover an executor with no database
stromanni f11f5ac
feat(node): deliver frame cancels to the handler
stromanni d8956b2
feat(node): executor opens no storage
stromanni 4916a2a
test(node): cover an executor with no database
stromanni 52a8735
fix(core): resolve the cancel module doc link
stromanni 39126be
test(python): type the attach tests' stderr reads
stromanni d430e94
fix(core): dial every resolved scheduler address
stromanni 9112610
refactor(core): move the worker tests to tests/rust
stromanni 1668ed8
fix(core): drain locally before announcing to the peer
stromanni 607c483
fix(core): bound shutdown when a job never returns
stromanni 351aa51
fix(java): do not generate a provider for a nested handler
stromanni 40db7b6
fix(java): drop the drain hook on the normal exit path
stromanni f605ede
fix(java): validate --slots and report through the spec
stromanni 1934391
fix(java): release the lock before the session wait
stromanni 6892390
fix(java): close the handle if post-attach setup throws
stromanni fec80be
fix(node): handle a rejected stop in the signal path
stromanni 528573d
fix(node): release the attach if start-up throws
stromanni 0a3c6b2
feat(node): shutdown stops executors too
stromanni eb452aa
test(node): discard the server's output
stromanni fab6f68
style(java): wrap the service-loadable guard for spotless
stromanni bcb21c9
docs(core): note the job fields a dispatch frame drops
stromanni abdd44a
docs(python): state what can delay executor stop()
stromanni 6dad121
docs(java): describe the generated Provider correctly
stromanni 9f0aea2
fix(java): treat a blank attach token as absent
stromanni 42be90b
test(python): poll for the attach instead of sleeping
stromanni File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| //! How a dispatcher learns that a running job was cancelled. | ||
| //! | ||
| //! An in-process worker reads the storage flag `Storage::request_cancel` sets. | ||
| //! An attached executor has no storage — it is the whole point of #546 that it | ||
| //! carries no database credentials — and learns instead from the `cancel` frame | ||
| //! the scheduler sends, which arrives as | ||
| //! [`WorkerDispatcher::notify_cancel`](super::WorkerDispatcher::notify_cancel). | ||
| //! | ||
| //! Both sources answer one question, so both dispatchers ask it here rather | ||
| //! than growing two cancel paths each. | ||
|
|
||
| use std::collections::HashSet; | ||
| use std::sync::{Mutex, PoisonError}; | ||
|
|
||
| use crate::storage::{Storage, StorageBackend}; | ||
|
|
||
| /// The cancel sources available to one dispatcher. | ||
| pub struct CancelSignals { | ||
| /// Present only for a dispatcher running inside a worker. | ||
| storage: Option<StorageBackend>, | ||
| /// Ids delivered out of band. Kept until the job reports, so a cancel that | ||
| /// races a job's start still fires rather than being missed. | ||
| signalled: Mutex<HashSet<String>>, | ||
| } | ||
|
|
||
| impl CancelSignals { | ||
| /// Read cancels from storage, and from `notify_cancel` when it is called. | ||
| pub fn from_storage(storage: StorageBackend) -> Self { | ||
| Self { | ||
| storage: Some(storage), | ||
| signalled: Mutex::new(HashSet::new()), | ||
| } | ||
| } | ||
|
|
||
| /// Read cancels only from `notify_cancel` — the attached-executor case. | ||
| pub fn detached() -> Self { | ||
| Self { | ||
| storage: None, | ||
| signalled: Mutex::new(HashSet::new()), | ||
| } | ||
| } | ||
|
|
||
| /// Record a cancel request for `job_id`. | ||
| pub fn signal(&self, job_id: &str) { | ||
| self.lock().insert(job_id.to_string()); | ||
| } | ||
|
|
||
| /// Whether `job_id` has been cancelled. | ||
| /// | ||
| /// The out-of-band set is checked first: it needs no I/O, and for a | ||
| /// detached executor it is the only answer there is. | ||
| pub fn is_cancelled(&self, job_id: &str) -> bool { | ||
| if self.lock().contains(job_id) { | ||
| return true; | ||
| } | ||
| self.storage | ||
| .as_ref() | ||
| .is_some_and(|storage| storage.is_cancel_requested(job_id).unwrap_or(false)) | ||
| } | ||
|
|
||
| /// Drop the record for a finished job, so the set cannot grow for the life | ||
| /// of the process. | ||
| pub fn forget(&self, job_id: &str) { | ||
| self.lock().remove(job_id); | ||
| } | ||
|
|
||
| fn lock(&self) -> std::sync::MutexGuard<'_, HashSet<String>> { | ||
| // The state behind the lock is a plain set, so reading it stays safe | ||
| // even if a holder panicked. | ||
| self.signalled | ||
| .lock() | ||
| .unwrap_or_else(PoisonError::into_inner) | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn a_detached_signal_is_observed_without_storage() { | ||
| let signals = CancelSignals::detached(); | ||
| assert!(!signals.is_cancelled("job-1")); | ||
|
|
||
| signals.signal("job-1"); | ||
| assert!(signals.is_cancelled("job-1")); | ||
| } | ||
|
|
||
| #[test] | ||
| fn forgetting_a_job_clears_its_signal() { | ||
| // Ids are held until the job reports, so they have to be released or | ||
| // the set grows for the life of the process. | ||
| let signals = CancelSignals::detached(); | ||
| signals.signal("job-1"); | ||
| signals.forget("job-1"); | ||
| assert!(!signals.is_cancelled("job-1")); | ||
| } | ||
|
|
||
| #[test] | ||
| fn an_unknown_job_is_not_cancelled() { | ||
| let signals = CancelSignals::detached(); | ||
| signals.signal("job-1"); | ||
| assert!(!signals.is_cancelled("job-2")); | ||
| } | ||
|
|
||
| #[test] | ||
| fn a_storage_flag_is_honoured_too() { | ||
| use crate::job::{now_millis, NewJob}; | ||
| use crate::storage::sqlite::SqliteStorage; | ||
|
|
||
| let storage = StorageBackend::Sqlite(SqliteStorage::in_memory().expect("in-memory")); | ||
| let job = storage | ||
| .enqueue(NewJob { | ||
| queue: "default".to_string(), | ||
| task_name: "resize".to_string(), | ||
| payload: Vec::new(), | ||
| priority: 0, | ||
| scheduled_at: now_millis(), | ||
| max_retries: 0, | ||
| timeout_ms: 0, | ||
| unique_key: None, | ||
| metadata: None, | ||
| notes: None, | ||
| depends_on: vec![], | ||
| expires_at: None, | ||
| result_ttl_ms: None, | ||
| namespace: None, | ||
| }) | ||
| .expect("enqueue"); | ||
|
|
||
| // `request_cancel` only flags a *running* job — a pending one is | ||
| // cancelled outright — so the job has to be dequeued first. | ||
| let running = storage | ||
| .dequeue("default", now_millis() + 1_000, None) | ||
| .expect("dequeue") | ||
| .expect("the enqueued job"); | ||
| assert_eq!(running.id, job.id); | ||
|
|
||
| let signals = CancelSignals::from_storage(storage.clone()); | ||
| assert!(!signals.is_cancelled(&job.id)); | ||
|
|
||
| assert!(storage.request_cancel(&job.id).expect("request cancel")); | ||
| assert!(signals.is_cancelled(&job.id)); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,219 @@ | ||
| //! Dial the address an executor was pointed at. | ||
| //! | ||
| //! The listener parses the same grammar on the bind side | ||
| //! (`taskito-server`'s `config::listen`), so the two stay readable against each | ||
| //! other: whatever `TASKITO_LISTEN` accepts, `TASKITO_ATTACH` dials. Every SDK | ||
| //! shares this rather than reimplementing the grammar in its own language, | ||
| //! where `unix:` support would inevitably drift. | ||
|
|
||
| use std::io; | ||
| use std::net::{TcpStream, ToSocketAddrs}; | ||
| #[cfg(unix)] | ||
| use std::os::unix::net::UnixStream; | ||
| use std::time::Duration; | ||
|
|
||
| #[cfg(unix)] | ||
| use super::transport::UnixTransport; | ||
| use super::transport::{TcpTransport, Transport}; | ||
|
|
||
| /// Where an executor attaches. | ||
| #[derive(Debug, Clone, PartialEq, Eq)] | ||
| pub enum AttachAddress { | ||
| /// TCP, for a scheduler in another container or host. | ||
| Tcp(String), | ||
| /// Unix domain socket, the same-pod sidecar case. | ||
| #[cfg(unix)] | ||
| Unix(std::path::PathBuf), | ||
| } | ||
|
|
||
| impl std::fmt::Display for AttachAddress { | ||
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||
| match self { | ||
| Self::Tcp(target) => write!(f, "tcp://{target}"), | ||
| #[cfg(unix)] | ||
| Self::Unix(path) => write!(f, "unix:{}", path.display()), | ||
| } | ||
| } | ||
| } | ||
|
|
||
| impl AttachAddress { | ||
| /// Parse one attach spec: `unix:/path`, `tcp://host:port`, `host:port`, or | ||
| /// `:port`. | ||
| /// | ||
| /// A bare `:port` means loopback, matching the listener's reading of the | ||
| /// same ambiguous value. | ||
| pub fn parse(spec: &str) -> io::Result<Self> { | ||
| let spec = spec.trim(); | ||
| if spec.is_empty() { | ||
| return Err(invalid("an attach address must not be empty")); | ||
| } | ||
|
|
||
| if let Some(path) = spec.strip_prefix("unix:") { | ||
| #[cfg(unix)] | ||
| { | ||
| if path.is_empty() { | ||
| return Err(invalid( | ||
| "a unix attach address needs a socket path, e.g. unix:/run/taskito.sock", | ||
| )); | ||
| } | ||
| return Ok(Self::Unix(std::path::PathBuf::from(path))); | ||
| } | ||
| #[cfg(not(unix))] | ||
| { | ||
| let _ = path; | ||
| return Err(invalid( | ||
| "unix socket attach addresses are not supported on this platform", | ||
| )); | ||
| } | ||
| } | ||
|
|
||
| // The listener prints itself as `tcp://host:port`, so an operator who | ||
| // copies that line out of the logs must get a working address back. | ||
| let target = spec.strip_prefix("tcp://").unwrap_or(spec); | ||
| let target = match target.strip_prefix(':') { | ||
| Some(port) => format!("127.0.0.1:{port}"), | ||
| None => target.to_string(), | ||
| }; | ||
| if !target.contains(':') { | ||
| return Err(invalid(format!( | ||
| "'{spec}' has no port — an attach address looks like host:port or \ | ||
| unix:/run/taskito.sock" | ||
| ))); | ||
| } | ||
| Ok(Self::Tcp(target)) | ||
| } | ||
|
|
||
| /// Open a connection to this address. | ||
| /// | ||
| /// `timeout` bounds the TCP connect so an unreachable scheduler fails | ||
| /// promptly instead of sitting in the platform's default retry window, | ||
| /// which can be minutes. | ||
| pub fn connect(&self, timeout: Duration) -> io::Result<Box<dyn Transport>> { | ||
| match self { | ||
| Self::Tcp(target) => { | ||
| let addresses = target.to_socket_addrs().map_err(|error| { | ||
| invalid(format!("'{target}' is not a valid host:port: {error}")) | ||
| })?; | ||
| // Every resolved address is tried, not just the first: a | ||
| // dual-stack scheduler resolves to both an AAAA and an A | ||
| // record, and a host that cannot route one still reaches the | ||
| // other. The last failure is what gets reported. | ||
| let mut last_error = None; | ||
| for address in addresses { | ||
| match TcpStream::connect_timeout(&address, timeout) { | ||
| Ok(stream) => return Ok(Box::new(TcpTransport::new(stream)?)), | ||
| Err(error) => last_error = Some(error), | ||
| } | ||
| } | ||
| Err(last_error | ||
| .unwrap_or_else(|| invalid(format!("'{target}' resolved to no address")))) | ||
| } | ||
| #[cfg(unix)] | ||
| Self::Unix(path) => { | ||
| // No connect timeout exists for a Unix socket, and none is | ||
| // needed: the peer is on this host, so a connect either | ||
| // succeeds or fails at once. | ||
| let stream = UnixStream::connect(path)?; | ||
| Ok(Box::new(UnixTransport::new(stream))) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| fn invalid(message: impl Into<String>) -> io::Error { | ||
| io::Error::new(io::ErrorKind::InvalidInput, message.into()) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn a_host_and_port_parses_as_tcp() { | ||
| assert_eq!( | ||
| AttachAddress::parse("scheduler:7749").expect("parse"), | ||
| AttachAddress::Tcp("scheduler:7749".to_string()) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn the_tcp_scheme_the_listener_prints_is_accepted() { | ||
| // The listener logs `attach listener on tcp://127.0.0.1:7749`; pasting | ||
| // that back must work. | ||
| assert_eq!( | ||
| AttachAddress::parse("tcp://127.0.0.1:7749").expect("parse"), | ||
| AttachAddress::Tcp("127.0.0.1:7749".to_string()) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn a_bare_port_means_loopback() { | ||
| assert_eq!( | ||
| AttachAddress::parse(":7749").expect("parse"), | ||
| AttachAddress::Tcp("127.0.0.1:7749".to_string()) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn surrounding_whitespace_is_ignored() { | ||
| // Shell heredocs and Kubernetes manifests both leak trailing newlines. | ||
| assert_eq!( | ||
| AttachAddress::parse(" 127.0.0.1:7749\n").expect("parse"), | ||
| AttachAddress::Tcp("127.0.0.1:7749".to_string()) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn an_address_without_a_port_is_rejected_with_the_shape_it_wanted() { | ||
| let error = AttachAddress::parse("scheduler").expect_err("must be rejected"); | ||
| assert_eq!(error.kind(), io::ErrorKind::InvalidInput); | ||
| assert!( | ||
| error.to_string().contains("host:port"), | ||
| "the message must show the expected shape: {error}" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn an_empty_address_is_rejected() { | ||
| assert!(AttachAddress::parse("").is_err()); | ||
| assert!(AttachAddress::parse(" ").is_err()); | ||
| } | ||
|
|
||
| #[cfg(unix)] | ||
| #[test] | ||
| fn a_unix_path_parses_and_prints_back() { | ||
| let address = AttachAddress::parse("unix:/run/taskito.sock").expect("parse"); | ||
| assert_eq!( | ||
| address, | ||
| AttachAddress::Unix(std::path::PathBuf::from("/run/taskito.sock")) | ||
| ); | ||
| assert_eq!(address.to_string(), "unix:/run/taskito.sock"); | ||
| } | ||
|
|
||
| #[cfg(unix)] | ||
| #[test] | ||
| fn a_unix_scheme_without_a_path_is_rejected() { | ||
| let error = AttachAddress::parse("unix:").expect_err("must be rejected"); | ||
| assert!(error.to_string().contains("socket path"), "{error}"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn connecting_to_a_closed_port_fails_rather_than_hanging() { | ||
| // Port 1 on loopback: reserved, and nothing listens there. | ||
| let address = AttachAddress::parse("127.0.0.1:1").expect("parse"); | ||
| assert!(address.connect(Duration::from_millis(500)).is_err()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn a_dialed_address_round_trips_through_a_real_listener() { | ||
| let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); | ||
| let port = listener.local_addr().expect("addr").port(); | ||
| let accepting = std::thread::spawn(move || listener.accept().expect("accept")); | ||
|
|
||
| let address = AttachAddress::parse(&format!(":{port}")).expect("parse"); | ||
| let transport = address.connect(Duration::from_secs(5)).expect("connect"); | ||
| assert!(transport.peer().starts_with("tcp:")); | ||
|
|
||
| let _ = accepting.join(); | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.