platform_core/app_starter.rs
1//
2// Copyright 2018-2026 Accenture Technology
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8// http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15//
16
17//! Rust port of the Java application lifecycle — `AutoStart` + `AppStarter`
18//! (`org.platformlambda.core.system`) and the `EntryPoint` contract
19//! (`org.platformlambda.core.models.EntryPoint`).
20//!
21//! Startup order is the Java sequence exactly:
22//!
23//! 1. **Essential services** (the Java `EssentialServiceLoader`, sequence 0 —
24//! reserved for the framework): here, the elastic store's housekeeping
25//! (holding-area liveness marker, keep-alive refresh, expired-store scan).
26//! 2. **Before-application hooks** (`@BeforeApplication`), ordered by
27//! `sequence` (1–999, lower first; user code conventionally uses 3–999) —
28//! validation/compilation work before anything is registered or served
29//! (e.g. event-script's `CompileFlows` in Java). A failure **aborts
30//! startup** (Java parity: fatal).
31//! 3. **Preload** (`@PreLoad`): composable functions are registered and bound
32//! to their routes — callable via `PostOffice` from this point on.
33//! 4. The HTTP server would start here when `rest.automation=true` — REST
34//! automation is a later increment; the flag currently logs a notice.
35//! 5. **Main applications** (`@MainApplication`), ordered by `sequence`.
36//! A missing main application is an error (Java parity).
37//!
38//! Java discovers all five phases by classpath annotation scanning; Rust has
39//! no runtime scanning (design D6), so [`AppStarter`] is an explicit
40//! **builder** — a `#[preload]`-style proc-macro can add the ergonomic layer
41//! in a later increment. Java's `AutoStart` global run-once guard is not
42//! ported: the builder is consumed by [`run`](AppStarter::run), and the
43//! framework phases behind it are idempotent (deliberate divergence,
44//! test-friendly).
45
46use std::sync::Arc;
47
48use async_trait::async_trait;
49
50use crate::function::{AppError, ComposableFunction};
51use crate::platform::{FunctionOptions, Platform};
52use crate::util::app_config_reader::AppConfigReader;
53use crate::util::{elastic_queue, managed_cache};
54
55/// Highest allowed hook sequence (Java `MAX_SEQ`); larger values clamp to it.
56const MAX_SEQ: u32 = 999;
57
58/// The application entry-point contract (Java `EntryPoint`): implemented by
59/// both before-application hooks and main applications.
60#[async_trait]
61pub trait EntryPoint: Send + Sync {
62 async fn start(&self, args: &[String]) -> Result<(), AppError>;
63}
64
65/// The lifecycle builder (Java `AutoStart`/`AppStarter`, explicit-registration
66/// form). Build the application declaratively, then [`run`](Self::run) it:
67///
68/// ```ignore
69/// AppStarter::new()
70/// .before_application(5, Arc::new(CompileCheck))
71/// .preload("greeting.demo", TypedAdapter::arc(Greetings), 10)
72/// .main_application(1, Arc::new(MainApp))
73/// .run(std::env::args().collect())
74/// .await?;
75/// ```
76#[derive(Default)]
77pub struct AppStarter {
78 before: Vec<(u32, Arc<dyn EntryPoint>)>,
79 preloads: Vec<(String, Arc<dyn ComposableFunction>, usize, FunctionOptions)>,
80 mains: Vec<(u32, Arc<dyn EntryPoint>)>,
81}
82
83impl AppStarter {
84 pub fn new() -> Self {
85 Self::default()
86 }
87
88 /// Register a before-application hook (Java `@BeforeApplication`).
89 /// `sequence` orders execution (lower first, clamped to 999; 0 is reserved
90 /// for the framework's essential services — user code uses 1–999,
91 /// conventionally 3–999).
92 pub fn before_application(mut self, sequence: u32, hook: Arc<dyn EntryPoint>) -> Self {
93 self.before.push((sequence.min(MAX_SEQ), hook));
94 self
95 }
96
97 /// Register a composable function at startup (Java `@PreLoad`).
98 pub fn preload(
99 self,
100 route: &str,
101 function: Arc<dyn ComposableFunction>,
102 instances: usize,
103 ) -> Self {
104 self.preload_with_options(route, function, instances, FunctionOptions::default())
105 }
106
107 /// Register a composable function with explicit options (Java `@PreLoad`
108 /// combined with `@ZeroTracing` and/or `@EventInterceptor`).
109 pub fn preload_with_options(
110 mut self,
111 route: &str,
112 function: Arc<dyn ComposableFunction>,
113 instances: usize,
114 options: FunctionOptions,
115 ) -> Self {
116 self.preloads
117 .push((route.to_string(), function, instances, options));
118 self
119 }
120
121 /// Register a main application (Java `@MainApplication`); `sequence`
122 /// orders execution when there is more than one (lower first).
123 pub fn main_application(mut self, sequence: u32, entry: Arc<dyn EntryPoint>) -> Self {
124 self.mains.push((sequence.min(MAX_SEQ), entry));
125 self
126 }
127
128 /// Run the lifecycle (Java `AutoStart.main` → `AppStarter.main`).
129 /// Must be called within a Tokio runtime.
130 pub async fn run(self, args: Vec<String>) -> Result<(), AppError> {
131 // -Dkey=value runtime arguments (the JVM -D analog) become process
132 // overrides — idempotent, in case logging::init already loaded them
133 crate::util::overrides::load_runtime_args();
134 let config = AppConfigReader::get_instance();
135 log::info!(
136 "Starting application {} (platform-core v{})",
137 Platform::name(),
138 env!("CARGO_PKG_VERSION")
139 );
140 // 1. essential services (sequence 0, framework-reserved — the Java
141 // EssentialServiceLoader, which registers async.http.request (500),
142 // temporary.inbox (500) and distributed.tracing (1) at the HIGHEST
143 // startup priority, before any user hook or preload): elastic-store
144 // housekeeping, the RPC reply listener, the telemetry sink (a
145 // deliberate singleton — serializes trace-record processing to
146 // preserve ordering; the event system buffers bursts), the event
147 // API, actuators, no.op and the HTTP client (all idempotent)
148 elastic_queue::start_housekeeping();
149 managed_cache::start_housekeeping();
150 let platform = Platform::get_instance();
151 // the RPC reply listener is registered at Platform construction (so
152 // every registry has it from birth); assert it here like the Java
153 // loader does. Reply dispatch is DIRECT on the sender's runtime (see
154 // RESERVED_ENGINE_ROUTES), so the entry is the addressable identity
155 if !platform.has_route(crate::inbox::TEMPORARY_INBOX) {
156 if let Err(e) = platform.register_with_options(
157 crate::inbox::TEMPORARY_INBOX,
158 Arc::new(crate::inbox::TemporaryInbox),
159 500,
160 crate::platform::FunctionOptions {
161 zero_traced: true,
162 interceptor: false,
163 private: true,
164 },
165 ) {
166 if !platform.has_route(crate::inbox::TEMPORARY_INBOX) {
167 return Err(e);
168 }
169 }
170 }
171 if !platform.has_route(crate::telemetry::DISTRIBUTED_TRACING) {
172 if let Err(e) = platform.register_private(
173 crate::telemetry::DISTRIBUTED_TRACING,
174 std::sync::Arc::new(crate::telemetry::Telemetry::new(&platform)),
175 1,
176 ) {
177 // a concurrent lifecycle run may have won the race — benign;
178 // anything else is a real startup failure
179 if !platform.has_route(crate::telemetry::DISTRIBUTED_TRACING) {
180 return Err(e);
181 }
182 }
183 }
184 // the event-over-http service (Java EssentialServiceLoader): reached
185 // through REST automation's default /api/event route. Registered
186 // PRIVATE — it is never itself a remote target. 250 workers (Java
187 // parity: the endpoint fans out concurrent RPC forwards). An event
188 // INTERCEPTOR (Java @EventInterceptor parity): replies are sent
189 // manually, so the streaming relay can withhold its auto-reply.
190 if !platform.has_route(crate::automation::EVENT_API_SERVICE) {
191 let event_api_options = crate::platform::FunctionOptions {
192 zero_traced: false,
193 interceptor: true,
194 private: true,
195 };
196 if let Err(e) = platform.register_with_options(
197 crate::automation::EVENT_API_SERVICE,
198 Arc::new(crate::automation::EventApiService::new(&platform)),
199 250,
200 event_api_options,
201 ) {
202 if !platform.has_route(crate::automation::EVENT_API_SERVICE) {
203 return Err(e);
204 }
205 }
206 }
207 // actuator endpoints (Java EssentialServiceLoader parity): /info /env
208 // /health /livenessprobe, exposed via REST automation's default routes
209 if !platform.has_route(crate::actuator::INFO_ACTUATOR) {
210 use crate::actuator::{ActuatorContext, ActuatorKind, ActuatorServices};
211 let context = ActuatorContext::new(&platform);
212 let actuators = [
213 (crate::actuator::INFO_ACTUATOR, ActuatorKind::Info),
214 (crate::actuator::ROUTES_ACTUATOR, ActuatorKind::Routes),
215 (crate::actuator::ENV_ACTUATOR, ActuatorKind::Env),
216 (crate::actuator::HEALTH_ACTUATOR, ActuatorKind::Health),
217 (crate::actuator::LIVENESS_ACTUATOR, ActuatorKind::Liveness),
218 ];
219 // one ops knob for the whole family (see actuator_instances)
220 let instances = crate::actuator::actuator_instances(config);
221 for (route, kind) in actuators {
222 if let Err(e) = platform.register_private(
223 route,
224 Arc::new(ActuatorServices::new(kind, context.clone())),
225 instances,
226 ) {
227 if !platform.has_route(route) {
228 return Err(e);
229 }
230 }
231 }
232 }
233 // the no-op echo function (Java NoOpFunction, a platform built-in
234 // for event scripts): echoes headers and body; instance count is
235 // overridable via worker.instances.no.op (Java envInstances parity)
236 if !platform.has_route("no.op") {
237 let config = AppConfigReader::get_instance();
238 let no_op_instances = config
239 .get_property("worker.instances.no.op")
240 .and_then(|value| value.parse::<usize>().ok())
241 .unwrap_or(500);
242 if let Err(e) = platform.register_private(
243 "no.op",
244 Arc::new(crate::function::NoOpFunction),
245 no_op_instances,
246 ) {
247 if !platform.has_route("no.op") {
248 return Err(e);
249 }
250 }
251 }
252 // the Async HTTP client (Java EssentialServiceLoader parity);
253 // tolerate a concurrent registration like the actuators above
254 if !platform.has_route(crate::automation::ASYNC_HTTP_REQUEST) {
255 if let Err(e) = platform.register_with_options(
256 crate::automation::ASYNC_HTTP_REQUEST,
257 Arc::new(crate::automation::http_client::AsyncHttpClientService::new(
258 &platform,
259 )),
260 500,
261 FunctionOptions {
262 zero_traced: false,
263 interceptor: true,
264 // Java EssentialServiceLoader: registerPrivate
265 private: true,
266 },
267 ) {
268 if !platform.has_route(crate::automation::ASYNC_HTTP_REQUEST) {
269 return Err(e);
270 }
271 }
272 }
273 // 2. before-application hooks, ordered by sequence (stable sort keeps
274 // registration order within a sequence — Java scan order analog)
275 let mut before = self.before;
276 before.sort_by_key(|(sequence, _)| *sequence);
277 for (sequence, hook) in before {
278 hook.start(&args).await.map_err(|e| {
279 AppError::new(
280 e.status(),
281 format!(
282 "BeforeApplication (sequence {sequence}) failed: {}",
283 e.message()
284 ),
285 )
286 })?;
287 }
288 // 3. preload: bind composable functions to their routes
289 for (route, function, instances, options) in self.preloads {
290 platform.register_with_options(&route, function, instances, options)?;
291 log::info!(
292 "{route} with {instances} instance{} started",
293 if instances == 1 { "" } else { "s" }
294 );
295 }
296 // 4. REST automation: the HTTP protocol boundary. Websocket services
297 // from the `#[websocket_service]` inventory register their URL
298 // paths first (Java `prepareWebsocketServices`); the server starts
299 // when REST automation is enabled OR any websocket service exists
300 // (Java `startHttpServerIfAny` parity).
301 for entry in inventory::iter::<crate::registry::WsServiceEntry> {
302 // Java @OptionalService: skip a conditionally-registered websocket
303 // service when its configuration condition does not hold.
304 if !crate::util::feature::is_required(entry.optional_service, config) {
305 log::info!(
306 "Skip optional websocket service /{}/{} (condition: {})",
307 entry.namespace,
308 entry.name,
309 entry.optional_service.unwrap_or_default()
310 );
311 continue;
312 }
313 if validate_ws_service_name(entry.name) && validate_ws_service_name(entry.namespace) {
314 crate::automation::ws_server::register_ws_service_with_namespace(
315 entry.namespace,
316 entry.name,
317 entry.factory,
318 );
319 } else {
320 log::error!(
321 "Unable to load websocket service /{}/{} - not a valid service name",
322 entry.namespace,
323 entry.name
324 );
325 }
326 }
327 let serve_http = config.get_property_or("rest.automation", "false") == "true"
328 || crate::automation::ws_server::has_ws_services();
329 if serve_http {
330 crate::automation::start_http_server(&platform).await?;
331 }
332 // 5. main applications, ordered by sequence
333 if self.mains.is_empty() {
334 // Java: "Missing MainApplication ... Did you forget to annotate your main module?"
335 return Err(AppError::new(
336 400,
337 "Missing main application - did you forget to add one with main_application()?",
338 ));
339 }
340 let mut mains = self.mains;
341 mains.sort_by_key(|(sequence, _)| *sequence);
342 for (sequence, entry) in mains {
343 entry.start(&args).await.map_err(|e| {
344 AppError::new(
345 e.status(),
346 format!(
347 "MainApplication (sequence {sequence}) failed: {}",
348 e.message()
349 ),
350 )
351 })?;
352 }
353 Ok(())
354 }
355}
356
357/// The one-line application entry point (Java `AutoStart.main(args)`): loads
358/// the `-D` runtime overrides, installs the logger, **collects every
359/// annotated item from the link-time inventory** (`#[preload]`,
360/// `#[before_application]`, `#[main_application]` — the classpath-scanning
361/// analog), and runs the lifecycle. A standalone `fn main()` uses
362/// [`AutoStart::run`], which then keeps serving until Ctrl-C or `SIGTERM`; an embedder
363/// (tests, an existing async context) calls [`AutoStart::main`], which returns
364/// once the app is booted (the HTTP/websocket accept loop runs in the
365/// background).
366/// The future that resolves when the process is told to stop — Ctrl-C
367/// (`SIGINT`), or on Unix also `SIGTERM`, the signal a Kubernetes pod stop or a
368/// plain `kill <pid>` sends. Handling both is what lets the shutdown hooks run
369/// on a pod stop: a Kafka consumer leaves its group explicitly (an immediate
370/// rebalance) instead of expiring at the broker's session timeout. Resolves to
371/// the name of the signal received. The `SIGTERM` listener is registered when
372/// this function is CALLED (not when the future is first polled), so a caller
373/// that raises the signal right after calling it cannot race the registration.
374/// Requires a Tokio runtime context.
375pub(crate) fn shutdown_signal() -> impl std::future::Future<Output = &'static str> {
376 #[cfg(unix)]
377 let terminate = match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
378 {
379 Ok(stream) => Some(stream),
380 Err(e) => {
381 log::warn!("Unable to listen for SIGTERM ({e}) - stopping on Ctrl-C only");
382 None
383 }
384 };
385 async move {
386 #[cfg(unix)]
387 if let Some(mut terminate) = terminate {
388 return tokio::select! {
389 _ = tokio::signal::ctrl_c() => "SIGINT",
390 _ = terminate.recv() => "SIGTERM",
391 };
392 }
393 let _ = tokio::signal::ctrl_c().await;
394 "SIGINT"
395 }
396}
397
398pub struct AutoStart;
399
400impl AutoStart {
401 /// Build a Tokio runtime, boot the application, then — when serving (REST
402 /// automation on, or any websocket service registered) or when a component
403 /// declared [`Platform::keep_running`](crate::Platform::keep_running) (a
404 /// headless app whose Kafka flow adapter consumes topics, say) — stay alive
405 /// until the process is told to stop: Ctrl-C, or `SIGTERM` on Unix (Java:
406 /// the JVM stays up on non-daemon threads and runs its shutdown hooks on
407 /// either). This is the whole `fn main()` body; the `auto_start_main!` macro
408 /// wraps exactly this plus the app's resource root.
409 pub fn run() -> Result<(), AppError> {
410 let runtime = tokio::runtime::Runtime::new()
411 .map_err(|e| AppError::new(500, format!("Unable to start runtime: {e}")))?;
412 runtime.block_on(async {
413 Self::main(std::env::args().collect()).await?;
414 // HTTP/websocket serving, or a component that runs background work
415 // for the life of the process: stay alive until Ctrl-C or SIGTERM.
416 // This blocking wait lives here (the standalone-process entry
417 // point), NOT in `main`, so an embedder that awaits `main` gets
418 // control back once the app is booted instead of hanging on the
419 // signal.
420 let config = AppConfigReader::get_instance();
421 if config.get_property_or("rest.automation", "false") == "true"
422 || crate::automation::ws_server::has_ws_services()
423 || crate::Platform::is_kept_running()
424 {
425 log::info!("Application running - stop with Ctrl-C or SIGTERM");
426 let signal = shutdown_signal().await;
427 log::info!("{signal} received - stopping");
428 } else {
429 // give fire-and-forget telemetry a beat to be logged before exit
430 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
431 }
432 // components release what they opened (Java Platform.onShutdown
433 // parity), newest first, before the engine's own cleanup
434 crate::Platform::run_shutdown_hooks();
435 crate::util::elastic_queue::shutdown_cleanup();
436 Ok(())
437 })
438 }
439
440 /// The async lifecycle (must run within a Tokio runtime): run every
441 /// before-application hook, bind the preloads, start the HTTP/websocket
442 /// server when serving — then **return** (the accept loop keeps running as
443 /// a background task). Booting the engine hands control back to the caller;
444 /// a standalone process uses [`AutoStart::run`] to serve until Ctrl-C or `SIGTERM`.
445 ///
446 /// Runs only once per process (Java parity: `AutoStart.started` is an
447 /// `AtomicBoolean`) — repeated execution is a no-op.
448 pub async fn main(args: Vec<String>) -> Result<(), AppError> {
449 static STARTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
450 if STARTED.swap(true, std::sync::atomic::Ordering::SeqCst) {
451 return Ok(());
452 }
453 crate::util::overrides::load_runtime_args();
454 crate::logging::init();
455 let config = AppConfigReader::get_instance();
456 let mut starter = AppStarter::new();
457 for entry in inventory::iter::<crate::registry::BeforeAppEntry> {
458 // Java @OptionalService: skip a conditionally-run before-application hook
459 if !crate::util::feature::is_required(entry.optional_service, config) {
460 log::info!(
461 "Skip optional before-application (condition: {})",
462 entry.optional_service.unwrap_or_default()
463 );
464 continue;
465 }
466 starter = starter.before_application(entry.sequence, (entry.factory)());
467 }
468 // Java yaml.preload.override: a config-driven transform over the
469 // collected #[preload] set, applied between inventory collection and
470 // registration — rename / fan out / re-tune instances without
471 // recompiling (the boot sequence's "override" step)
472 let preload_overrides = crate::preload_override::preload_override(config);
473 for entry in inventory::iter::<crate::registry::PreloadEntry> {
474 // Java @OptionalService: skip a conditionally-registered route when
475 // its configuration condition does not hold (Java `Feature`).
476 if !crate::util::feature::is_required(entry.optional_service, config) {
477 log::info!(
478 "Skip optional {} (condition: {})",
479 entry.route,
480 entry.optional_service.unwrap_or_default()
481 );
482 continue;
483 }
484 // Java envInstances: the instance count may come from configuration.
485 // Resolved BEFORE the override applies (Java processPreload order),
486 // so the resolved value is the "old" count in the override's log.
487 let instances = entry
488 .env_instances
489 .and_then(|key| config.get_property(key))
490 .and_then(|value| value.parse::<usize>().ok())
491 .unwrap_or(entry.instances);
492 let (routes, instances) =
493 crate::preload_override::apply(&preload_overrides, entry.route, instances);
494 // a comma-separated route value declares ALIASES: every name
495 // registers the SAME function object with the same instance
496 // count and visibility (Java AppStarter splits @PreLoad.route
497 // and registers one instance for all names) — an override's
498 // replacement route set fans out the same way
499 let function = (entry.factory)();
500 for route in &routes {
501 starter = starter.preload_with_options(
502 route,
503 function.clone(),
504 instances,
505 FunctionOptions {
506 zero_traced: entry.zero_tracing,
507 interceptor: entry.interceptor,
508 private: entry.is_private,
509 },
510 );
511 }
512 }
513 for entry in inventory::iter::<crate::registry::MainAppEntry> {
514 // Java @OptionalService: skip a conditionally-run main application
515 if !crate::util::feature::is_required(entry.optional_service, config) {
516 log::info!(
517 "Skip optional main-application (condition: {})",
518 entry.optional_service.unwrap_or_default()
519 );
520 continue;
521 }
522 starter = starter.main_application(entry.sequence, (entry.factory)());
523 }
524 starter.run(args).await?;
525 // The app is booted; the HTTP/websocket accept loop (if serving) runs
526 // as a background task. Return control to the caller — `AutoStart::run`
527 // is what blocks a standalone process alive until Ctrl-C or SIGTERM.
528 Ok(())
529 }
530}
531
532/// Java `validServiceName` for websocket paths: lowercase letters, digits,
533/// '.', '-', '_'.
534fn validate_ws_service_name(name: &str) -> bool {
535 !name.is_empty()
536 && name.bytes().all(|b| {
537 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'-' | b'_')
538 })
539}
540
541#[cfg(test)]
542mod tests {
543 /// A `SIGTERM` (the Kubernetes pod-stop signal) ends the standalone wait
544 /// exactly like Ctrl-C does — the process keeps running and the caller
545 /// learns which signal arrived, so the shutdown hooks follow.
546 #[cfg(unix)]
547 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
548 async fn sigterm_ends_the_shutdown_wait() {
549 // the listener is registered on the call, before the signal is raised
550 let wait = super::shutdown_signal();
551 let status = std::process::Command::new("kill")
552 .arg("-TERM")
553 .arg(std::process::id().to_string())
554 .status()
555 .expect("kill runs");
556 assert!(status.success());
557 let signal = tokio::time::timeout(std::time::Duration::from_secs(5), wait)
558 .await
559 .expect("the wait ends within 5 s of SIGTERM");
560 assert_eq!("SIGTERM", signal);
561 }
562}