1use std::path::PathBuf;
8use std::thread;
9use std::time::{Duration, Instant};
10
11use serde::Serialize;
12use serde::de::DeserializeOwned;
13use serde_json::Value;
14
15use lean_rs_worker_protocol::types::{
16 LeanWorkerElabOptions, LeanWorkerImportStats, LeanWorkerModuleQueryBatchOutcome, LeanWorkerModuleQuerySelector,
17 LeanWorkerOutputBudgets, LeanWorkerResourceExhaustedFacts, LeanWorkerSessionImportProfile,
18};
19
20use crate::capability::{LeanWorkerCapability, LeanWorkerCapabilityBuilder};
21use crate::session::{
22 LeanWorkerCancellationToken, LeanWorkerDiagnosticSink, LeanWorkerJsonCommand, LeanWorkerProgressSink,
23 LeanWorkerRuntimeMetadata, LeanWorkerSession, LeanWorkerStreamingCommand, LeanWorkerTypedDataSink,
24 LeanWorkerTypedStreamSummary,
25};
26use crate::supervisor::{LeanWorkerError, LeanWorkerReplacementTiming, LeanWorkerRestartReason, LeanWorkerStatus};
27
28#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
35pub enum LeanWorkerRestartPolicyClass {
36 Default,
37 Custom,
38}
39
40#[derive(Clone, Debug, Eq, PartialEq)]
49pub struct LeanWorkerSessionKey {
50 project_root: PathBuf,
51 import_workspace_root: PathBuf,
52 built_manifest_path: Option<PathBuf>,
53 package: String,
54 lib_name: String,
55 imports: Vec<String>,
56 import_profile: LeanWorkerSessionImportProfile,
57 metadata_expectation: Option<LeanWorkerMetadataExpectationKey>,
58 toolchain_fingerprint: lean_toolchain::ToolchainFingerprint,
59 restart_policy_class: LeanWorkerRestartPolicyClass,
60}
61
62impl LeanWorkerSessionKey {
63 #[must_use]
69 pub fn new(
70 project_root: impl Into<PathBuf>,
71 package: impl Into<String>,
72 lib_name: impl Into<String>,
73 imports: impl IntoIterator<Item = impl Into<String>>,
74 ) -> Self {
75 let project_root = normalize_import_workspace_root(project_root.into());
76 Self {
77 import_workspace_root: normalize_import_workspace_root(project_root.clone()),
78 project_root,
79 built_manifest_path: None,
80 package: package.into(),
81 lib_name: lib_name.into(),
82 imports: imports.into_iter().map(Into::into).collect(),
83 import_profile: LeanWorkerSessionImportProfile::default(),
84 metadata_expectation: None,
85 toolchain_fingerprint: lean_toolchain::ToolchainFingerprint::current(),
86 restart_policy_class: LeanWorkerRestartPolicyClass::Default,
87 }
88 }
89
90 pub(crate) fn with_import_workspace_root(mut self, root: impl Into<PathBuf>) -> Self {
91 self.import_workspace_root = normalize_import_workspace_root(root.into());
92 self
93 }
94
95 pub(crate) fn with_built_manifest_path(mut self, path: impl Into<PathBuf>) -> Self {
96 self.built_manifest_path = Some(normalize_import_workspace_root(path.into()));
97 self
98 }
99
100 pub(crate) fn with_import_profile(mut self, profile: LeanWorkerSessionImportProfile) -> Self {
101 self.import_profile = profile;
102 self
103 }
104
105 #[must_use]
111 pub fn metadata_expectation(
112 mut self,
113 export: impl Into<String>,
114 request: Value,
115 expected: Option<lean_rs_worker_protocol::types::LeanWorkerCapabilityMetadata>,
116 ) -> Self {
117 self.metadata_expectation = Some(LeanWorkerMetadataExpectationKey {
118 export: export.into(),
119 request,
120 expected,
121 });
122 self
123 }
124
125 #[must_use]
127 pub fn restart_policy_class(mut self, class: LeanWorkerRestartPolicyClass) -> Self {
128 self.restart_policy_class = class;
129 self
130 }
131
132 #[must_use]
134 pub fn project_root(&self) -> &std::path::Path {
135 &self.project_root
136 }
137
138 #[must_use]
140 pub fn import_workspace_root(&self) -> &std::path::Path {
141 &self.import_workspace_root
142 }
143
144 #[must_use]
146 pub fn package(&self) -> &str {
147 &self.package
148 }
149
150 #[must_use]
152 pub fn lib_name(&self) -> &str {
153 &self.lib_name
154 }
155
156 #[must_use]
158 pub fn imports(&self) -> &[String] {
159 &self.imports
160 }
161
162 #[must_use]
164 pub fn import_profile(&self) -> LeanWorkerSessionImportProfile {
165 self.import_profile
166 }
167
168 #[must_use]
170 pub fn toolchain_fingerprint(&self) -> &lean_toolchain::ToolchainFingerprint {
171 &self.toolchain_fingerprint
172 }
173
174 #[must_use]
176 pub fn policy_class(&self) -> LeanWorkerRestartPolicyClass {
177 self.restart_policy_class
178 }
179}
180
181#[derive(Clone, Debug, Eq, PartialEq)]
182struct LeanWorkerMetadataExpectationKey {
183 export: String,
184 request: Value,
185 expected: Option<lean_rs_worker_protocol::types::LeanWorkerCapabilityMetadata>,
186}
187
188#[derive(Clone, Debug, Eq, PartialEq)]
196pub struct LeanWorkerPoolConfig {
197 max_workers: usize,
198 max_total_child_rss_kib: Option<u64>,
199 per_worker_rss_ceiling_kib: Option<u64>,
200 idle_cycle_after: Option<Duration>,
201 queue_wait_timeout: Duration,
202}
203
204impl LeanWorkerPoolConfig {
205 #[must_use]
212 pub fn new(max_workers: usize) -> Self {
213 Self {
214 max_workers: max_workers.max(1),
215 max_total_child_rss_kib: None,
216 per_worker_rss_ceiling_kib: None,
217 idle_cycle_after: None,
218 queue_wait_timeout: Duration::ZERO,
219 }
220 }
221
222 #[must_use]
224 pub fn max_workers(&self) -> usize {
225 self.max_workers
226 }
227
228 #[must_use]
236 pub fn max_total_child_rss_kib(mut self, limit: u64) -> Self {
237 self.max_total_child_rss_kib = Some(limit.max(1));
238 self
239 }
240
241 #[must_use]
247 pub fn per_worker_rss_ceiling_kib(mut self, limit: u64) -> Self {
248 self.per_worker_rss_ceiling_kib = Some(limit.max(1));
249 self
250 }
251
252 #[must_use]
254 pub fn idle_cycle_after(mut self, limit: Duration) -> Self {
255 self.idle_cycle_after = Some(limit);
256 self
257 }
258
259 #[must_use]
264 pub fn queue_wait_timeout(mut self, timeout: Duration) -> Self {
265 self.queue_wait_timeout = timeout;
266 self
267 }
268
269 #[must_use]
271 pub fn max_total_child_rss_kib_limit(&self) -> Option<u64> {
272 self.max_total_child_rss_kib
273 }
274
275 #[must_use]
277 pub fn per_worker_rss_ceiling_kib_limit(&self) -> Option<u64> {
278 self.per_worker_rss_ceiling_kib
279 }
280
281 #[must_use]
283 pub fn idle_cycle_after_limit(&self) -> Option<Duration> {
284 self.idle_cycle_after
285 }
286
287 #[must_use]
289 pub fn queue_wait_timeout_limit(&self) -> Duration {
290 self.queue_wait_timeout
291 }
292}
293
294impl Default for LeanWorkerPoolConfig {
295 fn default() -> Self {
296 Self::new(1)
297 }
298}
299
300#[derive(Clone, Debug, Eq, PartialEq)]
305pub struct LeanWorkerPoolSnapshot {
306 pub max_workers: usize,
307 pub workers: usize,
308 pub active_workers: usize,
309 pub warm_leases: usize,
310 pub queue_depth: usize,
311 pub total_child_rss_kib: Option<u64>,
312 pub rss_samples_unavailable: u64,
313 pub requests: u64,
314 pub imports: u64,
315 pub worker_restarts: u64,
316 pub max_request_restarts: u64,
317 pub max_import_restarts: u64,
318 pub rss_restarts: u64,
319 pub idle_restarts: u64,
320 pub cancelled_restarts: u64,
321 pub timeout_restarts: u64,
322 pub policy_restarts: u64,
323 pub queue_timeouts: u64,
324 pub memory_budget_rejections: u64,
325 pub cold_open_attempts: u64,
326 pub cold_open_admitted: u64,
327 pub cold_open_refusals: u64,
328 pub key_hits: u64,
329 pub key_misses: u64,
330 pub distinct_keys_seen: u64,
331 pub fresh_cold_opens_avoided: u64,
332 pub miss_empty_pool: u64,
333 pub miss_no_matching_key: u64,
334 pub last_key_miss_reason: Option<String>,
335 pub import_like_requests: u64,
336 pub concurrent_cold_opens_observed: u64,
337 pub rss_before_admission_kib: Option<u64>,
338 pub rss_after_open_kib: Option<u64>,
339 pub refusal_reason: Option<String>,
340 pub replacement_attempts: u64,
341 pub replacement_successes: u64,
342 pub replacement_failures: u64,
343 pub replacement_budget_admitted: u64,
344 pub replacement_budget_skipped: u64,
345 pub last_replacement_timing: Option<LeanWorkerReplacementTiming>,
346 pub last_replacement_skipped_reason: Option<String>,
347 pub last_spawn_handshake_elapsed: Option<Duration>,
348 pub last_capability_load_elapsed: Option<Duration>,
349 pub last_session_open_import_elapsed: Option<Duration>,
350 pub last_first_command_elapsed: Option<Duration>,
351 pub last_warm_command_elapsed: Option<Duration>,
352 pub last_restart_reason: Option<LeanWorkerRestartReason>,
353 pub last_import_stats: Option<LeanWorkerImportStats>,
354 pub stream_requests: u64,
355 pub stream_successes: u64,
356 pub stream_failures: u64,
357 pub data_rows_delivered: u64,
358 pub data_row_payload_bytes: u64,
359 pub stream_elapsed: Duration,
360 pub backpressure_waits: u64,
361 pub backpressure_failures: u64,
362}
363
364#[derive(Debug)]
366pub struct LeanWorkerPool {
367 config: LeanWorkerPoolConfig,
368 entries: Vec<PoolEntry>,
369 queue_timeouts: u64,
370 memory_budget_rejections: u64,
371 cold_open_attempts: u64,
372 cold_open_admitted: u64,
373 cold_open_refusals: u64,
374 key_hits: u64,
375 key_misses: u64,
376 seen_keys: Vec<LeanWorkerSessionKey>,
377 fresh_cold_opens_avoided: u64,
378 miss_empty_pool: u64,
379 miss_no_matching_key: u64,
380 last_key_miss_reason: Option<String>,
381 cold_opens_in_progress: u64,
382 concurrent_cold_opens_observed: u64,
383 rss_before_admission_kib: Option<u64>,
384 rss_after_open_kib: Option<u64>,
385 refusal_reason: Option<String>,
386}
387
388#[derive(Clone, Copy, Debug, Eq, PartialEq)]
389enum PoolAdmissionDecision {
390 ReuseWarmEntry { index: usize },
391 OpenColdEntry,
392}
393
394fn grant_lease<'pool>(entry: &'pool mut PoolEntry, config: &LeanWorkerPoolConfig) -> LeanWorkerSessionLease<'pool> {
395 let admitted_generation = entry.capability.lifecycle_snapshot().worker_generation;
396 entry.active_leases = entry.active_leases.saturating_add(1);
397 LeanWorkerSessionLease {
398 entry,
399 config: config.clone(),
400 valid: true,
401 invalidation_reason: None,
402 request_timeout_override: None,
403 admitted_generation,
404 released: false,
405 }
406}
407
408impl LeanWorkerPool {
409 #[must_use]
411 pub fn new(config: LeanWorkerPoolConfig) -> Self {
412 Self {
413 config,
414 entries: Vec::new(),
415 queue_timeouts: 0,
416 memory_budget_rejections: 0,
417 cold_open_attempts: 0,
418 cold_open_admitted: 0,
419 cold_open_refusals: 0,
420 key_hits: 0,
421 key_misses: 0,
422 seen_keys: Vec::new(),
423 fresh_cold_opens_avoided: 0,
424 miss_empty_pool: 0,
425 miss_no_matching_key: 0,
426 last_key_miss_reason: None,
427 cold_opens_in_progress: 0,
428 concurrent_cold_opens_observed: 0,
429 rss_before_admission_kib: None,
430 rss_after_open_kib: None,
431 refusal_reason: None,
432 }
433 }
434
435 pub fn acquire_lease(
448 &mut self,
449 builder: LeanWorkerCapabilityBuilder,
450 ) -> Result<LeanWorkerSessionLease<'_>, LeanWorkerError> {
451 let key = builder.session_key();
452 match self.admit_lease(&key)? {
453 PoolAdmissionDecision::ReuseWarmEntry { index } => {
454 self.ensure_entry_running(index)?;
455 self.enforce_entry_policy_before_assignment(index)?;
456 let entry = self.entries.get_mut(index).ok_or_else(|| LeanWorkerError::Protocol {
457 message: "worker pool entry disappeared during lease acquisition".to_owned(),
458 })?;
459 return Ok(grant_lease(entry, &self.config));
460 }
461 PoolAdmissionDecision::OpenColdEntry => {}
462 }
463
464 let capability = match builder.clone().open() {
465 Ok(capability) => capability,
466 Err(err) => {
467 self.cold_opens_in_progress = self.cold_opens_in_progress.saturating_sub(1);
468 return Err(err);
469 }
470 };
471 self.cold_opens_in_progress = self.cold_opens_in_progress.saturating_sub(1);
472 let base_request_timeout = builder.pool_request_timeout();
473 self.entries.push(PoolEntry {
474 key,
475 builder,
476 capability,
477 base_request_timeout,
478 last_rss_kib: None,
479 rss_samples_unavailable: 0,
480 last_activity: Instant::now(),
481 last_restart_reason: None,
482 policy_restarts: 0,
483 active_leases: 0,
484 commands_since_open: 0,
485 });
486 let index = self
487 .entries
488 .len()
489 .checked_sub(1)
490 .ok_or_else(|| LeanWorkerError::Protocol {
491 message: "worker pool failed to retain newly opened entry".to_owned(),
492 })?;
493 let entry = self.entries.get_mut(index).ok_or_else(|| LeanWorkerError::Protocol {
494 message: "worker pool failed to retain newly opened entry".to_owned(),
495 })?;
496 let _ = entry.sample_rss();
497 self.rss_after_open_kib = total_known_child_rss_kib(&self.entries);
498 let entry = self.entries.get_mut(index).ok_or_else(|| LeanWorkerError::Protocol {
499 message: "worker pool failed to retain newly opened entry".to_owned(),
500 })?;
501 Ok(grant_lease(entry, &self.config))
502 }
503
504 fn admit_lease(&mut self, key: &LeanWorkerSessionKey) -> Result<PoolAdmissionDecision, LeanWorkerError> {
505 self.remember_seen_key(key);
506 if let Some(index) = self.entries.iter().position(|entry| &entry.key == key) {
507 self.record_key_hit();
508 return Ok(PoolAdmissionDecision::ReuseWarmEntry { index });
509 }
510
511 let miss_reason = if self.entries.is_empty() {
512 "empty_pool"
513 } else {
514 "no_matching_key"
515 };
516 self.record_key_miss(miss_reason);
517 self.record_cold_open_attempt();
518 let rss_before_admission = self.refresh_total_child_rss();
519 self.rss_before_admission_kib = rss_before_admission.available_total();
520 if self.entries.len() >= self.config.max_workers {
521 return self.pool_full_error();
522 }
523 self.ensure_spawn_within_total_rss_budget(rss_before_admission)?;
524
525 self.record_cold_open_admitted();
526 Ok(PoolAdmissionDecision::OpenColdEntry)
527 }
528
529 #[must_use]
531 pub fn snapshot(&self) -> LeanWorkerPoolSnapshot {
532 snapshot_from_entries(
533 &self.config,
534 &self.entries,
535 self.queue_timeouts,
536 self.memory_budget_rejections,
537 self.cold_open_attempts,
538 self.cold_open_admitted,
539 self.cold_open_refusals,
540 self.key_hits,
541 self.key_misses,
542 u64::try_from(self.seen_keys.len()).unwrap_or(u64::MAX),
543 self.fresh_cold_opens_avoided,
544 self.miss_empty_pool,
545 self.miss_no_matching_key,
546 self.last_key_miss_reason.clone(),
547 self.concurrent_cold_opens_observed,
548 self.rss_before_admission_kib,
549 self.rss_after_open_kib,
550 self.refusal_reason.clone(),
551 )
552 }
553
554 fn snapshot_from_lease_config(config: &LeanWorkerPoolConfig, entry: &PoolEntry) -> LeanWorkerPoolSnapshot {
555 snapshot_from_entries(
556 config,
557 std::slice::from_ref(entry),
558 0,
559 0,
560 0,
561 0,
562 0,
563 0,
564 0,
565 0,
566 0,
567 0,
568 0,
569 None,
570 0,
571 None,
572 None,
573 None,
574 )
575 }
576}
577
578fn snapshot_from_entries(
579 config: &LeanWorkerPoolConfig,
580 entries: &[PoolEntry],
581 queue_timeouts: u64,
582 memory_budget_rejections: u64,
583 cold_open_attempts: u64,
584 cold_open_admitted: u64,
585 cold_open_refusals: u64,
586 key_hits: u64,
587 key_misses: u64,
588 distinct_keys_seen: u64,
589 fresh_cold_opens_avoided: u64,
590 miss_empty_pool: u64,
591 miss_no_matching_key: u64,
592 last_key_miss_reason: Option<String>,
593 concurrent_cold_opens_observed: u64,
594 rss_before_admission_kib: Option<u64>,
595 rss_after_open_kib: Option<u64>,
596 refusal_reason: Option<String>,
597) -> LeanWorkerPoolSnapshot {
598 LeanWorkerPoolSnapshot {
599 max_workers: config.max_workers,
600 workers: entries.len(),
601 active_workers: entries.iter().filter(|entry| entry.active_leases > 0).count(),
602 warm_leases: entries.iter().filter(|entry| entry.active_leases == 0).count(),
603 queue_depth: 0,
604 total_child_rss_kib: total_known_child_rss_kib(entries),
605 rss_samples_unavailable: entries.iter().map(|entry| entry.rss_samples_unavailable).sum(),
606 requests: entries.iter().map(|entry| entry.capability.stats().requests).sum(),
607 imports: entries.iter().map(|entry| entry.capability.stats().imports).sum(),
608 worker_restarts: entries.iter().map(|entry| entry.capability.stats().restarts).sum(),
609 max_request_restarts: entries
610 .iter()
611 .map(|entry| entry.capability.stats().max_request_restarts)
612 .sum(),
613 max_import_restarts: entries
614 .iter()
615 .map(|entry| entry.capability.stats().max_import_restarts)
616 .sum(),
617 rss_restarts: entries.iter().map(|entry| entry.capability.stats().rss_restarts).sum(),
618 idle_restarts: entries.iter().map(|entry| entry.capability.stats().idle_restarts).sum(),
619 cancelled_restarts: entries
620 .iter()
621 .map(|entry| entry.capability.stats().cancelled_restarts)
622 .sum(),
623 timeout_restarts: entries
624 .iter()
625 .map(|entry| entry.capability.stats().timeout_restarts)
626 .sum(),
627 policy_restarts: entries.iter().map(|entry| entry.policy_restarts).sum(),
628 queue_timeouts,
629 memory_budget_rejections,
630 cold_open_attempts,
631 cold_open_admitted,
632 cold_open_refusals,
633 key_hits,
634 key_misses,
635 distinct_keys_seen,
636 fresh_cold_opens_avoided,
637 miss_empty_pool,
638 miss_no_matching_key,
639 last_key_miss_reason,
640 import_like_requests: entries
641 .iter()
642 .map(|entry| entry.capability.stats().import_like_admission_attempts)
643 .sum(),
644 concurrent_cold_opens_observed,
645 rss_before_admission_kib,
646 rss_after_open_kib,
647 refusal_reason,
648 replacement_attempts: entries
649 .iter()
650 .map(|entry| entry.capability.stats().replacement_attempts)
651 .sum(),
652 replacement_successes: entries
653 .iter()
654 .map(|entry| entry.capability.stats().replacement_successes)
655 .sum(),
656 replacement_failures: entries
657 .iter()
658 .map(|entry| entry.capability.stats().replacement_failures)
659 .sum(),
660 replacement_budget_admitted: entries
661 .iter()
662 .map(|entry| entry.capability.stats().replacement_budget_admitted)
663 .sum(),
664 replacement_budget_skipped: entries
665 .iter()
666 .map(|entry| entry.capability.stats().replacement_budget_skipped)
667 .sum(),
668 last_replacement_timing: entries
669 .iter()
670 .rev()
671 .find_map(|entry| entry.capability.stats().last_replacement_timing),
672 last_replacement_skipped_reason: entries
673 .iter()
674 .rev()
675 .find_map(|entry| entry.capability.stats().last_replacement_skipped_reason),
676 last_spawn_handshake_elapsed: entries
677 .iter()
678 .rev()
679 .find_map(|entry| entry.capability.stats().last_spawn_handshake_elapsed),
680 last_capability_load_elapsed: entries
681 .iter()
682 .rev()
683 .find_map(|entry| entry.capability.stats().last_capability_load_elapsed),
684 last_session_open_import_elapsed: entries
685 .iter()
686 .rev()
687 .find_map(|entry| entry.capability.stats().last_session_open_import_elapsed),
688 last_first_command_elapsed: entries
689 .iter()
690 .rev()
691 .find_map(|entry| entry.capability.stats().last_first_command_elapsed),
692 last_warm_command_elapsed: entries
693 .iter()
694 .rev()
695 .find_map(|entry| entry.capability.stats().last_warm_command_elapsed),
696 last_restart_reason: entries.iter().rev().find_map(|entry| entry.last_restart_reason.clone()),
697 last_import_stats: entries
698 .iter()
699 .rev()
700 .find_map(|entry| entry.capability.stats().last_import_stats),
701 stream_requests: entries
702 .iter()
703 .map(|entry| entry.capability.stats().stream_requests)
704 .sum(),
705 stream_successes: entries
706 .iter()
707 .map(|entry| entry.capability.stats().stream_successes)
708 .sum(),
709 stream_failures: entries
710 .iter()
711 .map(|entry| entry.capability.stats().stream_failures)
712 .sum(),
713 data_rows_delivered: entries
714 .iter()
715 .map(|entry| entry.capability.stats().data_rows_delivered)
716 .sum(),
717 data_row_payload_bytes: entries
718 .iter()
719 .map(|entry| entry.capability.stats().data_row_payload_bytes)
720 .sum(),
721 stream_elapsed: entries.iter().fold(Duration::ZERO, |acc, entry| {
722 acc.saturating_add(entry.capability.stats().stream_elapsed)
723 }),
724 backpressure_waits: entries
725 .iter()
726 .map(|entry| entry.capability.stats().backpressure_waits)
727 .sum(),
728 backpressure_failures: entries
729 .iter()
730 .map(|entry| entry.capability.stats().backpressure_failures)
731 .sum(),
732 }
733}
734
735fn total_known_child_rss_kib(entries: &[PoolEntry]) -> Option<u64> {
736 entries
737 .iter()
738 .map(|entry| entry.last_rss_kib)
739 .try_fold(0_u64, |acc, value| value.map(|rss| acc.saturating_add(rss)))
740}
741
742impl LeanWorkerPool {
743 fn ensure_entry_running(&mut self, index: usize) -> Result<(), LeanWorkerError> {
744 let entry = self.entries.get_mut(index).ok_or_else(|| LeanWorkerError::Protocol {
745 message: "worker pool entry disappeared during liveness check".to_owned(),
746 })?;
747 match entry.capability.status()? {
748 LeanWorkerStatus::Running => Ok(()),
749 LeanWorkerStatus::Exited(_exit) => {
750 entry.capability = entry.builder.clone().open()?;
751 entry.last_activity = Instant::now();
752 Ok(())
753 }
754 }
755 }
756
757 fn enforce_entry_policy_before_assignment(&mut self, index: usize) -> Result<(), LeanWorkerError> {
758 let entry = self.entries.get_mut(index).ok_or_else(|| LeanWorkerError::Protocol {
759 message: "worker pool entry disappeared during policy check".to_owned(),
760 })?;
761 entry.enforce_policy(&self.config).map(|_| ())
762 }
763
764 fn ensure_spawn_within_total_rss_budget(&mut self, rss: PoolRssTotal) -> Result<(), LeanWorkerError> {
765 let Some(limit_kib) = self.config.max_total_child_rss_kib else {
766 return Ok(());
767 };
768 if rss.unavailable > 0 {
769 return Ok(());
770 }
771 if rss.total_kib >= limit_kib {
772 self.memory_budget_rejections = self.memory_budget_rejections.saturating_add(1);
773 self.record_cold_open_refusal("rss_budget");
774 let last_import_stats = self.latest_import_stats();
775 return Err(LeanWorkerError::WorkerPoolMemoryBudgetExceeded {
776 current_kib: rss.total_kib,
777 limit_kib,
778 last_import_stats: last_import_stats.clone().map(Box::new),
779 resource: Box::new(self.pool_resource_facts(
780 "worker_pool_total_rss_budget",
781 Some(rss.total_kib),
782 Some(limit_kib),
783 None,
784 last_import_stats,
785 )),
786 });
787 }
788 Ok(())
789 }
790
791 fn record_cold_open_attempt(&mut self) {
792 self.cold_open_attempts = self.cold_open_attempts.saturating_add(1);
793 self.refusal_reason = None;
794 if self.cold_opens_in_progress > 0 {
795 self.concurrent_cold_opens_observed = self.concurrent_cold_opens_observed.saturating_add(1);
796 }
797 }
798
799 fn record_cold_open_admitted(&mut self) {
800 self.cold_open_admitted = self.cold_open_admitted.saturating_add(1);
801 self.cold_opens_in_progress = self.cold_opens_in_progress.saturating_add(1);
802 }
803
804 fn record_cold_open_refusal(&mut self, reason: impl Into<String>) {
805 self.cold_open_refusals = self.cold_open_refusals.saturating_add(1);
806 self.refusal_reason = Some(reason.into());
807 }
808
809 fn remember_seen_key(&mut self, key: &LeanWorkerSessionKey) {
810 if self.seen_keys.iter().all(|seen| seen != key) {
811 self.seen_keys.push(key.clone());
812 }
813 }
814
815 fn record_key_hit(&mut self) {
816 self.key_hits = self.key_hits.saturating_add(1);
817 self.fresh_cold_opens_avoided = self.fresh_cold_opens_avoided.saturating_add(1);
818 self.last_key_miss_reason = None;
819 }
820
821 fn record_key_miss(&mut self, reason: &'static str) {
822 self.key_misses = self.key_misses.saturating_add(1);
823 match reason {
824 "empty_pool" => self.miss_empty_pool = self.miss_empty_pool.saturating_add(1),
825 "no_matching_key" => self.miss_no_matching_key = self.miss_no_matching_key.saturating_add(1),
826 _ => {}
827 }
828 self.last_key_miss_reason = Some(reason.to_owned());
829 }
830
831 fn latest_import_stats(&self) -> Option<LeanWorkerImportStats> {
832 self.entries
833 .iter()
834 .find_map(|entry| entry.capability.stats().last_import_stats)
835 }
836
837 fn pool_resource_facts(
838 &self,
839 cause: &str,
840 current_rss_kib: Option<u64>,
841 limit_kib: Option<u64>,
842 queue_wait: Option<Duration>,
843 last_import_stats: Option<LeanWorkerImportStats>,
844 ) -> LeanWorkerResourceExhaustedFacts {
845 let restart_reason = self
846 .entries
847 .iter()
848 .find_map(|entry| entry.capability.stats().last_restart_reason)
849 .map(|reason| reason.stable_cause().to_owned());
850 let import_like_requests = self
851 .entries
852 .iter()
853 .map(|entry| entry.capability.stats().import_like_admission_attempts)
854 .fold(0_u64, u64::saturating_add);
855 LeanWorkerResourceExhaustedFacts {
856 cause: cause.to_owned(),
857 work_entered_child: false,
858 operation: Some("worker_pool_acquire_lease".to_owned()),
859 current_rss_kib,
860 limit_kib,
861 import_count: None,
862 worker_generation: None,
863 restart_reason,
864 queue_wait_ms: queue_wait.map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)),
865 duration_ms: None,
866 cold_open_attempts: Some(self.cold_open_attempts),
867 cold_open_admitted: Some(self.cold_open_admitted),
868 cold_open_refusals: Some(self.cold_open_refusals),
869 import_like_requests: Some(import_like_requests),
870 import_like_admitted: None,
871 last_import_stats,
872 }
873 }
874
875 fn refresh_total_child_rss(&mut self) -> PoolRssTotal {
876 let mut total_kib = 0_u64;
877 let mut unavailable = 0_u64;
878 for entry in &mut self.entries {
879 match entry.sample_rss() {
880 Some(value) => {
881 total_kib = total_kib.saturating_add(value);
882 }
883 None => {
884 unavailable = unavailable.saturating_add(1);
885 }
886 }
887 }
888 PoolRssTotal { total_kib, unavailable }
889 }
890
891 fn pool_full_error<T>(&mut self) -> Result<T, LeanWorkerError> {
892 if self.config.queue_wait_timeout.is_zero() {
893 self.record_cold_open_refusal("max_workers");
894 return Err(LeanWorkerError::WorkerPoolExhausted {
895 max_workers: self.config.max_workers,
896 resource: Box::new(self.pool_resource_facts(
897 "worker_pool_max_workers",
898 None,
899 None,
900 None,
901 self.latest_import_stats(),
902 )),
903 });
904 }
905 let started = Instant::now();
906 while started.elapsed() < self.config.queue_wait_timeout {
907 let remaining = self.config.queue_wait_timeout.saturating_sub(started.elapsed());
908 thread::sleep(remaining.min(Duration::from_millis(10)));
909 }
910 self.queue_timeouts = self.queue_timeouts.saturating_add(1);
911 self.record_cold_open_refusal("queue_timeout");
912 Err(LeanWorkerError::WorkerPoolQueueTimeout {
913 waited: self.config.queue_wait_timeout,
914 resource: Box::new(self.pool_resource_facts(
915 "worker_pool_queue_timeout",
916 None,
917 None,
918 Some(self.config.queue_wait_timeout),
919 self.latest_import_stats(),
920 )),
921 })
922 }
923}
924
925impl Default for LeanWorkerPool {
926 fn default() -> Self {
927 Self::new(LeanWorkerPoolConfig::default())
928 }
929}
930
931#[derive(Debug)]
932struct PoolEntry {
933 key: LeanWorkerSessionKey,
934 builder: LeanWorkerCapabilityBuilder,
935 capability: LeanWorkerCapability,
936 base_request_timeout: Duration,
937 last_rss_kib: Option<u64>,
938 rss_samples_unavailable: u64,
939 last_activity: Instant,
940 last_restart_reason: Option<LeanWorkerRestartReason>,
941 policy_restarts: u64,
942 active_leases: u64,
943 commands_since_open: u64,
944}
945
946impl PoolEntry {
947 fn sample_rss(&mut self) -> Option<u64> {
948 match self.capability.rss_kib() {
949 Some(value) => {
950 self.last_rss_kib = Some(value);
951 Some(value)
952 }
953 None => {
954 self.rss_samples_unavailable = self.rss_samples_unavailable.saturating_add(1);
955 None
956 }
957 }
958 }
959
960 fn enforce_policy(&mut self, config: &LeanWorkerPoolConfig) -> Result<Option<String>, LeanWorkerError> {
961 if let Some(limit_kib) = config.per_worker_rss_ceiling_kib {
962 match self.sample_rss() {
963 Some(current_kib) if current_kib >= limit_kib => {
964 let reason = LeanWorkerRestartReason::RssCeiling {
965 current_kib,
966 limit_kib,
967 last_import_stats: self.capability.stats().last_import_stats,
968 };
969 self.cycle_for_policy(reason)?;
970 return Ok(Some(format!(
971 "memory policy cycled worker at {current_kib} KiB RSS with limit {limit_kib} KiB"
972 )));
973 }
974 Some(_) | None => {}
975 }
976 }
977
978 if let Some(limit) = config.idle_cycle_after {
979 let idle_for = self.last_activity.elapsed();
980 if idle_for >= limit {
981 let reason = LeanWorkerRestartReason::Idle { idle_for, limit };
982 self.cycle_for_policy(reason)?;
983 return Ok(Some(format!(
984 "idle policy cycled worker after {idle_for:?} idle with limit {limit:?}"
985 )));
986 }
987 }
988
989 Ok(None)
990 }
991
992 fn cycle_for_policy(&mut self, reason: LeanWorkerRestartReason) -> Result<(), LeanWorkerError> {
993 self.capability.cycle_with_restart_reason(reason.clone())?;
994 self.last_restart_reason = Some(reason);
995 self.last_activity = Instant::now();
996 self.last_rss_kib = None;
997 self.policy_restarts = self.policy_restarts.saturating_add(1);
998 self.commands_since_open = 0;
999 Ok(())
1000 }
1001}
1002
1003#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1004struct PoolRssTotal {
1005 total_kib: u64,
1006 unavailable: u64,
1007}
1008
1009impl PoolRssTotal {
1010 fn available_total(self) -> Option<u64> {
1011 if self.unavailable == 0 {
1012 Some(self.total_kib)
1013 } else {
1014 None
1015 }
1016 }
1017}
1018
1019#[derive(Debug)]
1025pub struct LeanWorkerSessionLease<'pool> {
1026 entry: &'pool mut PoolEntry,
1027 config: LeanWorkerPoolConfig,
1028 valid: bool,
1029 invalidation_reason: Option<String>,
1030 request_timeout_override: Option<Duration>,
1031 admitted_generation: u64,
1032 released: bool,
1033}
1034
1035impl LeanWorkerSessionLease<'_> {
1036 #[must_use]
1038 pub fn session_key(&self) -> &LeanWorkerSessionKey {
1039 &self.entry.key
1040 }
1041
1042 #[must_use]
1044 pub fn runtime_metadata(&self) -> LeanWorkerRuntimeMetadata {
1045 self.entry.capability.runtime_metadata()
1046 }
1047
1048 #[must_use]
1050 pub fn is_valid(&self) -> bool {
1051 self.valid
1052 }
1053
1054 pub fn release(mut self) {
1059 self.release_once();
1060 }
1061
1062 #[must_use]
1069 pub fn snapshot(&self) -> LeanWorkerPoolSnapshot {
1070 LeanWorkerPool::snapshot_from_lease_config(&self.config, self.entry)
1071 }
1072
1073 pub fn cycle(&mut self) -> Result<(), LeanWorkerError> {
1083 self.ensure_valid()?;
1084 self.entry.capability.cycle()?;
1085 self.invalidate("explicit worker cycle");
1086 Ok(())
1087 }
1088
1089 pub fn set_request_timeout(&mut self, timeout: Duration) -> Result<(), LeanWorkerError> {
1099 self.ensure_valid()?;
1100 self.request_timeout_override = Some(timeout);
1101 Ok(())
1102 }
1103
1104 pub fn run_json_command<Req, Resp>(
1112 &mut self,
1113 command: &LeanWorkerJsonCommand<Req, Resp>,
1114 request: &Req,
1115 cancellation: Option<&LeanWorkerCancellationToken>,
1116 progress: Option<&dyn LeanWorkerProgressSink>,
1117 ) -> Result<Resp, LeanWorkerError>
1118 where
1119 Req: Serialize,
1120 Resp: DeserializeOwned,
1121 {
1122 self.run_with_current_session(cancellation, progress, |session| {
1123 session.run_json_command(command, request, cancellation, progress)
1124 })
1125 }
1126
1127 pub fn run_streaming_command<Req, Row, Summary>(
1135 &mut self,
1136 command: &LeanWorkerStreamingCommand<Req, Row, Summary>,
1137 request: &Req,
1138 rows: &dyn LeanWorkerTypedDataSink<Row>,
1139 diagnostics: Option<&dyn LeanWorkerDiagnosticSink>,
1140 cancellation: Option<&LeanWorkerCancellationToken>,
1141 progress: Option<&dyn LeanWorkerProgressSink>,
1142 ) -> Result<LeanWorkerTypedStreamSummary<Summary>, LeanWorkerError>
1143 where
1144 Req: Serialize,
1145 Row: DeserializeOwned,
1146 Summary: DeserializeOwned,
1147 {
1148 self.run_with_current_session(cancellation, progress, |session| {
1149 session.run_streaming_command(command, request, rows, diagnostics, cancellation, progress)
1150 })
1151 }
1152
1153 pub fn process_module_query_batch(
1171 &mut self,
1172 source: &str,
1173 selectors: &[LeanWorkerModuleQuerySelector],
1174 budgets: &LeanWorkerOutputBudgets,
1175 options: &LeanWorkerElabOptions,
1176 cancellation: Option<&LeanWorkerCancellationToken>,
1177 progress: Option<&dyn LeanWorkerProgressSink>,
1178 ) -> Result<LeanWorkerModuleQueryBatchOutcome, LeanWorkerError> {
1179 self.run_with_current_session(cancellation, progress, |session| {
1180 session.process_module_query_batch(source, selectors, budgets, options, cancellation, progress)
1181 })
1182 }
1183
1184 fn run_with_current_session<T>(
1185 &mut self,
1186 cancellation: Option<&LeanWorkerCancellationToken>,
1187 progress: Option<&dyn LeanWorkerProgressSink>,
1188 mut command: impl FnMut(&mut LeanWorkerSession<'_>) -> Result<T, LeanWorkerError>,
1189 ) -> Result<T, LeanWorkerError> {
1190 self.ensure_valid()?;
1191 self.enforce_policy_before_request()?;
1192 let request_timeout = self.request_timeout_override;
1193 let first_command_after_open = self.entry.commands_since_open == 0;
1194 let command_started = Instant::now();
1195 let result = {
1196 let mut session = self.entry.capability.attach_open_session();
1197 if let Some(timeout) = request_timeout {
1198 session.set_request_timeout(timeout);
1199 }
1200 command(&mut session)
1201 };
1202 let result = match result {
1203 Err(err) if worker_session_missing(&err) => self
1204 .entry
1205 .capability
1206 .open_session(cancellation, progress)
1207 .and_then(|mut session| {
1208 if let Some(timeout) = request_timeout {
1209 session.set_request_timeout(timeout);
1210 }
1211 command(&mut session)
1212 }),
1213 other => other,
1214 };
1215 self.entry
1216 .capability
1217 .record_command_timing(first_command_after_open, command_started.elapsed());
1218 self.entry.commands_since_open = self.entry.commands_since_open.saturating_add(1);
1219 self.map_lifecycle_result(result)
1220 }
1221
1222 fn ensure_valid(&self) -> Result<(), LeanWorkerError> {
1223 if !self.valid {
1224 return Err(LeanWorkerError::LeaseInvalidated {
1225 reason: self.invalid_lease_reason(),
1226 });
1227 }
1228 let current_generation = self.entry.capability.lifecycle_snapshot().worker_generation;
1229 if current_generation != self.admitted_generation {
1230 return Err(LeanWorkerError::LeaseInvalidated {
1231 reason: format!(
1232 "worker generation changed from {} to {current_generation}",
1233 self.admitted_generation
1234 ),
1235 });
1236 }
1237 Ok(())
1238 }
1239
1240 fn enforce_policy_before_request(&mut self) -> Result<(), LeanWorkerError> {
1241 if let Some(reason) = self.entry.enforce_policy(&self.config)? {
1242 self.invalidate(reason.clone());
1243 return Err(LeanWorkerError::LeaseInvalidated { reason });
1244 }
1245 Ok(())
1246 }
1247
1248 fn map_lifecycle_result<T>(&mut self, result: Result<T, LeanWorkerError>) -> Result<T, LeanWorkerError> {
1249 if self.request_timeout_override.is_some() {
1250 self.entry
1251 .capability
1252 .set_request_timeout(self.entry.base_request_timeout);
1253 }
1254 match result {
1255 Ok(value) => {
1256 self.entry.last_activity = Instant::now();
1257 Ok(value)
1258 }
1259 Err(err) => {
1260 self.entry.last_activity = Instant::now();
1261 if invalidates_lease(&err) {
1262 self.invalidate(invalidation_reason(&err));
1263 }
1264 Err(err)
1265 }
1266 }
1267 }
1268
1269 fn invalidate(&mut self, reason: impl Into<String>) {
1270 self.valid = false;
1271 self.invalidation_reason = Some(reason.into());
1272 }
1273
1274 fn invalid_lease_reason(&self) -> String {
1275 self.invalidation_reason
1276 .clone()
1277 .unwrap_or_else(|| "lease was invalidated by a worker lifecycle transition".to_owned())
1278 }
1279
1280 fn release_once(&mut self) {
1281 if !self.released {
1282 self.entry.active_leases = self.entry.active_leases.saturating_sub(1);
1283 self.released = true;
1284 }
1285 }
1286}
1287
1288impl Drop for LeanWorkerSessionLease<'_> {
1289 fn drop(&mut self) {
1290 self.release_once();
1291 }
1292}
1293
1294fn invalidates_lease(err: &LeanWorkerError) -> bool {
1295 matches!(
1296 err,
1297 LeanWorkerError::Cancelled { .. }
1298 | LeanWorkerError::Timeout { .. }
1299 | LeanWorkerError::RestartLimitExceeded { .. }
1300 | LeanWorkerError::ChildExited { .. }
1301 | LeanWorkerError::ChildPanicOrAbort { .. }
1302 | LeanWorkerError::CapabilityMetadataMismatch { .. }
1303 )
1304}
1305
1306fn invalidation_reason(err: &LeanWorkerError) -> String {
1307 if let LeanWorkerError::Cancelled { operation, .. } = err {
1308 format!("cancelled during {operation}")
1309 } else if let LeanWorkerError::Timeout { operation, .. } = err {
1310 format!("timed out during {operation}")
1311 } else if matches!(err, LeanWorkerError::RestartLimitExceeded { .. }) {
1312 "worker restart limit exceeded".to_owned()
1313 } else if matches!(err, LeanWorkerError::ChildExited { .. }) {
1314 "worker child exited".to_owned()
1315 } else if matches!(err, LeanWorkerError::ChildPanicOrAbort { .. }) {
1316 "worker child exited fatally".to_owned()
1317 } else if let LeanWorkerError::CapabilityMetadataMismatch { export, .. } = err {
1318 format!("capability metadata mismatch from {export}")
1319 } else {
1320 "worker lifecycle transition".to_owned()
1321 }
1322}
1323
1324fn worker_session_missing(err: &LeanWorkerError) -> bool {
1325 matches!(err, LeanWorkerError::Worker { code, .. } if code == "lean_rs.worker.session_missing")
1326}
1327
1328fn normalize_import_workspace_root(path: PathBuf) -> PathBuf {
1329 std::fs::canonicalize(&path).unwrap_or(path)
1330}