1use std::sync::{Arc, Mutex, OnceLock};
2
3use async_trait::async_trait;
4use authn_resolver_sdk::{AuthNResolverClient, ClientCredentialsRequest};
5use authz_resolver_sdk::AuthZResolverClient;
6use std::time::Duration;
7use toolkit::api::OpenApiRegistry;
8use toolkit::contracts::RunnableCapability;
9use toolkit::{DatabaseCapability, Gear, GearCtx, RestApiCapability};
10
11use oagw_sdk::ServiceGatewayClientV1;
12use sea_orm_migration::MigrationTrait;
13use tokio_util::sync::CancellationToken;
14use toolkit_db::outbox::{LeaseConfig, Outbox, OutboxHandle, Partitions};
15use tracing::info;
16
17use crate::api::rest::routes;
18use crate::background_workers::{self, WORKER_STOP_TIMEOUT, WorkerConfigs};
19use crate::config::ProviderEntry;
20use crate::domain::ports::MiniChatMetricsPort;
21use crate::domain::service::{AppServices as GenericAppServices, Repositories};
22use crate::infra::metrics::MiniChatMetricsMeter;
23use crate::infra::outbox::{AuditEventHandler, InfraOutboxEnqueuer, UsageEventHandler};
24use crate::infra::workers::WorkerHandles;
25
26pub(crate) type AppServices = GenericAppServices<
27 TurnRepository,
28 MessageRepository,
29 QuotaUsageRepository,
30 ReactionRepository,
31 ChatRepository,
32 ThreadSummaryRepository,
33 AttachmentRepository,
34 VectorStoreRepository,
35 MessageAttachmentRepository,
36>;
37use crate::infra::audit_gateway::AuditGateway;
38use crate::infra::db::repo::attachment_repo::AttachmentRepository;
39use crate::infra::db::repo::chat_repo::ChatRepository;
40use crate::infra::db::repo::message_attachment_repo::MessageAttachmentRepository;
41use crate::infra::db::repo::message_repo::MessageRepository;
42use crate::infra::db::repo::quota_usage_repo::QuotaUsageRepository;
43use crate::infra::db::repo::reaction_repo::ReactionRepository;
44use crate::infra::db::repo::thread_summary_repo::ThreadSummaryRepository;
45use crate::infra::db::repo::turn_repo::TurnRepository;
46use crate::infra::db::repo::vector_store_repo::VectorStoreRepository;
47use crate::infra::llm::provider_resolver::ProviderResolver;
48use crate::infra::model_policy::ModelPolicyGateway;
49
50pub const DEFAULT_URL_PREFIX: &str = "/mini-chat";
52
53#[toolkit::gear(
55 name = "mini-chat",
56 deps = [types_registry, authn_resolver, authz_resolver, oagw],
57 capabilities = [db, rest, stateful],
58)]
59pub struct MiniChatGear {
60 service: OnceLock<Arc<AppServices>>,
61 url_prefix: OnceLock<String>,
62 outbox_handle: Mutex<Option<OutboxHandle>>,
63 oagw_deferred: OnceLock<OagwDeferred>,
65 worker_configs: OnceLock<WorkerConfigs>,
67 worker_cancel: Mutex<Option<CancellationToken>>,
68 worker_handles: Mutex<Option<WorkerHandles>>,
70 outbox_deferred: OnceLock<OutboxDeferred>,
72}
73
74struct OagwDeferred {
76 gateway: Arc<dyn ServiceGatewayClientV1>,
77 authn: Arc<dyn AuthNResolverClient>,
78 client_credentials: crate::config::ClientCredentialsConfig,
79 providers: std::collections::HashMap<String, ProviderEntry>,
80}
81
82struct OutboxDeferred {
85 db: Arc<crate::domain::service::DbProvider>,
86 outbox_config: crate::config::OutboxConfig,
87 cleanup_config: crate::config::background::CleanupWorkerConfig,
88 model_policy_gw: Arc<ModelPolicyGateway>,
89 audit_gateway: Arc<AuditGateway>,
90 file_storage: Arc<dyn crate::domain::ports::FileStorageProvider>,
91 vector_store_prov: Arc<dyn crate::domain::ports::VectorStoreProvider>,
92 metrics: Arc<dyn MiniChatMetricsPort>,
93 enqueuer: Arc<InfraOutboxEnqueuer>,
94 provider_resolver: Arc<crate::infra::llm::provider_resolver::ProviderResolver>,
95 model_resolver: Arc<dyn crate::domain::repos::ModelResolver>,
96 thread_summary_config: crate::config::background::ThreadSummaryWorkerConfig,
97 anthropic_files_client:
101 Option<Arc<crate::infra::llm::providers::anthropic_files_client::AnthropicFilesClient>>,
102}
103
104impl Default for MiniChatGear {
105 fn default() -> Self {
106 Self {
107 service: OnceLock::new(),
108 url_prefix: OnceLock::new(),
109 outbox_handle: Mutex::new(None),
110 oagw_deferred: OnceLock::new(),
111 worker_configs: OnceLock::new(),
112 worker_cancel: Mutex::new(None),
113 worker_handles: Mutex::new(None),
114 outbox_deferred: OnceLock::new(),
115 }
116 }
117}
118
119#[allow(clippy::too_many_lines)]
120#[async_trait]
121impl Gear for MiniChatGear {
122 async fn init(&self, ctx: &GearCtx) -> anyhow::Result<()> {
123 info!("Initializing {} gear", Self::MODULE_NAME);
124
125 let mut cfg: crate::config::MiniChatConfig = ctx.config_expanded_or_default()?;
126 cfg.streaming
127 .validate()
128 .map_err(|e| anyhow::anyhow!("streaming config: {e}"))?;
129 cfg.estimation_budgets
130 .validate()
131 .map_err(|e| anyhow::anyhow!("estimation_budgets config: {e}"))?;
132 cfg.quota
133 .validate()
134 .map_err(|e| anyhow::anyhow!("quota config: {e}"))?;
135 cfg.outbox
136 .validate()
137 .map_err(|e| anyhow::anyhow!("outbox config: {e}"))?;
138 cfg.context
139 .validate()
140 .map_err(|e| anyhow::anyhow!("context config: {e}"))?;
141 cfg.client_credentials
142 .validate()
143 .map_err(|e| anyhow::anyhow!("client_credentials config: {e}"))?;
144 for (id, entry) in &cfg.providers {
145 entry
146 .validate(id)
147 .map_err(|e| anyhow::anyhow!("providers config: {e}"))?;
148 }
149 cfg.validate_provider_refs()
150 .map_err(|e| anyhow::anyhow!("providers config: {e}"))?;
151 cfg.orphan_watchdog
152 .validate()
153 .map_err(|e| anyhow::anyhow!("orphan_watchdog config: {e}"))?;
154 cfg.thread_summary_worker
155 .validate()
156 .map_err(|e| anyhow::anyhow!("thread_summary_worker config: {e}"))?;
157 cfg.cleanup_worker
158 .validate()
159 .map_err(|e| anyhow::anyhow!("cleanup_worker config: {e}"))?;
160 cfg.thumbnail
161 .validate()
162 .map_err(|e| anyhow::anyhow!("thumbnail config: {e}"))?;
163 cfg.rag
164 .validate()
165 .map_err(|e| anyhow::anyhow!("rag config: {e}"))?;
166 cfg.knowledge_search
167 .validate()
168 .map_err(|e| anyhow::anyhow!("knowledge_search config: {e}"))?;
169
170 let vendor = cfg.vendor.trim().to_owned();
171 if vendor.is_empty() {
172 return Err(anyhow::anyhow!(
173 "{}: vendor must be a non-empty string",
174 Self::MODULE_NAME
175 ));
176 }
177
178 self.url_prefix
184 .set(cfg.url_prefix)
185 .map_err(|_| anyhow::anyhow!("{} url_prefix already set", Self::MODULE_NAME))?;
186
187 let db_provider = ctx.db_required()?;
188 let db = Arc::new(db_provider);
189
190 let model_policy_gw = Arc::new(ModelPolicyGateway::new(ctx.client_hub(), vendor.clone()));
192
193 let audit_gateway = Arc::new(AuditGateway::new(ctx.client_hub(), vendor));
195
196 let authz = ctx
199 .client_hub()
200 .get::<dyn AuthZResolverClient>()
201 .map_err(|e| anyhow::anyhow!("failed to get AuthZ resolver: {e}"))?;
202
203 let authn_client = ctx
204 .client_hub()
205 .get::<dyn AuthNResolverClient>()
206 .map_err(|e| anyhow::anyhow!("failed to get AuthN resolver: {e}"))?;
207
208 let gateway = ctx
209 .client_hub()
210 .get::<dyn ServiceGatewayClientV1>()
211 .map_err(|e| anyhow::anyhow!("failed to get OAGW gateway: {e}"))?;
212
213 for entry in cfg.providers.values_mut() {
218 if entry.upstream_alias.is_none() {
219 entry.upstream_alias = Some(entry.host.clone());
220 }
221 for ovr in entry.tenant_overrides.values_mut() {
222 if ovr.upstream_alias.is_none()
223 && let Some(ref h) = ovr.host
224 {
225 ovr.upstream_alias = Some(h.clone());
226 }
227 }
228 }
229
230 drop(self.oagw_deferred.set(OagwDeferred {
233 gateway: Arc::clone(&gateway),
234 authn: Arc::clone(&authn_client),
235 client_credentials: cfg.client_credentials.clone(),
236 providers: cfg.providers.clone(),
237 }));
238
239 let provider_resolver = Arc::new(ProviderResolver::new(&gateway, cfg.providers));
240
241 let repos = Repositories {
242 chat: Arc::new(ChatRepository::new(toolkit_db::odata::LimitCfg {
243 default: 20,
244 max: 100,
245 })),
246 attachment: Arc::new(AttachmentRepository),
247 message: Arc::new(MessageRepository::new(toolkit_db::odata::LimitCfg {
248 default: 20,
249 max: 100,
250 })),
251 quota: Arc::new(QuotaUsageRepository),
252 turn: Arc::new(TurnRepository),
253 reaction: Arc::new(ReactionRepository),
254 thread_summary: Arc::new(ThreadSummaryRepository),
255 vector_store: Arc::new(VectorStoreRepository),
256 message_attachment: Arc::new(MessageAttachmentRepository),
257 };
258
259 let rag_client = Arc::new(
260 crate::infra::llm::providers::rag_http_client::RagHttpClient::new(Arc::clone(&gateway)),
261 );
262
263 let mut file_impls: std::collections::HashMap<
266 String,
267 Arc<dyn crate::domain::ports::FileStorageProvider>,
268 > = std::collections::HashMap::new();
269 let mut vs_impls: std::collections::HashMap<
270 String,
271 Arc<dyn crate::domain::ports::VectorStoreProvider>,
272 > = std::collections::HashMap::new();
273 for (provider_id, entry) in provider_resolver.entries() {
274 let (file, vs): (
275 Arc<dyn crate::domain::ports::FileStorageProvider>,
276 Arc<dyn crate::domain::ports::VectorStoreProvider>,
277 ) = match entry.storage_kind {
278 crate::config::StorageKind::Azure => {
279 let api_version = entry.api_version.clone().unwrap_or_else(|| {
280 panic!(
281 "provider '{provider_id}': storage_kind is 'azure' \
282 but api_version is not set"
283 )
284 });
285 (
286 Arc::new(
287 crate::infra::llm::providers::azure_file_storage::AzureFileStorage::new(
288 Arc::clone(&rag_client),
289 Arc::clone(&provider_resolver),
290 api_version.clone(),
291 ),
292 ),
293 Arc::new(
294 crate::infra::llm::providers::azure_vector_store::AzureVectorStore::new(
295 Arc::clone(&rag_client),
296 Arc::clone(&provider_resolver),
297 api_version,
298 ),
299 ),
300 )
301 }
302 crate::config::StorageKind::OpenAi => (
303 Arc::new(
304 crate::infra::llm::providers::openai_file_storage::OpenAiFileStorage::new(
305 Arc::clone(&rag_client),
306 Arc::clone(&provider_resolver),
307 ),
308 ),
309 Arc::new(
310 crate::infra::llm::providers::openai_vector_store::OpenAiVectorStore::new(
311 Arc::clone(&rag_client),
312 Arc::clone(&provider_resolver),
313 ),
314 ),
315 ),
316 };
317 file_impls.insert(provider_id.clone(), file);
318 vs_impls.insert(provider_id.clone(), vs);
319 }
320 let file_storage: Arc<dyn crate::domain::ports::FileStorageProvider> = Arc::new(
321 crate::infra::llm::providers::dispatching_storage::DispatchingFileStorage::new(
322 file_impls,
323 ),
324 );
325 let vector_store_prov: Arc<dyn crate::domain::ports::VectorStoreProvider> = Arc::new(
326 crate::infra::llm::providers::dispatching_storage::DispatchingVectorStore::new(
327 vs_impls,
328 ),
329 );
330
331 let metrics_prefix = cfg.metrics.effective_prefix(Self::MODULE_NAME);
334 let scope =
335 opentelemetry::InstrumentationScope::builder(Self::MODULE_NAME.to_owned()).build();
336 let metrics: Arc<dyn MiniChatMetricsPort> = Arc::new(MiniChatMetricsMeter::new(
337 &opentelemetry::global::meter_with_scope(scope),
338 &metrics_prefix,
339 ));
340
341 let outbox_enqueuer = Arc::new(InfraOutboxEnqueuer::new(
349 cfg.outbox.queue_name.clone(),
350 cfg.outbox.cleanup_queue_name.clone(),
351 cfg.outbox.chat_cleanup_queue_name.clone(),
352 cfg.outbox.thread_summary_queue_name.clone(),
353 cfg.outbox.audit_queue_name.clone(),
354 cfg.outbox.num_partitions,
355 ));
356
357 let knowledge_retriever: Option<Arc<dyn crate::domain::ports::KnowledgeRetriever>> = if cfg
360 .knowledge_search
361 .enabled
362 {
363 Some(Arc::new(
364 crate::infra::llm::providers::azure_knowledge_retriever::AzureKnowledgeRetriever::new(
365 Arc::clone(&rag_client),
366 ),
367 ))
368 } else {
369 None
370 };
371
372 let anthropic_files_client = if provider_resolver.entries().values().any(|entry| {
383 matches!(
384 entry.kind,
385 crate::infra::llm::providers::ProviderKind::AnthropicMessages
386 )
387 }) {
388 Some(Arc::new(
389 crate::infra::llm::providers::anthropic_files_client::AnthropicFilesClient::new(
390 Arc::clone(&gateway),
391 ),
392 ))
393 } else {
394 None
395 };
396
397 drop(self.outbox_deferred.set(OutboxDeferred {
399 db: Arc::clone(&db),
400 outbox_config: cfg.outbox,
401 cleanup_config: cfg.cleanup_worker,
402 model_policy_gw: model_policy_gw.clone(),
403 audit_gateway: Arc::clone(&audit_gateway),
404 file_storage: Arc::clone(&file_storage),
405 vector_store_prov: Arc::clone(&vector_store_prov),
406 metrics: Arc::clone(&metrics),
407 enqueuer: Arc::clone(&outbox_enqueuer),
408 provider_resolver: Arc::clone(&provider_resolver),
409 model_resolver: model_policy_gw.clone() as Arc<dyn crate::domain::repos::ModelResolver>,
410 thread_summary_config: cfg.thread_summary_worker.clone(),
411 anthropic_files_client: anthropic_files_client.clone(),
412 }));
413
414 let services = Arc::new(AppServices::new(
417 &repos,
418 db,
419 authz,
420 &(model_policy_gw.clone() as Arc<dyn crate::domain::repos::ModelResolver>),
421 &provider_resolver,
422 cfg.streaming,
423 model_policy_gw.clone() as Arc<dyn crate::domain::repos::PolicySnapshotProvider>,
424 model_policy_gw as Arc<dyn crate::domain::repos::UserLimitsProvider>,
425 cfg.estimation_budgets,
426 cfg.quota,
427 &(outbox_enqueuer as Arc<dyn crate::domain::repos::OutboxEnqueuer>),
428 cfg.context,
429 file_storage,
430 vector_store_prov,
431 cfg.rag,
432 cfg.thumbnail,
433 metrics,
434 cfg.thread_summary_worker,
435 cfg.knowledge_search,
436 knowledge_retriever,
437 anthropic_files_client,
438 ));
439
440 self.service
441 .set(services)
442 .map_err(|_| anyhow::anyhow!("{} gear already initialized", Self::MODULE_NAME))?;
443
444 self.worker_configs
445 .set(WorkerConfigs {
446 orphan_watchdog: cfg.orphan_watchdog,
447 })
448 .map_err(|_| anyhow::anyhow!("{} worker_configs already set", Self::MODULE_NAME))?;
449
450 info!("{} gear initialized successfully", Self::MODULE_NAME);
451 Ok(())
452 }
453}
454
455impl DatabaseCapability for MiniChatGear {
456 fn migrations(&self) -> Vec<Box<dyn MigrationTrait>> {
457 use sea_orm_migration::MigratorTrait;
458 info!("Providing mini-chat database migrations");
459 let mut m = crate::infra::db::migrations::Migrator::migrations();
460 m.extend(toolkit_db::outbox::outbox_migrations());
461 m
462 }
463}
464
465impl RestApiCapability for MiniChatGear {
466 fn register_rest(
467 &self,
468 _ctx: &GearCtx,
469 router: axum::Router,
470 openapi: &dyn OpenApiRegistry,
471 ) -> anyhow::Result<axum::Router> {
472 let services = self
473 .service
474 .get()
475 .ok_or_else(|| anyhow::anyhow!("{} not initialized", Self::MODULE_NAME))?;
476
477 info!("Registering mini-chat REST routes");
478 let prefix = self
479 .url_prefix
480 .get()
481 .ok_or_else(|| anyhow::anyhow!("{} not initialized (url_prefix)", Self::MODULE_NAME))?;
482
483 let router = routes::register_routes(router, openapi, Arc::clone(services), prefix);
484 info!("Mini-chat REST routes registered successfully");
485 Ok(router)
486 }
487}
488
489#[async_trait]
490impl RunnableCapability for MiniChatGear {
491 async fn start(&self, cancel: CancellationToken) -> anyhow::Result<()> {
492 let wc = self.worker_configs.get().ok_or_else(|| {
493 anyhow::anyhow!(
494 "{} worker_configs not set - init() must run before start()",
495 Self::MODULE_NAME
496 )
497 })?;
498 let leader_elector = background_workers::prepare_worker_runtime(wc).await?;
499
500 if let Some(deferred) = self.oagw_deferred.get() {
505 let ctx =
506 exchange_client_credentials(&deferred.authn, &deferred.client_credentials).await?;
507 let mut providers = deferred.providers.clone();
508 let report = crate::infra::oagw_provisioning::register_oagw_upstreams(
509 &deferred.gateway,
510 &ctx,
511 &mut providers,
512 )
513 .await?;
514 report.ensure_no_misconfigured()?;
520 let deferred_ids = report.deferred;
521
522 if !deferred_ids.is_empty() {
530 tracing::warn!(
531 deferred = ?deferred_ids,
532 "OAGW upstreams deferred at boot (secret not yet accessible); \
533 retrying registration in the background"
534 );
535 let gateway = Arc::clone(&deferred.gateway);
536 let authn = Arc::clone(&deferred.authn);
537 let creds = deferred.client_credentials.clone();
538 let providers = providers.clone();
539 let cancel = cancel.clone();
540 tokio::spawn(reconcile_deferred_upstreams_with_retry(
541 gateway,
542 authn,
543 creds,
544 providers,
545 deferred_ids,
546 cancel,
547 ));
548 }
549 }
550
551 if let Some(od) = self.outbox_deferred.get() {
554 let outbox_db = od.db.db();
555 let num_partitions = od.outbox_config.num_partitions;
556 let max_cleanup_attempts = od.cleanup_config.max_attempts;
557
558 let partitions = Partitions::of(
559 u16::try_from(num_partitions)
560 .map_err(|_| anyhow::anyhow!("num_partitions exceeds u16"))?,
561 );
562
563 let outbox_handle = Outbox::builder(outbox_db)
564 .queue(&od.outbox_config.queue_name, partitions)
565 .leased(UsageEventHandler {
566 plugin_provider: od.model_policy_gw.clone(),
567 })
568 .queue(&od.outbox_config.cleanup_queue_name, partitions)
569 .leased(
570 crate::infra::workers::cleanup_worker::AttachmentCleanupHandler::new(
571 Arc::clone(&od.file_storage),
572 Arc::clone(&od.db),
573 ChatRepository::new(toolkit_db::odata::LimitCfg {
574 default: 20,
575 max: 100,
576 }),
577 max_cleanup_attempts,
578 Arc::clone(&od.metrics),
579 od.anthropic_files_client.clone(),
580 ),
581 )
582 .queue(&od.outbox_config.chat_cleanup_queue_name, partitions)
583 .leased(
584 crate::infra::workers::cleanup_worker::ChatCleanupHandler::new(
585 Arc::clone(&od.file_storage),
586 Arc::clone(&od.vector_store_prov),
587 Arc::clone(&od.db),
588 ChatRepository::new(toolkit_db::odata::LimitCfg {
589 default: 20,
590 max: 100,
591 }),
592 max_cleanup_attempts,
593 Arc::clone(&od.metrics),
594 od.anthropic_files_client.clone(),
595 ),
596 )
597 .queue(&od.outbox_config.thread_summary_queue_name, partitions)
598 .leased(
599 crate::infra::workers::thread_summary_worker::ThreadSummaryHandler::new(
600 Arc::new(
601 crate::infra::workers::thread_summary_worker::ThreadSummaryDeps {
602 db: Arc::clone(&od.db),
603 thread_summary_repo: Arc::new(ThreadSummaryRepository),
604 message_repo: Arc::new(MessageRepository::new(
605 toolkit_db::odata::LimitCfg {
606 default: 20,
607 max: 100,
608 },
609 )),
610 outbox_enqueuer: Arc::clone(&od.enqueuer)
611 as Arc<dyn crate::domain::repos::OutboxEnqueuer>,
612 metrics: Arc::clone(&od.metrics),
613 provider_resolver: Arc::clone(&od.provider_resolver),
614 model_resolver: Arc::clone(&od.model_resolver),
615 config: od.thread_summary_config.clone(),
616 },
617 ),
618 ),
619 )
620 .queue(&od.outbox_config.audit_queue_name, partitions)
621 .leased(AuditEventHandler {
622 audit_gateway: Arc::clone(&od.audit_gateway),
623 metrics: Arc::clone(&od.metrics),
624 })
625 .lease(LeaseConfig {
626 duration: Duration::from_mins(1),
627 ..LeaseConfig::default()
628 })
629 .start()
630 .await
631 .map_err(|e| anyhow::anyhow!("outbox start: {e}"))?;
632
633 od.enqueuer.set_outbox(Arc::clone(outbox_handle.outbox()));
635
636 let mut guard = self
637 .outbox_handle
638 .lock()
639 .map_err(|e| anyhow::anyhow!("outbox_handle lock: {e}"))?;
640 *guard = Some(outbox_handle);
641
642 info!("Outbox pipeline started (OAGW ready)");
643 }
644
645 let orphan_deps = if wc.orphan_watchdog.enabled {
646 let services = self.service.get().ok_or_else(|| {
647 anyhow::anyhow!(
648 "{} not initialized - init() must run before start()",
649 Self::MODULE_NAME
650 )
651 })?;
652 Some(crate::infra::workers::orphan_watchdog::OrphanWatchdogDeps {
653 finalization_svc: Arc::clone(&services.finalization),
654 turn_repo: Arc::clone(&services.turn_repo),
655 db: Arc::clone(&services.db),
656 metrics: Arc::clone(&services.metrics),
657 })
658 } else {
659 None
660 };
661
662 let (handles, worker_cancel) =
663 background_workers::spawn_workers(wc, &cancel, leader_elector.as_ref(), orphan_deps)?;
664 self.store_worker_runtime(handles, worker_cancel).await?;
665
666 Ok(())
667 }
668
669 async fn stop(&self, cancel: CancellationToken) -> anyhow::Result<()> {
670 if let Some(worker_cancel) = self
671 .worker_cancel
672 .lock()
673 .map_err(|e| anyhow::anyhow!("worker_cancel lock: {e}"))?
674 .take()
675 {
676 worker_cancel.cancel();
677 }
678
679 let workers = self
680 .worker_handles
681 .lock()
682 .map_err(|e| anyhow::anyhow!("worker_handles lock: {e}"))?
683 .take();
684 if let Some(handles) = workers {
685 info!("Waiting for background workers to stop");
686 handles.join_all(cancel.clone(), WORKER_STOP_TIMEOUT).await;
687 info!("Background workers stopped");
688 }
689
690 let handle = self
691 .outbox_handle
692 .lock()
693 .map_err(|e| anyhow::anyhow!("outbox_handle lock: {e}"))?
694 .take();
695 if let Some(handle) = handle {
696 info!("Stopping outbox pipeline");
697 tokio::select! {
698 () = handle.stop() => {
699 info!("Outbox pipeline stopped");
700 }
701 () = cancel.cancelled() => {
702 info!("Outbox pipeline stop cancelled by framework deadline");
703 }
704 }
705 }
706 Ok(())
707 }
708}
709
710impl MiniChatGear {
711 async fn store_worker_runtime(
712 &self,
713 handles: WorkerHandles,
714 worker_cancel: CancellationToken,
715 ) -> anyhow::Result<()> {
716 let worker_cancel_cleanup = worker_cancel.clone();
717
718 let cancel_already_set = {
720 let mut guard = self
721 .worker_cancel
722 .lock()
723 .map_err(|e| anyhow::anyhow!("worker_cancel lock: {e}"))?;
724 if guard.is_some() {
725 true
726 } else {
727 *guard = Some(worker_cancel);
728 false
729 }
730 };
732 if cancel_already_set {
733 worker_cancel_cleanup.cancel();
734 let hard_stop = CancellationToken::new();
735 hard_stop.cancel();
736 handles.join_all(hard_stop, WORKER_STOP_TIMEOUT).await;
737 anyhow::bail!("{} worker_cancel already set", Self::MODULE_NAME);
738 }
739
740 let mut handles = Some(handles);
742 let handles_err = {
743 match self.worker_handles.lock() {
744 Ok(mut guard) => {
745 if guard.is_some() {
746 Some("worker_handles already set".to_owned())
747 } else {
748 *guard = handles.take();
749 None
750 }
751 }
752 Err(e) => Some(format!("worker_handles lock: {e}")),
753 }
754 };
756 if let Some(msg) = handles_err {
757 if let Ok(mut cancel_guard) = self.worker_cancel.lock() {
758 cancel_guard.take();
759 }
760 worker_cancel_cleanup.cancel();
761 if let Some(handles) = handles {
762 let hard_stop = CancellationToken::new();
763 hard_stop.cancel();
764 handles.join_all(hard_stop, WORKER_STOP_TIMEOUT).await;
765 }
766 anyhow::bail!("{} {msg}", Self::MODULE_NAME);
772 }
773 Ok(())
774 }
775}
776
777#[allow(clippy::cognitive_complexity)]
792async fn reconcile_deferred_upstreams_with_retry(
793 gateway: Arc<dyn ServiceGatewayClientV1>,
794 authn: Arc<dyn AuthNResolverClient>,
795 creds: crate::config::ClientCredentialsConfig,
796 providers: std::collections::HashMap<String, ProviderEntry>,
797 mut deferred: Vec<String>,
798 cancel: CancellationToken,
799) {
800 const RETRY_INTERVAL_MIN: Duration = Duration::from_secs(2);
801 const RETRY_INTERVAL_MAX: Duration = Duration::from_mins(1);
802 const WARN_AFTER: Duration = Duration::from_mins(2);
807
808 let started = tokio::time::Instant::now();
809 let mut warned = false;
810 let mut backoff = RETRY_INTERVAL_MIN;
811 let mut attempt: u32 = 0;
812 loop {
813 tokio::select! {
814 () = cancel.cancelled() => {
815 info!(remaining = deferred.len(), "OAGW upstream reconcile cancelled");
816 return;
817 }
818 () = tokio::time::sleep(backoff) => {}
819 }
820
821 attempt = attempt.saturating_add(1);
822 deferred =
823 reconcile_deferred_once(&gateway, &authn, &creds, &providers, deferred, attempt).await;
824 if deferred.is_empty() {
825 info!("OAGW reconcile: all deferred provider upstreams registered");
826 return;
827 }
828
829 if !warned && started.elapsed() >= WARN_AFTER {
830 warned = true;
831 tracing::warn!(
832 remaining = ?deferred,
833 elapsed_secs = started.elapsed().as_secs(),
834 "OAGW reconcile: provider(s) still UNAVAILABLE; will keep retrying at a slower \
835 cadence until their secret is provisioned. Check for a missing secret or a \
836 misconfigured secret_ref/host for these providers"
837 );
838 }
839 backoff = (backoff * 2).min(RETRY_INTERVAL_MAX);
840 }
841}
842
843#[allow(clippy::cognitive_complexity)]
849async fn reconcile_deferred_once(
850 gateway: &Arc<dyn ServiceGatewayClientV1>,
851 authn: &Arc<dyn AuthNResolverClient>,
852 creds: &crate::config::ClientCredentialsConfig,
853 providers: &std::collections::HashMap<String, ProviderEntry>,
854 deferred: Vec<String>,
855 attempt: u32,
856) -> Vec<String> {
857 let ctx = match exchange_client_credentials(authn, creds).await {
858 Ok(ctx) => ctx,
859 Err(e) => {
860 tracing::warn!(error = %e, attempt, "OAGW reconcile: credential exchange failed; will retry");
861 return deferred;
862 }
863 };
864
865 match crate::infra::oagw_provisioning::reconcile_deferred_upstreams(
866 gateway, &ctx, providers, &deferred,
867 )
868 .await
869 {
870 Ok(still_deferred) => {
871 let registered = deferred.len().saturating_sub(still_deferred.len());
872 if registered > 0 {
873 info!(
874 registered,
875 remaining = still_deferred.len(),
876 attempt,
877 "OAGW reconcile: registered deferred provider upstream(s)"
878 );
879 }
880 still_deferred
881 }
882 Err(e) => {
883 tracing::warn!(error = %e, attempt, "OAGW reconcile attempt failed; will retry");
884 deferred
885 }
886 }
887}
888
889async fn exchange_client_credentials(
892 authn: &Arc<dyn AuthNResolverClient>,
893 creds: &crate::config::ClientCredentialsConfig,
894) -> anyhow::Result<toolkit_security::SecurityContext> {
895 info!("Exchanging client credentials for OAGW provisioning context");
896 let request = ClientCredentialsRequest {
897 client_id: creds.client_id.clone(),
898 client_secret: creds.client_secret.clone(),
899 scopes: Vec::new(),
900 };
901 let result = authn
902 .exchange_client_credentials(&request)
903 .await
904 .map_err(|e| anyhow::anyhow!("client credentials exchange failed: {e}"))?;
905 info!("Security context obtained for OAGW provisioning");
906 Ok(result.security_context)
907}