Skip to main content

somatize_worker/
worker.rs

1//! Worker — receives and executes plans from a coordinator.
2
3use crate::error::{Result, WorkerError};
4use crate::protocol::*;
5use somatize_core::cache::CacheStore;
6use somatize_core::event::Event;
7use somatize_core::filter::Filter;
8use somatize_core::store::{DataStore, LocalDataStore};
9use somatize_core::value::Value;
10use somatize_runtime::{EventBus, MemoryCache, NodeCatalog, Runner};
11use std::collections::HashMap;
12use std::sync::Arc;
13use std::time::Instant;
14
15/// Worker state: manages execution of plans received from a coordinator.
16pub struct Worker {
17    /// The identity this worker registers and reports under.
18    pub id: WorkerId,
19    /// What this worker can run, announced to the coordinator at
20    /// registration.
21    pub capabilities: Capabilities,
22    event_bus: Arc<EventBus>,
23    cache: Arc<dyn CacheStore>,
24    catalog: NodeCatalog,
25    /// Optional persistent DataStore (S3, Zarr, etc.) — configured by user.
26    data_store: Option<Arc<dyn DataStore>>,
27    /// Temporary local store for HTTP bulk uploads — auto-created, auto-cleaned.
28    temp_store: Arc<LocalDataStore>,
29    /// Environment manager for creating venvs with filter dependencies.
30    env_manager: crate::env_manager::EnvManager,
31    /// Which interpreter to unpickle filters in, when no venv is needed.
32    ///
33    /// A cloudpickled filter can only be reconstructed by an interpreter
34    /// close enough to the one that pickled it. Defaulting to `python3`
35    /// off `PATH` means the worker will happily pick a different minor
36    /// version from the process that sent the work, and cloudpickle then
37    /// returns the class's `__dict__` instead of an instance — which
38    /// surfaces as `'dict' object is not callable`, from inside a
39    /// subprocess, with nothing pointing at the version gap.
40    python: String,
41}
42
43/// `$SOMA_PYTHON`, else `python3` off `PATH`.
44///
45/// The env var exists because a worker started from a shell has no other
46/// way to be told, and `python3` is frequently not the interpreter whose
47/// pickles it will be asked to read.
48fn default_python() -> String {
49    std::env::var("SOMA_PYTHON")
50        .ok()
51        .filter(|p| !p.is_empty())
52        .unwrap_or_else(|| "python3".to_string())
53}
54
55impl Worker {
56    /// A worker with an in-memory cache, an empty catalog, and per-worker
57    /// temp/env directories derived from `id`. Filters arrive later, with
58    /// the plans; the interpreter defaults to `$SOMA_PYTHON`, then
59    /// `python3` — see [`Worker::with_python`] for why that matters.
60    pub fn new(id: impl Into<String>, capabilities: Capabilities) -> Self {
61        let worker_id: String = id.into();
62        let temp_path = std::env::temp_dir().join(format!("soma-uploads-{worker_id}"));
63        let temp_store = LocalDataStore::new(temp_path);
64        let env_path = std::env::temp_dir().join(format!("soma-envs-{worker_id}"));
65        Self {
66            id: worker_id,
67            capabilities,
68            event_bus: Arc::new(EventBus::new(256)),
69            cache: Arc::new(MemoryCache::default()),
70            catalog: NodeCatalog::new(),
71            data_store: None,
72            temp_store: Arc::new(temp_store),
73            env_manager: crate::env_manager::EnvManager::new(
74                env_path,
75                crate::env_manager::EnvType::Venv,
76            ),
77            python: default_python(),
78        }
79    }
80
81    /// Run filters in this interpreter rather than whatever `python3`
82    /// resolves to.
83    ///
84    /// An embedding process should pass its own `sys.executable`: it is
85    /// the interpreter that pickled the filters, so it is the only one
86    /// certain to unpickle them.
87    pub fn with_python(mut self, python: impl Into<String>) -> Self {
88        self.python = python.into();
89        self
90    }
91
92    /// Set a custom cache store (e.g. tiered or shared).
93    pub fn with_cache(mut self, cache: Arc<dyn CacheStore>) -> Self {
94        self.cache = cache;
95        self
96    }
97
98    /// Set a persistent DataStore (S3, Zarr, etc.) for large data references.
99    pub fn with_data_store(mut self, store: Arc<dyn DataStore>) -> Self {
100        self.data_store = Some(store);
101        self
102    }
103
104    /// Set a custom temp directory for HTTP bulk uploads.
105    pub fn with_temp_dir(mut self, path: std::path::PathBuf) -> Self {
106        self.temp_store = Arc::new(LocalDataStore::new(path));
107        self
108    }
109
110    /// Get the temp store (for HTTP upload endpoint).
111    pub fn temp_store(&self) -> &Arc<LocalDataStore> {
112        &self.temp_store
113    }
114
115    /// Register a filter that this worker can execute.
116    pub fn register_filter(&mut self, node_id: impl Into<String>, filter: Box<dyn Filter>) {
117        self.catalog.register(node_id, filter);
118    }
119
120    /// Get a filter by node_id.
121    pub fn get_filter(&self, node_id: &str) -> Option<Arc<dyn Filter>> {
122        self.catalog.get(node_id)
123    }
124
125    /// The node catalog — what a stream driver is built over.
126    pub fn catalog(&self) -> &NodeCatalog {
127        &self.catalog
128    }
129
130    /// The worker's event bus.
131    pub fn event_bus(&self) -> &Arc<EventBus> {
132        &self.event_bus
133    }
134
135    /// The worker's cache store.
136    pub fn cache(&self) -> &Arc<dyn CacheStore> {
137        &self.cache
138    }
139
140    /// Get trained state for a filter.
141    pub fn get_filter_state(&self, node_id: &str) -> Arc<Value> {
142        self.catalog
143            .get_state(node_id)
144            .unwrap_or_else(|| Arc::new(Value::Empty))
145    }
146
147    /// Reach the live Python process behind a node, if it has one.
148    ///
149    /// Every per-node operation that has to talk to the subprocess goes
150    /// through here rather than repeating the downcast.
151    fn subprocess_for(
152        &self,
153        node_id: &str,
154    ) -> Option<Arc<std::sync::Mutex<crate::python_process::PythonProcess>>> {
155        let filter = self.catalog.get(node_id)?;
156        let sf = filter
157            .as_any()
158            .downcast_ref::<crate::python_process::SubprocessFilter>()?;
159        Some(sf.process.clone())
160    }
161
162    /// Trained state of one or more nodes, read from the Python process.
163    ///
164    /// The four methods below back the wire messages of the same names.
165    /// They existed on `PythonProcess` and in the daemon script from the
166    /// start; what was missing was anything calling them, so
167    /// `soma-worker/src/server.rs` answered all four with "not implemented
168    /// for SubprocessFilter" and `DataParallel` could not run.
169    pub fn read_states(&self, node_ids: &[String]) -> Result<HashMap<String, Value>> {
170        let mut out = HashMap::new();
171        for node_id in node_ids {
172            let Some(proc) = self.subprocess_for(node_id) else {
173                // A Rust filter keeps its state in the catalog.
174                out.insert(node_id.clone(), (*self.get_filter_state(node_id)).clone());
175                continue;
176            };
177            let mut guard = proc
178                .lock()
179                .map_err(|e| WorkerError::Concurrency(format!("process mutex poisoned: {e}")))?;
180            out.insert(node_id.clone(), guard.get_state(node_id)?);
181        }
182        Ok(out)
183    }
184
185    /// Load states into the Python process (and the catalog beside it).
186    pub fn write_states(&mut self, states: &HashMap<String, Value>) -> Result<()> {
187        for (node_id, state) in states {
188            if let Some(proc) = self.subprocess_for(node_id) {
189                let mut guard = proc.lock().map_err(|e| {
190                    WorkerError::Concurrency(format!("process mutex poisoned: {e}"))
191                })?;
192                guard.set_state(node_id, state)?;
193            }
194            self.set_filter_state(node_id, state.clone());
195        }
196        Ok(())
197    }
198
199    /// Gradients currently held by each node's parameters.
200    pub fn read_gradients(&self, node_ids: &[String]) -> Result<HashMap<String, Value>> {
201        let mut out = HashMap::new();
202        for node_id in node_ids {
203            let proc = self.subprocess_for(node_id).ok_or_else(|| {
204                WorkerError::Env(format!(
205                    "`{node_id}` has no Python process, so it has no gradients to read"
206                ))
207            })?;
208            let mut guard = proc
209                .lock()
210                .map_err(|e| WorkerError::Concurrency(format!("process mutex poisoned: {e}")))?;
211            out.insert(node_id.clone(), guard.get_gradients(node_id)?);
212        }
213        Ok(out)
214    }
215
216    /// Apply aggregated gradients to each node's parameters.
217    pub fn write_gradients(&self, gradients: &HashMap<String, Value>) -> Result<()> {
218        for (node_id, grads) in gradients {
219            let proc = self.subprocess_for(node_id).ok_or_else(|| {
220                WorkerError::Env(format!(
221                    "`{node_id}` has no Python process, so gradients cannot be applied"
222                ))
223            })?;
224            let mut guard = proc
225                .lock()
226                .map_err(|e| WorkerError::Concurrency(format!("process mutex poisoned: {e}")))?;
227            guard.apply_gradients(node_id, grads)?;
228        }
229        Ok(())
230    }
231
232    /// Set trained state for a filter.
233    pub fn set_filter_state(&mut self, node_id: &str, state: Value) {
234        if let Err(e) = self.catalog.try_set_state(node_id, state) {
235            tracing::error!(node_id, "storing filter state failed: {e}");
236        }
237    }
238
239    /// Wrap output in the right delivery: inline for small, DataRef for large.
240    pub fn wrap_output(&self, output: Value) -> OutputDelivery {
241        let size = serde_json::to_vec(&output).map(|v| v.len()).unwrap_or(0);
242        if size >= somatize_core::store::INLINE_THRESHOLD_BYTES {
243            let key = somatize_core::cache::CacheKey::hash_data(
244                &serde_json::to_vec(&output).unwrap_or_default(),
245            );
246            if let Ok(data_ref) = self.temp_store.put(&key, &output) {
247                return OutputDelivery::Reference { data_ref };
248            }
249        }
250        OutputDelivery::Inline { value: output }
251    }
252
253    /// Subscribe to execution events.
254    pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<Event> {
255        self.event_bus.subscribe()
256    }
257
258    /// Build a registration message.
259    pub fn registration_message(&self) -> WorkerToCoordinator {
260        WorkerToCoordinator::Register {
261            worker_id: self.id.clone(),
262            capabilities: self.capabilities.clone(),
263        }
264    }
265
266    /// Execute a serialized plan.
267    ///
268    /// If the plan contains serialized filter definitions, they are registered
269    /// temporarily for this execution (alongside any pre-registered filters).
270    ///
271    /// In **Fit** mode: fits each filter (topological order), stores trained states,
272    /// then forwards to propagate outputs. Returns states so the client can cache them.
273    ///
274    /// In **Forward** mode: executes the compiled plan directly.
275    pub fn execute_plan(&mut self, plan: &SerializedPlan) -> PlanResult {
276        let start = Instant::now();
277
278        // Before anything else. A plan this build only partly understands
279        // must be refused, not executed with the parts it recognised.
280        if let Err(message) = plan.check_version() {
281            tracing::error!("{message}");
282            return PlanResult::Failed {
283                error: message,
284                duration_ms: start.elapsed().as_millis() as u64,
285            };
286        }
287
288        let _span = tracing::info_span!(
289            "execute_plan",
290            plan_id = %plan.plan_id,
291            n_filters = plan.filters.len(),
292            mode = ?plan.mode,
293        )
294        .entered();
295
296        tracing::info!(
297            "Plan received: {} filters, mode={:?}",
298            plan.filters.len(),
299            plan.mode
300        );
301
302        // Collect all requirements from serialized filters
303        let all_reqs: Vec<String> = plan
304            .filters
305            .iter()
306            .flat_map(|sf| sf.requirements.iter().cloned())
307            .collect::<std::collections::HashSet<_>>()
308            .into_iter()
309            .collect();
310
311        // Create/reuse venv if there are pip requirements, otherwise use system python
312        let python_path = if all_reqs.is_empty() {
313            self.python.clone()
314        } else {
315            let reqs_str = all_reqs.join("\n");
316            // Keyed by the requirements, not by the plan. A plan id is a
317            // fresh timestamp, so keying on it meant every plan built its
318            // own venv and pip-installed into it — never reusing anything,
319            // and never cleaning up. Plans that need the same packages now
320            // share one environment, which is what the lockfile inside it
321            // was already written to support.
322            let env_id = crate::env_manager::EnvManager::env_id_for(&reqs_str);
323            match self.env_manager.ensure_env(&env_id, &reqs_str) {
324                Ok(path) => {
325                    tracing::info!("Using venv {env_id} for plan {}: {:?}", plan.plan_id, path);
326                    path.to_string_lossy().to_string()
327                }
328                Err(e) => {
329                    tracing::warn!("Failed to create venv, falling back to system python: {e}");
330                    self.python.clone()
331                }
332            }
333        };
334
335        // No site-packages resolution needed — subprocess uses the venv python directly
336
337        // Spawn ONE Python subprocess for all filters in this plan.
338        // All filters share the same process (needed for Composite autograd).
339        let filter_specs: Vec<(String, Vec<u8>, bool)> = plan
340            .filters
341            .iter()
342            .map(|sf| (sf.node_id.clone(), sf.pickled_filter.clone(), sf.trainable))
343            .collect();
344
345        if !filter_specs.is_empty() {
346            let filter_names: Vec<&str> =
347                plan.filters.iter().map(|sf| sf.node_id.as_str()).collect();
348            tracing::info!(
349                python = %python_path,
350                filters = ?filter_names,
351                "Spawning Python process for {} filters",
352                filter_specs.len()
353            );
354
355            let proc = crate::python_process::PythonProcess::spawn(&python_path, &filter_specs)
356                .map_err(|e| {
357                    // Not `.expect`: this runs on a tokio worker thread, so
358                    // a panic here took the whole worker down — every other
359                    // pipeline it was holding with it — because one plan
360                    // named an interpreter that would not start.
361                    tracing::error!(python = %python_path, "failed to spawn Python: {e}");
362                    e
363                });
364            let mut proc = match proc {
365                Ok(p) => p,
366                Err(e) => {
367                    return PlanResult::Failed {
368                        error: format!("could not start `{python_path}`: {e}"),
369                        duration_ms: start.elapsed().as_millis() as u64,
370                    };
371                }
372            };
373
374            // Load trained states from previous epochs (SET_STATE)
375            for sf in &plan.filters {
376                if let Some(state) = &sf.state {
377                    let size = match state {
378                        Value::Bytes(b) => b.len(),
379                        _ => 0,
380                    };
381                    tracing::info!(
382                        node_id = %sf.node_id,
383                        size_bytes = size,
384                        "Loading trained state from previous epoch"
385                    );
386                    if let Err(e) = proc.set_state(&sf.node_id, state) {
387                        tracing::warn!(
388                            node_id = %sf.node_id,
389                            error = %e,
390                            "Failed to load state (will use fresh weights)"
391                        );
392                    }
393                }
394            }
395
396            let process = Arc::new(std::sync::Mutex::new(proc));
397
398            for sf in &plan.filters {
399                let config_hash = sf.config_hash.clone().unwrap_or_else(|| {
400                    crate::python_process::SubprocessFilter::fallback_config_hash(
401                        &sf.node_id,
402                        &sf.pickled_filter,
403                    )
404                });
405                let filter = Box::new(crate::python_process::SubprocessFilter::new(
406                    process.clone(),
407                    sf.node_id.clone(),
408                    sf.trainable,
409                    config_hash,
410                ));
411                self.catalog.register(&sf.node_id, filter);
412                if let Some(state) = &sf.state
413                    && let Err(e) = self.catalog.try_set_state(&sf.node_id, state.clone())
414                {
415                    tracing::error!(node_id = %sf.node_id, "storing filter state failed: {e}");
416                }
417            }
418
419            tracing::info!("Filters registered, Python process ready");
420        }
421
422        // Resolve input via InputSource::resolve(). A reference that
423        // resolves nowhere fails HERE, naming what it looked in — it used
424        // to become an empty value and travel on into the filter, where it
425        // surfaced as a TypeError in the user's own code.
426        let input_value = match plan
427            .input
428            .as_ref()
429            .map(|src| src.resolve(self.data_store.as_deref(), &self.temp_store))
430            .transpose()
431        {
432            Ok(value) => value,
433            Err(e) => {
434                return PlanResult::Failed {
435                    error: e.to_string(),
436                    duration_ms: start.elapsed().as_millis() as u64,
437                };
438            }
439        };
440
441        // DataStore-backed streaming: if input is a large DataRef and we
442        // have a store, read chunks via get_rows() and stream them (no
443        // full materialization).
444        //
445        // FORWARD ONLY, and the mode check is the whole point. This branch
446        // used to be taken before `plan.mode` was ever looked at, so a Fit
447        // over a large reference was silently executed as a stream of
448        // forwards: nothing was fitted, no state was stored, and the fit
449        // reported success. The next forward then found no state and died
450        // inside the user's filter — while the same graph under the
451        // 1024-row threshold fitted normally and gave the right answer.
452        // A stream has no fit semantics (`compile_stream` refuses one
453        // locally, for the same reason), so a Fit falls through to the
454        // path that can honour it.
455        if matches!(plan.mode, ExecutionMode::Forward)
456            && let Some(InputSource::Reference { data_ref }) = &plan.input
457            && let Some(store) = self.data_store.clone()
458            && let Ok(meta) = store.meta(data_ref)
459            && meta.total_rows > 1024
460        {
461            return self.execute_streamed_from_store(plan, &store, data_ref, &meta, start);
462        }
463
464        // Delegate to LocalRunner (same execution path as local)
465        let runner = somatize_runtime::LocalRunner;
466        let x = input_value.unwrap_or(Value::Empty);
467
468        let result = match &plan.mode {
469            ExecutionMode::Fit { y, batch_size } => {
470                // If batch_size is set, use BATCHED_FIT on the subprocess directly
471                if let Some(bs) = batch_size {
472                    tracing::info!(batch_size = bs, "Using batched fit");
473                    let node_ids = plan
474                        .plan
475                        .node_ids()
476                        .iter()
477                        .map(|s| s.to_string())
478                        .collect::<Vec<_>>();
479                    if let Some(filter) = self.catalog.get(&node_ids[0]) {
480                        if let Some(sf) = filter
481                            .as_any()
482                            .downcast_ref::<crate::python_process::SubprocessFilter>()
483                        {
484                            let result = sf
485                                .process
486                                .lock()
487                                .map_err(|e| {
488                                    WorkerError::Concurrency(format!("process mutex poisoned: {e}"))
489                                })
490                                .and_then(|mut proc| {
491                                    proc.batched_fit(&node_ids, &x, y.as_ref(), *bs)
492                                });
493                            match result {
494                                Ok((output, states)) => {
495                                    for (id, state) in &states {
496                                        if let Err(e) =
497                                            self.catalog.try_set_state(id, state.clone())
498                                        {
499                                            tracing::error!(
500                                                node_id = %id,
501                                                "storing filter state failed: {e}"
502                                            );
503                                        }
504                                    }
505                                    Ok((output, states))
506                                }
507                                Err(e) => Err(e.into()),
508                            }
509                        } else {
510                            Err(somatize_core::error::SomaError::Other(
511                                "batched_fit requires SubprocessFilter".into(),
512                            ))
513                        }
514                    } else {
515                        Err(somatize_core::error::SomaError::Other(
516                            "no filters found".into(),
517                        ))
518                    }
519                } else {
520                    let run_id = format!("worker_fit_{}", plan.plan_id);
521                    // `linear`, explicitly: a worker receives a serialized
522                    // plan and no graph, so it has no topology to consult.
523                    // Correct for the pipelines that get dispatched, and
524                    // stated here rather than assumed inside the runner.
525                    let mut ctx = somatize_runtime::runner::RunContext::linear(
526                        &self.catalog,
527                        self.cache.as_ref(),
528                        &self.event_bus,
529                        &run_id,
530                        &plan.plan,
531                    );
532                    ctx.seed = plan.seed;
533                    runner
534                        .fit(&plan.plan, &ctx, &x, y.as_ref())
535                        .map(|(output, all_outputs)| {
536                            // Extract trained states (prefixed __state_) and store in library
537                            let mut trained_states = std::collections::HashMap::new();
538                            for (key, value) in &all_outputs {
539                                if let Some(node_id) = somatize_core::keys::node_of_state_key(key) {
540                                    if let Err(e) =
541                                        self.catalog.try_set_state(node_id, value.clone())
542                                    {
543                                        tracing::error!(
544                                            node_id,
545                                            "storing filter state failed: {e}"
546                                        );
547                                    }
548                                    trained_states.insert(node_id.to_string(), value.clone());
549                                }
550                            }
551                            (output, trained_states)
552                        })
553                }
554            }
555            ExecutionMode::Forward => {
556                let run_id = format!("worker_forward_{}", plan.plan_id);
557                let mut ctx = somatize_runtime::runner::RunContext::linear(
558                    &self.catalog,
559                    self.cache.as_ref(),
560                    &self.event_bus,
561                    &run_id,
562                    &plan.plan,
563                );
564                ctx.seed = plan.seed;
565                runner
566                    .forward(&plan.plan, &ctx, &x)
567                    .map(|output| (output, std::collections::HashMap::new()))
568            }
569        };
570
571        let elapsed = start.elapsed().as_millis() as u64;
572        match result {
573            Ok((output, states)) => {
574                tracing::info!(
575                    duration_ms = elapsed,
576                    n_states = states.len(),
577                    "Plan completed successfully"
578                );
579                PlanResult::Success {
580                    output: self.wrap_output(output),
581                    duration_ms: elapsed,
582                    states,
583                }
584            }
585            Err(e) => {
586                tracing::error!(duration_ms = elapsed, error = %e, "Plan failed");
587                PlanResult::Failed {
588                    error: e.to_string(),
589                    duration_ms: elapsed,
590                }
591            }
592        }
593    }
594
595    /// DataStore-backed streaming: read chunks via get_rows() and drive
596    /// them through the runtime's `StreamRun` — the same primitives,
597    /// cache, and per-node events as a local stream, without loading the
598    /// dataset into memory. The concatenated output is the plan result.
599    fn execute_streamed_from_store(
600        &mut self,
601        plan: &SerializedPlan,
602        store: &Arc<dyn DataStore>,
603        data_ref: &somatize_core::store::DataRef,
604        meta: &somatize_core::store::StoreMeta,
605        start: Instant,
606    ) -> PlanResult {
607        use somatize_runtime::{Context, StreamOutput, StreamRun};
608
609        /// Rows per chunk when auto-streaming from a DataStore — also the
610        /// threshold that triggers this path (see `total_rows > 1024`).
611        const STREAM_CHUNK_ROWS: usize = 1024;
612
613        let node_ids: Vec<String> = plan.plan.node_ids().into_iter().map(String::from).collect();
614
615        // StreamRun refuses a node the catalog does not know — a failed
616        // plan, never a silently shorter chain (a `filter_map` here once
617        // streamed a 3-node plan through 2 filters and reported success).
618        let mut run = match StreamRun::new(&node_ids, &self.catalog) {
619            Ok(run) => run,
620            Err(e) => {
621                return PlanResult::Failed {
622                    error: e.to_string(),
623                    duration_ms: start.elapsed().as_millis() as u64,
624                };
625            }
626        };
627
628        let chunk_size = STREAM_CHUNK_ROWS;
629        let run_id = format!("worker_stream_{}", plan.plan_id);
630        let mut ctx = Context::new(self.event_bus.clone(), run_id.clone()).with_seed(plan.seed);
631
632        self.event_bus.emit(Event::RunStarted {
633            run_id: run_id.clone(),
634            plan_summary: somatize_core::event::PlanSummary {
635                total_nodes: node_ids.len(),
636                cached_nodes: 0,
637                parallel_branches: 0,
638            },
639        });
640        // Every early return below is a failed run; say so on the bus
641        // instead of leaving the RunStarted bracket open.
642        let fail = |bus: &EventBus, error: String, start: Instant| {
643            bus.emit(Event::RunFailed {
644                run_id: run_id.clone(),
645                error: error.clone(),
646            });
647            PlanResult::Failed {
648                error,
649                duration_ms: start.elapsed().as_millis() as u64,
650            }
651        };
652
653        let mut output = StreamOutput::new();
654        let total = meta.total_rows;
655        let mut chunk_idx = 0;
656
657        for row_start in (0..total).step_by(chunk_size) {
658            let len = chunk_size.min(total - row_start);
659            let chunk = match store.get_rows(data_ref, row_start, len) {
660                Ok(c) => c,
661                Err(e) => {
662                    let error = format!("get_rows({row_start}..{}): {e}", row_start + len);
663                    return fail(&self.event_bus, error, start);
664                }
665            };
666
667            match run.process_chunk(chunk, &mut ctx, self.cache.as_ref()) {
668                Ok(Some(out)) => output.push(out),
669                Ok(None) => {} // Barrier — accumulating
670                Err(e) => {
671                    return fail(
672                        &self.event_bus,
673                        format!("stream chunk {chunk_idx}: {e}"),
674                        start,
675                    );
676                }
677            }
678            chunk_idx += 1;
679        }
680
681        // Flush barrier filters.
682        match run.flush(&mut ctx, self.cache.as_ref()) {
683            Ok(Some(out)) => output.push(out),
684            Ok(None) => {}
685            Err(e) => {
686                return fail(&self.event_bus, format!("stream flush: {e}"), start);
687            }
688        }
689        run.finish(&ctx);
690
691        tracing::info!(
692            "Streamed {chunk_idx} chunks ({total} rows) in {}ms",
693            start.elapsed().as_millis()
694        );
695
696        self.event_bus.emit(Event::RunCompleted {
697            run_id,
698            duration: start.elapsed(),
699        });
700        PlanResult::Success {
701            output: self.wrap_output(output.finish()),
702            duration_ms: start.elapsed().as_millis() as u64,
703            states: std::collections::HashMap::new(),
704        }
705    }
706
707    /// Check if this worker matches a remote target.
708    pub fn matches_target(&self, target: &somatize_core::filter::RemoteTarget) -> bool {
709        match target {
710            somatize_core::filter::RemoteTarget::WorkerId(id) => &self.id == id,
711            somatize_core::filter::RemoteTarget::Tag(tag) => self.capabilities.tags.contains(tag),
712        }
713    }
714}
715
716#[cfg(test)]
717mod tests {
718    use super::*;
719    use somatize_compiler::ExecutionPlan;
720    use somatize_core::cache::CacheKey;
721    use somatize_core::error::Result as SomaResult;
722    use somatize_core::filter::{FilterKind, FilterMeta, StreamMode};
723    use somatize_core::value::Value;
724
725    struct TestDoubler;
726
727    impl Filter for TestDoubler {
728        fn config_hash(&self) -> CacheKey {
729            CacheKey::from_parts(&[b"TestDoubler"])
730        }
731        fn fit(&self, _x: &Value, _y: Option<&Value>) -> SomaResult<Value> {
732            Ok(Value::Empty)
733        }
734        fn forward(&self, x: &Value, _state: &Value) -> SomaResult<Value> {
735            match x {
736                Value::Tensor { values, shape } => {
737                    let doubled: Vec<f64> = values.iter().map(|v| v * 2.0).collect();
738                    Ok(Value::tensor(doubled, shape.clone()))
739                }
740                _ => Ok(x.clone()),
741            }
742        }
743        fn meta(&self) -> FilterMeta {
744            FilterMeta {
745                name: "TestDoubler".into(),
746                kind: FilterKind::Stateless,
747                cacheable: true,
748                differentiable: true,
749                deterministic: true,
750                stream_mode: StreamMode::FixedState,
751                distribution: somatize_core::filter::Distribution::Local,
752                input_schema: None,
753                output_schema: None,
754            }
755        }
756    }
757
758    fn make_worker() -> Worker {
759        Worker::new(
760            "test_worker",
761            Capabilities {
762                cpu_cores: 4,
763                ram_bytes: 8_000_000_000,
764                gpus: vec![],
765                python_envs: vec![],
766                tags: vec!["cpu".into(), "test".into()],
767            },
768        )
769    }
770
771    #[test]
772    fn worker_registration() {
773        let worker = make_worker();
774        let msg = worker.registration_message();
775        if let WorkerToCoordinator::Register {
776            worker_id,
777            capabilities,
778        } = msg
779        {
780            assert_eq!(worker_id, "test_worker");
781            assert_eq!(capabilities.cpu_cores, 4);
782        } else {
783            panic!("wrong message type");
784        }
785    }
786
787    #[test]
788    fn worker_executes_plan_successfully() {
789        let mut worker = make_worker();
790        worker.register_filter("doubler", Box::new(TestDoubler));
791
792        let plan = SerializedPlan {
793            protocol_version: PROTOCOL_VERSION,
794            plan_id: "p_001".into(),
795            plan: ExecutionPlan::Execute {
796                node_id: "doubler".into(),
797            },
798            input: Some(crate::protocol::InputSource::Inline {
799                value: Value::tensor(vec![1.0, 2.0, 3.0], vec![3]),
800            }),
801            filters: vec![],
802            mode: ExecutionMode::default(),
803            seed: None,
804            metadata: serde_json::json!({}),
805        };
806
807        let result = worker.execute_plan(&plan);
808
809        if let PlanResult::Success {
810            output,
811            duration_ms,
812            ..
813        } = result
814        {
815            let value = match output {
816                OutputDelivery::Inline { value } => value,
817                _ => panic!("expected inline output"),
818            };
819            let (data, _) = value.as_tensor().unwrap();
820            assert_eq!(data, &[2.0, 4.0, 6.0]);
821            assert!(duration_ms < 1000);
822        } else {
823            panic!("expected success, got: {result:?}");
824        }
825    }
826
827    #[test]
828    fn worker_handles_missing_filter() {
829        let mut worker = make_worker();
830        // Don't register any filters
831
832        let plan = SerializedPlan {
833            protocol_version: PROTOCOL_VERSION,
834            plan_id: "p_002".into(),
835            plan: ExecutionPlan::Execute {
836                node_id: "nonexistent".into(),
837            },
838            input: None,
839            filters: vec![],
840            mode: ExecutionMode::default(),
841            seed: None,
842            metadata: serde_json::json!({}),
843        };
844
845        let result = worker.execute_plan(&plan);
846        assert!(matches!(result, PlanResult::Failed { .. }));
847    }
848
849    #[test]
850    fn worker_matches_target_by_id() {
851        let worker = make_worker();
852        assert!(
853            worker.matches_target(&somatize_core::filter::RemoteTarget::WorkerId(
854                "test_worker".into()
855            ))
856        );
857        assert!(
858            !worker.matches_target(&somatize_core::filter::RemoteTarget::WorkerId(
859                "other".into()
860            ))
861        );
862    }
863
864    #[test]
865    fn worker_matches_target_by_tag() {
866        let worker = make_worker();
867        assert!(worker.matches_target(&somatize_core::filter::RemoteTarget::Tag("cpu".into())));
868        assert!(worker.matches_target(&somatize_core::filter::RemoteTarget::Tag("test".into())));
869        assert!(!worker.matches_target(&somatize_core::filter::RemoteTarget::Tag("gpu".into())));
870    }
871
872    #[test]
873    fn worker_executes_sequence() {
874        let mut worker = make_worker();
875        worker.register_filter("d1", Box::new(TestDoubler));
876        worker.register_filter("d2", Box::new(TestDoubler));
877
878        let plan = SerializedPlan {
879            protocol_version: PROTOCOL_VERSION,
880            plan_id: "p_003".into(),
881            plan: ExecutionPlan::Sequence(vec![
882                ExecutionPlan::Execute {
883                    node_id: "d1".into(),
884                },
885                ExecutionPlan::Execute {
886                    node_id: "d2".into(),
887                },
888            ]),
889            input: Some(crate::protocol::InputSource::Inline {
890                value: Value::tensor(vec![5.0], vec![1]),
891            }),
892            filters: vec![],
893            mode: ExecutionMode::default(),
894            seed: None,
895            metadata: serde_json::json!({}),
896        };
897
898        let result = worker.execute_plan(&plan);
899        if let PlanResult::Success { output, .. } = result {
900            let value = match output {
901                OutputDelivery::Inline { value } => value,
902                _ => panic!("expected inline output"),
903            };
904            let (data, _) = value.as_tensor().unwrap();
905            assert_eq!(data, &[20.0]); // 5 * 2 * 2
906        } else {
907            panic!("expected success");
908        }
909    }
910
911    #[test]
912    fn worker_emits_events() {
913        let mut worker = make_worker();
914        worker.register_filter("doubler", Box::new(TestDoubler));
915        let mut rx = worker.subscribe();
916
917        let plan = SerializedPlan {
918            protocol_version: PROTOCOL_VERSION,
919            plan_id: "p_004".into(),
920            plan: ExecutionPlan::Execute {
921                node_id: "doubler".into(),
922            },
923            input: Some(crate::protocol::InputSource::Inline {
924                value: Value::tensor(vec![1.0], vec![1]),
925            }),
926            filters: vec![],
927            mode: ExecutionMode::default(),
928            seed: None,
929            metadata: serde_json::json!({}),
930        };
931
932        worker.execute_plan(&plan);
933
934        let mut events = Vec::new();
935        while let Ok(e) = rx.try_recv() {
936            events.push(e);
937        }
938        assert!(
939            events
940                .iter()
941                .any(|e| matches!(e, Event::NodeStarted { .. }))
942        );
943        assert!(
944            events
945                .iter()
946                .any(|e| matches!(e, Event::NodeCompleted { .. }))
947        );
948    }
949
950    /// A Fit over a large reference must FIT, not stream.
951    ///
952    /// The auto-stream branch used to be taken before `plan.mode` was
953    /// looked at, so a fit whose input happened to exceed 1024 rows was
954    /// silently executed as a stream of forwards: nothing was fitted, no
955    /// state was stored, and the plan reported success. The next forward
956    /// then found no state and died inside the user's filter — while the
957    /// same graph under the threshold fitted normally and was correct.
958    #[test]
959    fn a_fit_over_a_large_reference_is_not_silently_streamed() {
960        struct Counter;
961        impl Filter for Counter {
962            fn config_hash(&self) -> CacheKey {
963                CacheKey::from_parts(&[b"Counter"])
964            }
965            fn fit(&self, x: &Value, _y: Option<&Value>) -> SomaResult<Value> {
966                // The state a real fit produces: something derived from
967                // the WHOLE input, which is exactly what a stream cannot
968                // give you.
969                let n = match x {
970                    Value::Tensor { values, .. } => values.len() as f64,
971                    _ => 0.0,
972                };
973                Ok(Value::tensor(vec![n], vec![1]))
974            }
975            fn forward(&self, x: &Value, _state: &Value) -> SomaResult<Value> {
976                Ok(x.clone())
977            }
978            fn meta(&self) -> FilterMeta {
979                FilterMeta {
980                    name: "Counter".into(),
981                    kind: FilterKind::Trainable,
982                    cacheable: true,
983                    differentiable: false,
984                    deterministic: true,
985                    stream_mode: StreamMode::FixedState,
986                    distribution: somatize_core::filter::Distribution::Local,
987                    input_schema: None,
988                    output_schema: None,
989                }
990            }
991        }
992
993        let dir = tempfile::tempdir().unwrap();
994        let store: Arc<dyn DataStore> = Arc::new(LocalDataStore::new(dir.path().join("data")));
995        let n = 2048usize; // over the auto-stream threshold
996        let key = somatize_core::cache::CacheKey::hash_data(b"fit-input");
997        let data_ref = store
998            .put(&key, &Value::tensor(vec![1.0; n], vec![n]))
999            .unwrap();
1000
1001        let mut worker = make_worker().with_data_store(store);
1002        worker.register_filter("counter", Box::new(Counter));
1003
1004        let mut plan = SerializedPlan::new(
1005            "p_fit_large",
1006            ExecutionPlan::Execute {
1007                node_id: "counter".into(),
1008            },
1009        );
1010        plan.input = Some(InputSource::Reference { data_ref });
1011        plan.mode = ExecutionMode::Fit {
1012            y: None,
1013            batch_size: None,
1014        };
1015
1016        let result = worker.execute_plan(&plan);
1017        assert!(
1018            matches!(result, PlanResult::Success { .. }),
1019            "the fit failed: {result:?}"
1020        );
1021        // It fitted over everything, so the state says 2048 — not a chunk.
1022        let state = worker.get_filter_state("counter");
1023        let (values, _) = state
1024            .as_tensor()
1025            .expect("no state was stored: the fit was streamed");
1026        assert_eq!(
1027            values[0], n as f64,
1028            "the fit saw a chunk, not the whole input"
1029        );
1030    }
1031
1032    /// A stateful filter, streamed from a DataStore.
1033    ///
1034    /// The path above it only ever ran `TestDoubler`, which ignores its
1035    /// state, so "the stream reaches the filter" was proved and "the
1036    /// stream reaches the filter WITH its state" was not. Against a real
1037    /// worker, any filter whose `forward` reads `state["..."]` died with a
1038    /// KeyError on chunk 0 — while the same graph under the 1024-row
1039    /// threshold worked and gave the right answer.
1040    #[test]
1041    fn a_stateful_filter_keeps_its_state_across_a_streamed_data_ref() {
1042        struct Centre;
1043        impl Filter for Centre {
1044            fn config_hash(&self) -> CacheKey {
1045                CacheKey::from_parts(&[b"Centre"])
1046            }
1047            fn fit(&self, _x: &Value, _y: Option<&Value>) -> SomaResult<Value> {
1048                Ok(Value::Empty)
1049            }
1050            fn forward(&self, x: &Value, state: &Value) -> SomaResult<Value> {
1051                // The state a fit would have produced. Reading it is the
1052                // whole point: an empty state has to be an error here, not
1053                // a silently different answer.
1054                let mean = match state {
1055                    Value::Tensor { values, .. } if !values.is_empty() => values[0],
1056                    other => {
1057                        return Err(somatize_core::error::SomaError::Execution {
1058                            node_id: "centre".into(),
1059                            message: format!("no state reached the filter: {other:?}"),
1060                        });
1061                    }
1062                };
1063                match x {
1064                    Value::Tensor { values, shape } => Ok(Value::tensor(
1065                        values.iter().map(|v| v - mean).collect(),
1066                        shape.clone(),
1067                    )),
1068                    other => Ok(other.clone()),
1069                }
1070            }
1071            fn meta(&self) -> FilterMeta {
1072                FilterMeta {
1073                    name: "Centre".into(),
1074                    kind: FilterKind::Trainable,
1075                    cacheable: true,
1076                    differentiable: false,
1077                    deterministic: true,
1078                    stream_mode: StreamMode::FixedState,
1079                    distribution: somatize_core::filter::Distribution::Local,
1080                    input_schema: None,
1081                    output_schema: None,
1082                }
1083            }
1084        }
1085
1086        let dir = tempfile::tempdir().unwrap();
1087        let store: Arc<dyn DataStore> = Arc::new(LocalDataStore::new(dir.path().join("data")));
1088
1089        let n = 2048usize; // over the auto-stream threshold: two chunks
1090        let values: Vec<f64> = (0..n).map(|i| i as f64).collect();
1091        let mean = values.iter().sum::<f64>() / n as f64;
1092        let key = somatize_core::cache::CacheKey::hash_data(b"stateful-stream-input");
1093        let data_ref = store.put(&key, &Value::tensor(values, vec![n])).unwrap();
1094
1095        let mut worker = make_worker().with_data_store(store);
1096        worker.register_filter("centre", Box::new(Centre));
1097        worker.set_filter_state("centre", Value::tensor(vec![mean], vec![1]));
1098
1099        let mut plan = SerializedPlan::new(
1100            "p_stateful_stream",
1101            ExecutionPlan::Execute {
1102                node_id: "centre".into(),
1103            },
1104        );
1105        plan.input = Some(InputSource::Reference { data_ref });
1106
1107        let result = worker.execute_plan(&plan);
1108        let PlanResult::Success { output, .. } = result else {
1109            panic!("a stateful filter must keep its state when streamed: {result:?}");
1110        };
1111        let value = match output {
1112            OutputDelivery::Inline { value } => value,
1113            OutputDelivery::Reference { data_ref } => worker.temp_store().get(&data_ref).unwrap(),
1114        };
1115        let (data, shape) = value.as_tensor().unwrap();
1116        assert_eq!(shape, &[n]);
1117        // Centred on the mean of the WHOLE input, not of a chunk.
1118        assert!((data[0] - (0.0 - mean)).abs() < 1e-9, "{}", data[0]);
1119        assert!(
1120            (data[n - 1] - ((n - 1) as f64 - mean)).abs() < 1e-9,
1121            "{}",
1122            data[n - 1]
1123        );
1124    }
1125
1126    /// The DataStore auto-stream path runs through the runtime's
1127    /// `StreamRun`: the plan output is the CONCATENATED stream (the old
1128    /// executor returned only the last chunk's output), events carry a
1129    /// closed Run bracket, and the plan's seed salts the chunk cache.
1130    #[test]
1131    fn a_large_data_ref_streams_concatenated_through_stream_run() {
1132        let dir = tempfile::tempdir().unwrap();
1133        let store: Arc<dyn DataStore> = Arc::new(LocalDataStore::new(dir.path().join("data")));
1134
1135        // 2048 rows > the 1024-row auto-stream threshold: two chunks.
1136        let n = 2048usize;
1137        let values: Vec<f64> = (0..n).map(|i| i as f64).collect();
1138        let key = somatize_core::cache::CacheKey::hash_data(b"stream-input");
1139        let data_ref = store.put(&key, &Value::tensor(values, vec![n])).unwrap();
1140
1141        let mut worker = make_worker().with_data_store(store);
1142        let mut rx = worker.subscribe();
1143        worker.register_filter("doubler", Box::new(TestDoubler));
1144
1145        let mut plan = SerializedPlan::new(
1146            "p_stream",
1147            ExecutionPlan::Execute {
1148                node_id: "doubler".into(),
1149            },
1150        );
1151        plan.input = Some(InputSource::Reference { data_ref });
1152        plan.seed = Some(7);
1153
1154        let result = worker.execute_plan(&plan);
1155        let PlanResult::Success { output, .. } = result else {
1156            panic!("stream plan failed: {result:?}");
1157        };
1158        let value = match output {
1159            OutputDelivery::Inline { value } => value,
1160            OutputDelivery::Reference { data_ref } => worker.temp_store().get(&data_ref).unwrap(),
1161        };
1162        let (data, shape) = value.as_tensor().unwrap();
1163        assert_eq!(
1164            shape,
1165            &[n],
1166            "the output is the whole stream, not the last chunk"
1167        );
1168        assert_eq!(data[0], 0.0);
1169        assert_eq!(data[n - 1], (n - 1) as f64 * 2.0);
1170
1171        let mut started = 0;
1172        let mut node_completed = 0;
1173        let mut run_completed = 0;
1174        while let Ok(event) = rx.try_recv() {
1175            match event {
1176                Event::NodeStarted { node_id, .. } => {
1177                    assert_eq!(node_id, "doubler");
1178                    started += 1;
1179                }
1180                Event::NodeCompleted {
1181                    node_id,
1182                    output_summary,
1183                    ..
1184                } => {
1185                    assert_eq!(node_id, "doubler");
1186                    assert!(output_summary.contains("2 chunks"), "{output_summary}");
1187                    node_completed += 1;
1188                }
1189                Event::RunCompleted { .. } => run_completed += 1,
1190                _ => {}
1191            }
1192        }
1193        assert_eq!((started, node_completed), (1, 1), "one bracket per node");
1194        assert_eq!(run_completed, 1, "the run bracket must close");
1195    }
1196}