From de787ce7eeac41705dd39f8abeead27782f4381d Mon Sep 17 00:00:00 2001 From: zosimovaa Date: Wed, 4 Mar 2026 10:01:49 +0300 Subject: [PATCH] plba --- Architectural constraints.md | 111 +++++ Mail Order Bot Migration Plan.md | 116 +++++ README.md | 469 ++++++++++++++++++ architecture.md | 248 +++++++++ pyproject.toml | 22 + src/app_runtime/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 239 bytes src/app_runtime/config/__init__.py | 4 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 353 bytes .../__pycache__/file_loader.cpython-312.pyc | Bin 0 -> 2834 bytes .../__pycache__/providers.cpython-312.pyc | Bin 0 -> 1450 bytes src/app_runtime/config/file_loader.py | 48 ++ src/app_runtime/config/providers.py | 22 + src/app_runtime/contracts/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 249 bytes .../__pycache__/application.cpython-312.pyc | Bin 0 -> 1042 bytes .../__pycache__/config.cpython-312.pyc | Bin 0 -> 690 bytes .../__pycache__/health.cpython-312.pyc | Bin 0 -> 727 bytes .../__pycache__/queue.cpython-312.pyc | Bin 0 -> 1592 bytes .../__pycache__/runner.cpython-312.pyc | Bin 0 -> 1070 bytes .../__pycache__/tasks.cpython-312.pyc | Bin 0 -> 1136 bytes .../__pycache__/trace.cpython-312.pyc | Bin 0 -> 3220 bytes .../__pycache__/worker.cpython-312.pyc | Bin 0 -> 2750 bytes src/app_runtime/contracts/application.py | 16 + src/app_runtime/contracts/config.py | 10 + src/app_runtime/contracts/health.py | 10 + src/app_runtime/contracts/queue.py | 28 ++ src/app_runtime/contracts/tasks.py | 18 + src/app_runtime/contracts/trace.py | 57 +++ src/app_runtime/contracts/worker.py | 55 ++ src/app_runtime/control/__init__.py | 5 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 455 bytes .../control/__pycache__/base.cpython-312.pyc | Bin 0 -> 1527 bytes .../__pycache__/http_app.cpython-312.pyc | Bin 0 -> 2605 bytes .../__pycache__/http_channel.cpython-312.pyc | Bin 0 -> 4086 bytes .../__pycache__/http_runner.cpython-312.pyc | Bin 0 -> 4382 bytes .../__pycache__/service.cpython-312.pyc | Bin 0 -> 3442 bytes src/app_runtime/control/base.py | 28 ++ src/app_runtime/control/http_app.py | 38 ++ src/app_runtime/control/http_channel.py | 53 ++ src/app_runtime/control/http_runner.py | 61 +++ src/app_runtime/control/service.py | 52 ++ src/app_runtime/core/__init__.py | 1 + .../core/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 244 bytes .../__pycache__/configuration.cpython-312.pyc | Bin 0 -> 3084 bytes .../__pycache__/registration.cpython-312.pyc | Bin 0 -> 2291 bytes .../core/__pycache__/runtime.cpython-312.pyc | Bin 0 -> 8294 bytes .../service_container.cpython-312.pyc | Bin 0 -> 2127 bytes .../core/__pycache__/types.cpython-312.pyc | Bin 0 -> 923 bytes src/app_runtime/core/configuration.py | 48 ++ src/app_runtime/core/registration.py | 32 ++ src/app_runtime/core/runtime.py | 125 +++++ src/app_runtime/core/service_container.py | 28 ++ src/app_runtime/core/types.py | 19 + src/app_runtime/health/__init__.py | 3 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 266 bytes .../__pycache__/registry.cpython-312.pyc | Bin 0 -> 3957 bytes src/app_runtime/health/registry.py | 56 +++ src/app_runtime/logging/__init__.py | 3 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 262 bytes .../__pycache__/manager.cpython-312.pyc | Bin 0 -> 2152 bytes src/app_runtime/logging/manager.py | 31 ++ src/app_runtime/queue/__init__.py | 3 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 268 bytes .../__pycache__/in_memory.cpython-312.pyc | Bin 0 -> 2543 bytes src/app_runtime/queue/in_memory.py | 43 ++ src/app_runtime/tracing/__init__.py | 16 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 649 bytes .../__pycache__/manager.cpython-312.pyc | Bin 0 -> 8503 bytes .../__pycache__/service.cpython-312.pyc | Bin 0 -> 9017 bytes .../tracing/__pycache__/store.cpython-312.pyc | Bin 0 -> 3113 bytes .../__pycache__/transport.cpython-312.pyc | Bin 0 -> 871 bytes src/app_runtime/tracing/service.py | 166 +++++++ src/app_runtime/tracing/store.py | 52 ++ src/app_runtime/tracing/transport.py | 11 + src/app_runtime/workers/__init__.py | 4 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 351 bytes .../__pycache__/queue_worker.cpython-312.pyc | Bin 0 -> 7748 bytes .../__pycache__/runner.cpython-312.pyc | Bin 0 -> 2814 bytes .../__pycache__/supervisor.cpython-312.pyc | Bin 0 -> 3842 bytes src/app_runtime/workers/queue_worker.py | 125 +++++ src/app_runtime/workers/supervisor.py | 59 +++ src/plba/__init__.py | 57 +++ src/plba/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 1380 bytes .../__pycache__/bootstrap.cpython-312.pyc | Bin 0 -> 1250 bytes src/plba/__pycache__/config.cpython-312.pyc | Bin 0 -> 337 bytes .../__pycache__/contracts.cpython-312.pyc | Bin 0 -> 869 bytes src/plba/__pycache__/control.cpython-312.pyc | Bin 0 -> 441 bytes src/plba/__pycache__/core.cpython-312.pyc | Bin 0 -> 410 bytes src/plba/__pycache__/health.cpython-312.pyc | Bin 0 -> 250 bytes src/plba/__pycache__/logging.cpython-312.pyc | Bin 0 -> 246 bytes src/plba/__pycache__/queue.cpython-312.pyc | Bin 0 -> 252 bytes src/plba/__pycache__/tracing.cpython-312.pyc | Bin 0 -> 331 bytes src/plba/__pycache__/workers.cpython-312.pyc | Bin 0 -> 335 bytes src/plba/bootstrap.py | 29 ++ src/plba/config.py | 4 + src/plba/contracts.py | 28 ++ src/plba/control.py | 10 + src/plba/core.py | 9 + src/plba/health.py | 3 + src/plba/logging.py | 3 + src/plba/queue.py | 3 + src/plba/tracing.py | 4 + src/plba/workers.py | 4 + .../test_runtime.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 25461 bytes tests/test_runtime.py | 230 +++++++++ vision.md | 119 +++++ 107 files changed, 2801 insertions(+) create mode 100644 Architectural constraints.md create mode 100644 Mail Order Bot Migration Plan.md create mode 100644 README.md create mode 100644 architecture.md create mode 100644 pyproject.toml create mode 100644 src/app_runtime/__init__.py create mode 100644 src/app_runtime/__pycache__/__init__.cpython-312.pyc create mode 100644 src/app_runtime/config/__init__.py create mode 100644 src/app_runtime/config/__pycache__/__init__.cpython-312.pyc create mode 100644 src/app_runtime/config/__pycache__/file_loader.cpython-312.pyc create mode 100644 src/app_runtime/config/__pycache__/providers.cpython-312.pyc create mode 100644 src/app_runtime/config/file_loader.py create mode 100644 src/app_runtime/config/providers.py create mode 100644 src/app_runtime/contracts/__init__.py create mode 100644 src/app_runtime/contracts/__pycache__/__init__.cpython-312.pyc create mode 100644 src/app_runtime/contracts/__pycache__/application.cpython-312.pyc create mode 100644 src/app_runtime/contracts/__pycache__/config.cpython-312.pyc create mode 100644 src/app_runtime/contracts/__pycache__/health.cpython-312.pyc create mode 100644 src/app_runtime/contracts/__pycache__/queue.cpython-312.pyc create mode 100644 src/app_runtime/contracts/__pycache__/runner.cpython-312.pyc create mode 100644 src/app_runtime/contracts/__pycache__/tasks.cpython-312.pyc create mode 100644 src/app_runtime/contracts/__pycache__/trace.cpython-312.pyc create mode 100644 src/app_runtime/contracts/__pycache__/worker.cpython-312.pyc create mode 100644 src/app_runtime/contracts/application.py create mode 100644 src/app_runtime/contracts/config.py create mode 100644 src/app_runtime/contracts/health.py create mode 100644 src/app_runtime/contracts/queue.py create mode 100644 src/app_runtime/contracts/tasks.py create mode 100644 src/app_runtime/contracts/trace.py create mode 100644 src/app_runtime/contracts/worker.py create mode 100644 src/app_runtime/control/__init__.py create mode 100644 src/app_runtime/control/__pycache__/__init__.cpython-312.pyc create mode 100644 src/app_runtime/control/__pycache__/base.cpython-312.pyc create mode 100644 src/app_runtime/control/__pycache__/http_app.cpython-312.pyc create mode 100644 src/app_runtime/control/__pycache__/http_channel.cpython-312.pyc create mode 100644 src/app_runtime/control/__pycache__/http_runner.cpython-312.pyc create mode 100644 src/app_runtime/control/__pycache__/service.cpython-312.pyc create mode 100644 src/app_runtime/control/base.py create mode 100644 src/app_runtime/control/http_app.py create mode 100644 src/app_runtime/control/http_channel.py create mode 100644 src/app_runtime/control/http_runner.py create mode 100644 src/app_runtime/control/service.py create mode 100644 src/app_runtime/core/__init__.py create mode 100644 src/app_runtime/core/__pycache__/__init__.cpython-312.pyc create mode 100644 src/app_runtime/core/__pycache__/configuration.cpython-312.pyc create mode 100644 src/app_runtime/core/__pycache__/registration.cpython-312.pyc create mode 100644 src/app_runtime/core/__pycache__/runtime.cpython-312.pyc create mode 100644 src/app_runtime/core/__pycache__/service_container.cpython-312.pyc create mode 100644 src/app_runtime/core/__pycache__/types.cpython-312.pyc create mode 100644 src/app_runtime/core/configuration.py create mode 100644 src/app_runtime/core/registration.py create mode 100644 src/app_runtime/core/runtime.py create mode 100644 src/app_runtime/core/service_container.py create mode 100644 src/app_runtime/core/types.py create mode 100644 src/app_runtime/health/__init__.py create mode 100644 src/app_runtime/health/__pycache__/__init__.cpython-312.pyc create mode 100644 src/app_runtime/health/__pycache__/registry.cpython-312.pyc create mode 100644 src/app_runtime/health/registry.py create mode 100644 src/app_runtime/logging/__init__.py create mode 100644 src/app_runtime/logging/__pycache__/__init__.cpython-312.pyc create mode 100644 src/app_runtime/logging/__pycache__/manager.cpython-312.pyc create mode 100644 src/app_runtime/logging/manager.py create mode 100644 src/app_runtime/queue/__init__.py create mode 100644 src/app_runtime/queue/__pycache__/__init__.cpython-312.pyc create mode 100644 src/app_runtime/queue/__pycache__/in_memory.cpython-312.pyc create mode 100644 src/app_runtime/queue/in_memory.py create mode 100644 src/app_runtime/tracing/__init__.py create mode 100644 src/app_runtime/tracing/__pycache__/__init__.cpython-312.pyc create mode 100644 src/app_runtime/tracing/__pycache__/manager.cpython-312.pyc create mode 100644 src/app_runtime/tracing/__pycache__/service.cpython-312.pyc create mode 100644 src/app_runtime/tracing/__pycache__/store.cpython-312.pyc create mode 100644 src/app_runtime/tracing/__pycache__/transport.cpython-312.pyc create mode 100644 src/app_runtime/tracing/service.py create mode 100644 src/app_runtime/tracing/store.py create mode 100644 src/app_runtime/tracing/transport.py create mode 100644 src/app_runtime/workers/__init__.py create mode 100644 src/app_runtime/workers/__pycache__/__init__.cpython-312.pyc create mode 100644 src/app_runtime/workers/__pycache__/queue_worker.cpython-312.pyc create mode 100644 src/app_runtime/workers/__pycache__/runner.cpython-312.pyc create mode 100644 src/app_runtime/workers/__pycache__/supervisor.cpython-312.pyc create mode 100644 src/app_runtime/workers/queue_worker.py create mode 100644 src/app_runtime/workers/supervisor.py create mode 100644 src/plba/__init__.py create mode 100644 src/plba/__pycache__/__init__.cpython-312.pyc create mode 100644 src/plba/__pycache__/bootstrap.cpython-312.pyc create mode 100644 src/plba/__pycache__/config.cpython-312.pyc create mode 100644 src/plba/__pycache__/contracts.cpython-312.pyc create mode 100644 src/plba/__pycache__/control.cpython-312.pyc create mode 100644 src/plba/__pycache__/core.cpython-312.pyc create mode 100644 src/plba/__pycache__/health.cpython-312.pyc create mode 100644 src/plba/__pycache__/logging.cpython-312.pyc create mode 100644 src/plba/__pycache__/queue.cpython-312.pyc create mode 100644 src/plba/__pycache__/tracing.cpython-312.pyc create mode 100644 src/plba/__pycache__/workers.cpython-312.pyc create mode 100644 src/plba/bootstrap.py create mode 100644 src/plba/config.py create mode 100644 src/plba/contracts.py create mode 100644 src/plba/control.py create mode 100644 src/plba/core.py create mode 100644 src/plba/health.py create mode 100644 src/plba/logging.py create mode 100644 src/plba/queue.py create mode 100644 src/plba/tracing.py create mode 100644 src/plba/workers.py create mode 100644 tests/__pycache__/test_runtime.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/test_runtime.py create mode 100644 vision.md diff --git a/Architectural constraints.md b/Architectural constraints.md new file mode 100644 index 0000000..9f40030 --- /dev/null +++ b/Architectural constraints.md @@ -0,0 +1,111 @@ + +**`docs/adr/0001-new-runtime.md`** +```md +# ADR 0001: Create a new runtime project instead of evolving the legacy ConfigManager model + +## Status + +Accepted + +## Context + +The previous generation of the application runtime was centered around a timer-driven execution model: +- a manager loaded configuration +- a worker loop periodically called `execute()` +- applications placed most operational logic behind that entry point + +That model is adequate for simple periodic jobs, but it does not match the direction of the new platform. + +The new platform must support: +- task sources +- queue-driven processing +- multiple parallel workers +- trace propagation across producer/consumer boundaries +- richer lifecycle and status management +- health aggregation +- future admin web interface +- future authentication and user management + +These are platform concerns, not business concerns. + +We also want business applications to describe only business functionality and rely on the runtime for infrastructure behavior. + +## Decision + +We will create a new runtime project instead of implementing a `V3` directly inside the current legacy ConfigManager codebase. + +The new runtime will be built around a platform-oriented model with explicit concepts such as: +- `RuntimeManager` +- `ApplicationModule` +- `TaskSource` +- `TaskQueue` +- `WorkerSupervisor` +- `TaskHandler` +- `TraceService` +- `HealthRegistry` + +The old execute-centered model is treated as a previous-generation design and is not the architectural basis of the new runtime. + +## Rationale + +Creating a new runtime project gives us: +- freedom to design the correct abstractions from the start +- no pressure to preserve legacy contracts +- cleaner boundaries between platform and business logic +- simpler documentation and tests +- lower long-term complexity than mixing old and new models in one codebase + +If we built this as `V3` inside the old project, we would likely inherit: +- compatibility constraints +- mixed abstractions +- transitional adapters +- conceptual confusion between periodic execution and event/queue processing + +The expected long-term cost of such coupling is higher than creating a clean new runtime. + +## Consequences + +### Positive + +- the platform can be modeled cleanly +- business applications can integrate through explicit contracts +- new runtime capabilities can be added without legacy pressure +- mail_order_bot can become the first pilot application on the new runtime + +### Negative + +- some existing capabilities will need to be reintroduced in the new project +- there will be a temporary period with both legacy and new runtime lines +- migration requires explicit planning + +## Initial migration target + +The first application on the new runtime will be `mail_order_bot`. + +Initial runtime design for that application: +- IMAP polling source +- in-memory queue +- parallel workers +- business handler for email processing +- message marked as read only after successful processing + +Later: +- swap IMAP polling source for IMAP IDLE source +- keep the queue and worker model unchanged + +## What we intentionally do not carry over + +We do not keep the old architecture as the central organizing principle: +- no `execute()`-centric application model +- no timer-loop as the main abstraction +- no implicit mixing of lifecycle and business processing + +## Follow-up + +Next design work should define: +- core platform contracts +- package structure +- runtime lifecycle +- queue and worker interfaces +- config model split between platform and application +- pilot integration for `mail_order_bot` diff --git a/Mail Order Bot Migration Plan.md b/Mail Order Bot Migration Plan.md new file mode 100644 index 0000000..c613f16 --- /dev/null +++ b/Mail Order Bot Migration Plan.md @@ -0,0 +1,116 @@ +# Mail Order Bot Migration Plan + +## Purpose + +This document describes how `mail_order_bot` will be adapted to the new runtime as the first pilot business application. + +## Scope + +This is not a full migration specification for all future applications. +It is a practical first use case to validate the runtime architecture. + +## Current model + +The current application flow is tightly coupled: +- the manager checks IMAP +- unread emails are fetched +- emails are processed synchronously +- messages are marked as read after processing + +Polling and processing happen in one execution path. + +## Target model + +The new runtime-based flow should be: + +1. a mail source detects new tasks +2. tasks are published to a queue +3. workers consume tasks in parallel +4. a domain handler processes each email +5. successful tasks lead to `mark_as_read` +6. failed tasks remain retriable + +## Phase 1 + +### Source +- IMAP polling source + +### Queue +- in-memory task queue + +### Workers +- 2 to 4 parallel workers initially + +### Handler +- domain email processing handler built around the current processing logic + +### Delivery semantics +- email is marked as read only after successful processing +- unread state acts as the first safety mechanism against message loss + +## Why in-memory queue is acceptable at first + +For the first phase: +- infrastructure complexity stays low +- the runtime contracts can be tested quickly +- unread emails in IMAP provide a simple recovery path after crashes + +This allows us to validate the runtime architecture before adopting an external broker. + +## Phase 2 + +Replace: +- IMAP polling source + +With: +- IMAP IDLE source + +The queue, workers, and handler should remain unchanged. + +This is an explicit architectural goal: +source replacement without redesigning the processing pipeline. + +## Domain responsibilities that remain inside mail_order_bot + +The runtime should not own: +- email parsing rules +- client resolution logic +- attachment processing rules +- order creation logic +- client-specific behavior + +These remain in the business application. + +## Platform responsibilities used by mail_order_bot + +The new runtime should provide: +- lifecycle +- configuration +- queue abstraction +- worker orchestration +- tracing +- health checks +- status/control APIs + +## Migration boundaries + +### Move into runtime +- source orchestration +- worker supervision +- queue management +- trace provisioning +- health aggregation + +### Keep in mail_order_bot +- email business handler +- mail domain services +- business pipeline +- business-specific config validation beyond platform-level schema + +## Success criteria + +The migration is successful when: +- mail polling is no longer tightly coupled to processing +- workers can process emails in parallel +- business logic is not moved into the runtime +- replacing polling with IDLE is localized to the source layer diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a6c2a7 --- /dev/null +++ b/README.md @@ -0,0 +1,469 @@ +# PLBA + +`PLBA` is a reusable platform runtime for business applications. + +It solves platform concerns that should not live inside domain code: +- application lifecycle +- worker orchestration +- configuration loading from YAML +- tracing +- health aggregation +- runtime status reporting +- HTTP control endpoints +- logging configuration + +Business applications depend on `plba` as a package and implement only their own business behavior. + +## Architecture + +Current PLBA architecture is built around one core idea: +- the runtime manages a set of application workers + +A worker is any runtime-managed active component with a unified lifecycle: +- `start()` +- `stop(force=False)` +- `health()` +- `status()` + +This means PLBA does not require separate platform categories like `source` and `consumer`. +If an application needs polling, queue processing, listening, scheduled work, or another active loop, it is implemented as a worker. + +### Main runtime model + +1. application creates `RuntimeManager` +2. runtime loads configuration +3. runtime applies logging configuration +4. application module registers workers and supporting services +5. runtime starts all workers +6. workers execute business-related loops or processing +7. runtime aggregates health and status +8. runtime stops workers gracefully or forcefully + +## Core concepts + +### `ApplicationModule` + +File: [application.py](/Users/alex/Dev_projects_v2/apps/plba/src/app_runtime/contracts/application.py) + +Describes a business application to the runtime. + +Responsibilities: +- provide module name +- register workers +- register queues if needed +- register handlers if needed +- register health contributors +- compose application-specific objects + +`ApplicationModule` does not run the application itself. +It only declares how the application is assembled. + +### `Worker` + +File: [worker.py](/Users/alex/Dev_projects_v2/apps/plba/src/app_runtime/contracts/worker.py) + +The main runtime-managed contract. + +Responsibilities: +- start its own execution +- stop gracefully or forcefully +- report health +- report runtime status + +This is the main extension point for business applications. + +### `TaskQueue` + +File: [queue.py](/Users/alex/Dev_projects_v2/apps/plba/src/app_runtime/contracts/queue.py) + +Optional queue abstraction. + +Use it when application workers need buffered or decoupled processing. + +PLBA does not force every application to use a queue. +Queue is one supported pattern, not the foundation of the whole platform. + +### `TaskHandler` + +File: [tasks.py](/Users/alex/Dev_projects_v2/apps/plba/src/app_runtime/contracts/tasks.py) + +Optional unit of business processing for one task. + +Useful when a worker follows queue-driven logic: +- worker takes a task +- handler executes business logic + +### `TraceService` + +File: [service.py](/Users/alex/Dev_projects_v2/apps/plba/src/app_runtime/tracing/service.py) + +Platform trace service. + +Responsibilities: +- create trace contexts +- resume trace from task metadata +- write context records +- write trace messages + +Business code should use it as a platform service and should not implement its own tracing infrastructure. + +### `HealthRegistry` + +File: [registry.py](/Users/alex/Dev_projects_v2/apps/plba/src/app_runtime/health/registry.py) + +Aggregates application health. + +PLBA uses three health states: +- `ok` — all critical parts work +- `degraded` — application still works, but there is a problem +- `unhealthy` — application should not be considered operational + +### Runtime status + +File: [types.py](/Users/alex/Dev_projects_v2/apps/plba/src/app_runtime/core/types.py) + +Status is separate from health. + +Current runtime states: +- `starting` +- `idle` +- `busy` +- `stopping` +- `stopped` + +Status is used for operational lifecycle decisions such as graceful shutdown. + +### `ControlPlaneService` + +Files: +- [service.py](/Users/alex/Dev_projects_v2/apps/plba/src/app_runtime/control/service.py) +- [http_channel.py](/Users/alex/Dev_projects_v2/apps/plba/src/app_runtime/control/http_channel.py) + +Provides control and observability endpoints. + +Currently supported: +- health access +- runtime start action +- runtime stop action +- runtime status action + +### `ConfigurationManager` + +Files: +- [configuration.py](/Users/alex/Dev_projects_v2/apps/plba/src/app_runtime/core/configuration.py) +- [file_loader.py](/Users/alex/Dev_projects_v2/apps/plba/src/app_runtime/config/file_loader.py) +- [providers.py](/Users/alex/Dev_projects_v2/apps/plba/src/app_runtime/config/providers.py) + +Loads and merges configuration. + +Current built-in source: +- YAML file provider + +### `LogManager` + +File: [manager.py](/Users/alex/Dev_projects_v2/apps/plba/src/app_runtime/logging/manager.py) + +Applies logging configuration from config. + +Current expectation: +- logging config lives in the `log` section of YAML + +## Available platform services + +PLBA currently provides these reusable services. + +### 1. Runtime lifecycle + +Service: +- `RuntimeManager` + +What it gives: +- startup orchestration +- worker registration and startup +- graceful stop with timeout +- force stop +- status snapshot + +Example use: +- start `mail_order_bot` +- stop it after active email processing is drained + +### 2. Worker supervision + +Service: +- `WorkerSupervisor` + +What it gives: +- unified worker orchestration +- aggregated worker statuses +- aggregated worker health +- stop coordination + +Example use: +- run one polling worker and three processing workers in the same application + +### 3. Queue support + +Services: +- `TaskQueue` +- `InMemoryTaskQueue` +- `QueueWorker` + +What it gives: +- buffered processing +- decoupling between task production and task consumption +- worker concurrency for task handling + +Example use: +- worker A polls IMAP and pushes tasks to queue +- worker B processes queued email tasks with concurrency `3` + +### 4. Configuration + +Services: +- `ConfigurationManager` +- `FileConfigProvider` +- `ConfigFileLoader` + +What it gives: +- YAML config loading +- config merging +- access to platform and application config + +Example use: +- load `platform` section for runtime +- load `mail_order_bot` section for app-specific config + +### 5. Tracing + +Services: +- `TraceService` +- `TraceTransport` +- `NoOpTraceTransport` + +What it gives: +- trace context creation +- trace propagation through task metadata +- trace messages for processing steps + +Example use: +- polling worker creates trace when it discovers a mail +- processing worker resumes trace and writes business steps + +### 6. Health + +Services: +- `HealthRegistry` +- `WorkerHealth` + +What it gives: +- per-worker health +- aggregated application health +- critical vs non-critical component handling + +Example use: +- email processing workers are critical +- optional diagnostic worker may be non-critical + +### 7. Status + +Services: +- `WorkerStatus` +- runtime aggregated state + +What it gives: +- current activity visibility +- ability to stop application only after in-flight work is completed + +Example use: +- stop application only after processing workers become `idle` or `stopped` + +### 8. HTTP control + +Services: +- `ControlPlaneService` +- `HttpControlChannel` + +What it gives: +- HTTP health/status/actions +- operational integration point + +Example use: +- inspect current health from orchestration +- request graceful stop remotely + +## Public package API + +Public namespace is `plba`. + +Main imports for external applications: + +```python +from plba import ApplicationModule, QueueWorker, RuntimeManager, create_runtime +from plba.contracts import Task, TaskHandler, TaskQueue, Worker, WorkerHealth, WorkerStatus +from plba.queue import InMemoryTaskQueue +from plba.tracing import TraceService +``` + +## Example application pattern + +Minimal queue-based application: + +```python +from plba import ApplicationModule, QueueWorker, Task, TaskHandler, create_runtime +from plba.queue import InMemoryTaskQueue + + +class ExampleHandler(TaskHandler): + def handle(self, task: Task) -> None: + print(task.payload) + + +class ExampleModule(ApplicationModule): + @property + def name(self) -> str: + return "example" + + def register(self, registry) -> None: + queue = InMemoryTaskQueue() + traces = registry.services.get("traces") + + queue.publish(Task(name="incoming", payload={"hello": "world"})) + + registry.add_queue("incoming", queue) + registry.add_worker(QueueWorker("example-worker", queue, ExampleHandler(), traces)) + + +runtime = create_runtime( + ExampleModule(), + config_path="config.yml", + enable_http_control=False, +) +runtime.start() +``` + +## Building business applications on PLBA + +These are the current rules for building business applications correctly. + +### 1. Keep platform and business concerns separate + +PLBA owns: +- lifecycle +- worker management +- logging +- trace infrastructure +- health aggregation +- HTTP control +- config loading + +Business application owns: +- business workflows +- domain services +- application-specific config schema +- business task payloads +- business error semantics + +### 2. Build app behavior from workers + +A business application should be described as a small set of workers. + +Typical examples: +- polling worker +- processing worker +- reconciliation worker + +Do not introduce new worker types at platform level unless there is clear need for custom runtime behavior. + +### 3. Use queues only when they help + +Queue is optional. + +Use queue when: +- one worker discovers work +- another worker processes it +- buffering or decoupling helps +- concurrency is needed + +Do not force queue into applications that do not need it. + +### 4. Keep business logic out of worker lifecycle code + +Worker should orchestrate execution. +Business rules should live in dedicated services and handlers. + +Good: +- worker gets config +- worker calls domain service +- worker reports trace and status + +Bad: +- worker contains all parsing, decision logic, integration rules, and persistence rules in one class + +### 5. Use trace as a platform service + +Business application should: +- create meaningful trace steps +- propagate trace through task metadata if queue is used +- record business-relevant processing milestones + +Business application should not: +- implement its own trace store +- control trace transport directly unless explicitly needed + +### 6. Read config through PLBA + +Business application should not read YAML directly. + +Recommended flow: +- PLBA loads config +- application reads only its own config section +- application converts it to typed app config object +- services receive typed config object + +### 7. Distinguish health from status + +Use `health` for: +- is application operational? + +Use `status` for: +- what is application doing right now? + +This is important for graceful stop: +- health may still be `ok` +- status may be `busy` + +### 8. Design workers for graceful stop + +Workers should support: +- stop accepting new work +- finish current in-flight work when possible +- report `busy`, `idle`, `stopping`, `stopped` + +This allows runtime to stop application safely. + +## Recommended repository model + +PLBA is intended to live in its own repository as a reusable package. + +Recommended setup: +- repository `plba`: platform package only +- repository `mail_order_bot`: business application depending on `plba` +- repository `service_b`: business application depending on `plba` + +## Example: `mail_order_bot` + +Simple first version of `mail_order_bot` on PLBA: +- `MailPollingWorker`, concurrency `1` +- `EmailProcessingWorker`, concurrency `3` +- shared `InMemoryTaskQueue` +- domain services for mail parsing and order processing + +Flow: +1. polling worker checks IMAP +2. polling worker pushes email tasks into queue +3. processing workers consume tasks +4. processing workers execute domain logic +5. runtime aggregates health and status + +This keeps `mail_order_bot` small, explicit, and aligned with current PLBA architecture. diff --git a/architecture.md b/architecture.md new file mode 100644 index 0000000..b0ddba0 --- /dev/null +++ b/architecture.md @@ -0,0 +1,248 @@ +# Architecture + +## Overview + +The runtime is built as a platform layer for business applications. + +It consists of four logical layers: +- platform core +- platform contracts +- infrastructure adapters +- business applications + +## Layers + +### Platform core + +The core contains long-lived runtime services: +- `RuntimeManager` +- `ConfigurationManager` +- `WorkerSupervisor` +- `TraceService` +- `HealthRegistry` +- `ControlPlaneService` +- `ServiceContainer` + +The core is responsible for orchestration, not domain behavior. + +### Platform contracts + +Contracts define how business applications integrate with the runtime. + +Main contracts: +- `ApplicationModule` +- `TaskSource` +- `TaskQueue` +- `Worker` +- `TaskHandler` +- `ConfigProvider` +- `HealthContributor` +- `TraceFactory` + +These contracts must remain domain-agnostic. + +### Infrastructure adapters + +Adapters implement concrete runtime capabilities: +- in-memory queue +- Redis queue +- file config loader +- database config loader +- polling source +- IMAP IDLE source +- HTTP control plane +- trace transport adapters + +Adapters may change between applications and deployments. + +### Business applications + +Applications are built on top of the contracts and adapters. + +Examples: +- `mail_order_bot` +- future event-driven business services + +Applications contain: +- domain models +- domain handlers +- application-specific configuration schema +- source/handler composition + +## Core runtime components + +### RuntimeManager + +The main platform facade. + +Responsibilities: +- bootstrap runtime +- initialize services +- register application modules +- start and stop all runtime-managed components +- expose status +- coordinate graceful shutdown + +### ConfigurationManager + +Responsibilities: +- load configuration +- validate configuration +- publish config updates +- provide typed config access +- notify subscribers on reload + +Configuration should be divided into: +- platform config +- application config +- environment/runtime overrides + +### WorkerSupervisor + +Responsibilities: +- register worker definitions +- start worker pools +- monitor worker health +- restart failed workers when appropriate +- manage parallelism and backpressure +- expose worker-level status + +### TraceService + +Responsibilities: +- create traces for operations +- propagate trace context across source -> queue -> worker -> handler boundaries +- provide trace factories to applications +- remain transport-agnostic + +### HealthRegistry + +Responsibilities: +- collect health from registered contributors +- aggregate health into liveness/readiness/status views +- expose structured runtime health + +### ControlPlaneService + +Responsibilities: +- control endpoints +- runtime state visibility +- administrative actions +- later authentication and user/session-aware access + +## Main runtime model + +The runtime should operate on this conceptual flow: + +1. runtime starts +2. configuration is loaded +3. services are initialized +4. application modules register sources, queues, handlers, and workers +5. task sources start producing tasks +6. tasks are published into queues +7. workers consume tasks +8. handlers execute business logic +9. traces and health are updated throughout the flow +10. runtime stops gracefully on request + +## Contracts + +### ApplicationModule + +Describes a business application to the runtime. + +Responsibilities: +- register domain services +- register task sources +- register queues +- register worker pools +- register handlers +- declare config requirements +- optionally register health contributors + +### TaskSource + +Produces tasks into queues. + +Examples: +- IMAP polling source +- IMAP IDLE source +- webhook source +- scheduled source + +Responsibilities: +- start +- stop +- publish tasks +- expose source status + +### TaskQueue + +A queue abstraction. + +Expected operations: +- `publish(task)` +- `consume()` +- `ack(task)` +- `nack(task, retry_delay=None)` +- `stats()` + +The first implementation may be in-memory, but the interface should support future backends. + +### Worker + +Consumes tasks from a queue and passes them to a handler. + +Responsibilities: +- obtain task from queue +- open or resume trace context +- call business handler +- ack or nack the task +- expose worker state + +### TaskHandler + +Executes business logic for one task. + +The runtime should not know what the handler does. +It only knows that a task is processed. + +## Mail Order Bot as first application + +### Phase 1 + +- source: IMAP polling +- queue: in-memory queue +- workers: parallel email processing workers +- handler: domain email processing handler +- mark message as read only after successful processing + +### Phase 2 + +- source changes from polling to IMAP IDLE +- queue and workers remain the same + +This demonstrates one of the architectural goals: +the source can change without redesigning the rest of the processing pipeline. + +## Suggested package structure + +```text +src/ + app_runtime/ + core/ + contracts/ + config/ + workers/ + queue/ + tracing/ + health/ + control/ + container/ + adapters/ + mail_order_bot_app/ + module/ + sources/ + handlers/ + services/ + domain/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5af9178 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "plba" +version = "0.1.0" +description = "Platform runtime for business applications" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.129.0", + "PyYAML>=6.0.3", + "uvicorn>=0.41.0", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] diff --git a/src/app_runtime/__init__.py b/src/app_runtime/__init__.py new file mode 100644 index 0000000..c9c2ef6 --- /dev/null +++ b/src/app_runtime/__init__.py @@ -0,0 +1 @@ +__all__: list[str] = [] diff --git a/src/app_runtime/__pycache__/__init__.cpython-312.pyc b/src/app_runtime/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..98a9fc91f203b5f4077213279386d47c40482a5c GIT binary patch literal 239 zcmX@j%ge<81lKk%%aj1pk3k$5V1_b2O92_v8A2J-8KM|c8B>`gf&5hF6b2h0UCF4) zbc;PcJ~1aJKHg80rHBbAc#9<`v$*6Ib8$)0Eq93ySiyQj<%HkBzyxJ{Rsk}mGo&!2Fy=7iGDa~ng4j$sOu5Wa%s@7C z3QIau6iX$mCfiGp7EQ)m0?zq)X_@J6nK`LG`H3m1ML;nj5Dy|AP?TSm2^1}21}ZON z0TO#$erR!OQL%nvPHKg|OKMp>P<>Wva!GM~nUOxwxMKZ+oTNnk;-X{_59R`W zhzs=N<1_OzOXB183Mzkb*yQG?l;)(`6>$R%W(4A5F(C1QnURt4E`v1?J>ZtTAa8tu M%cPOLh!ZFU09|%x2LJ#7 literal 0 HcmV?d00001 diff --git a/src/app_runtime/config/__pycache__/file_loader.cpython-312.pyc b/src/app_runtime/config/__pycache__/file_loader.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..90c06171011a5c01221fdd09d7d538b3a5c5df78 GIT binary patch literal 2834 zcmb_e&2JM&6rWx1uI<=iV?q-0QS5|3x=o1-go;)r6cK^cR#qiw53Z`!;#ns)>$N+( zPGid;4mlX95}+OuPV|I8^iZjPK>vVVY}_i*7Ebii8^wo8IrY6=f7MCLr6c*x$9wbU z?eF*AjDH>+93aqs`tf0=A`eqyul}VxA!G|(EAJ_ zCniN#Cm2%BpY*rv0?7bRE)yzl5Gp<4J+H}NiC6r8;6&k^kY<{BTeCBH(@MZx_*Ao3 zq4!-dODS)ICw*SWH7~(nljtPLQ37!UD7PU%96`!MFVF$%gI=T|DnKvMFq|$V{d#~7 zQfWhY(((yW_>}|B(8au&&a7O@82Tr9jp{7biqbX1GvUWI6YiNd*V;@f+>_S0Le$PG zB{1vkcW14r3E&307OsSp$YU;{hzu7Tp#Y!kNGUfGCt_%pt*&WChN`V4S#{SeT{qQL z&01A>N3e7w&5%J04~)fjKaPK9>CB32hQ1zO*4NYm%V+hJZK-Rg;##3##S6wYEpD+C z&Qw-3?MzOOdq>360Dx+^2rLvzPC!*NX2w?4@_0W$3vE>x51$2bi#$7YcM3~1di;?$E(7xfvG0gkyHmMm~^mxA10fCP8Lo2TDF5-BmtvV%R&+Mw|27L z;43`k)*xit-?qsH+M{mYP0K694z<^_$W&kJwN&`T5(cy@ER=G_uDDRra)#pf5R|N9 zI-OZ}L?i#T&Kv=CPSDcQUb;9!*2zOKu`I0d26;IM{9wo$I8CRb{G7+tn_MO4O<6o4w7-7#Oq>)eDs#OJL~L#-9gV| zuk7xIogJ<#{1w8`T9Mp*WHA^sq>dqLj;y9MBULmsTX##J4Z>;*XBI^Z$pw%86X_uG zp;{#~wNHX~bd#{(x(LEc@SC@90)ABi(Vw_Cv9;WYOatblQ*J>|&v^wMJ-Qt|U5}pr zVYv}~50|EP zN~D9YcuxF~Yx-dLr#tk#p$~`^B1{(n8iaHN4WU!4!m5WBX*$!DwL^c5RW-pL zW+5y&tca+m**`$UB}e}N{{SzM2uhKFAbao@ob|FN-|LzgHM#{|^{ZF)UcHas>mOEE z>jcL4-+dF`HVF9*H;Z9wKxgj*n39M@G$ASVDMf4~Mr!(IiOs}HZQm}jl{jg|uawwM zs%g!yQDTs{NaP%nsKRPTrnc+Xi>g=q9XB3TD`A%9GL&(i3D{aULirJ_=4)92%ca-z zY&X7jJx?zSuBHKy!GF3x{JdVcvq~!bU;owJKtI-n=jtAK5xuG zLI%qSV1u}f`Dp-CG9i1gUK}?GM}r>a4ZW3HE3&qbMa#0@ zp|Y}&{$1>w#54NiXEbA6p6Aj{M?$(g%njWX3KeI!JiE+^s$rOcD6)a7$0E*z46^}K zRum7Us=Zqb*_)i_9O#0%Q;sg}oF6)g7Md7uJ|>0hpeA01>45ywdScrCs{M1T^JA-X z((>*)r_Iavp8xvlPtDHh`qpv#+R6I%abx?mx$))o&z6bqdlttsjO#*jcj96zDu**U z;+eAgdB(uIEx1rJdXC3y7{Ldi@q!CI`=M7s24-Q(f^>=LUIxsu5aopbD&fL(NM;7LyXOvZ*8Tw0o|%Bo5j}J)yRDT` zukWqk^SdMP9-ik&301+KOX3}lG*XT%utFU9psxI*uev(<-Cg({%-dEsar?q0$wTPT z`E`yA(LO(m2)SFv@u<43v+yeKfDGHB4CdUVl%A329+0Qb$deDqwKMX{L#IL8_pba& IU@0y91-{@`DF6Tf literal 0 HcmV?d00001 diff --git a/src/app_runtime/config/file_loader.py b/src/app_runtime/config/file_loader.py new file mode 100644 index 0000000..59919b2 --- /dev/null +++ b/src/app_runtime/config/file_loader.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +import yaml + + +class ConfigFileLoader: + def __init__(self, path: str | Path) -> None: + self.path = Path(path) + self.config: Any = None + self.last_valid_config: Any = None + self._last_seen_hash: str | None = None + + def read_text(self) -> str: + return self.path.read_text(encoding="utf-8") + + def parse(self, data: str) -> Any: + suffix = self.path.suffix.lower() + if suffix in {".yml", ".yaml"}: + return yaml.safe_load(data) + return json.loads(data) + + def load_sync(self) -> Any: + data = self.read_text() + parsed = self.parse(data) + self.config = parsed + self.last_valid_config = parsed + self._last_seen_hash = self._calculate_hash(data) + return parsed + + def load_if_changed(self) -> tuple[bool, Any]: + data = self.read_text() + current_hash = self._calculate_hash(data) + if current_hash == self._last_seen_hash: + return False, self.config + parsed = self.parse(data) + self.config = parsed + self.last_valid_config = parsed + self._last_seen_hash = current_hash + return True, parsed + + @staticmethod + def _calculate_hash(data: str) -> str: + return hashlib.sha256(data.encode("utf-8")).hexdigest() diff --git a/src/app_runtime/config/providers.py b/src/app_runtime/config/providers.py new file mode 100644 index 0000000..5f00e23 --- /dev/null +++ b/src/app_runtime/config/providers.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from app_runtime.config.file_loader import ConfigFileLoader +from app_runtime.contracts.config import ConfigProvider + + +class FileConfigProvider(ConfigProvider): + def __init__(self, path: str | Path) -> None: + self._loader = ConfigFileLoader(path) + + @property + def loader(self) -> ConfigFileLoader: + return self._loader + + def load(self) -> dict[str, Any]: + config = self._loader.load_sync() + if not isinstance(config, dict): + raise TypeError("Config root must be a mapping") + return dict(config) diff --git a/src/app_runtime/contracts/__init__.py b/src/app_runtime/contracts/__init__.py new file mode 100644 index 0000000..c9c2ef6 --- /dev/null +++ b/src/app_runtime/contracts/__init__.py @@ -0,0 +1 @@ +__all__: list[str] = [] diff --git a/src/app_runtime/contracts/__pycache__/__init__.cpython-312.pyc b/src/app_runtime/contracts/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6a0d031ca978f4221f26d5bb71f37673a375c91d GIT binary patch literal 249 zcmXv`u}VWh5Zrq&@KgvkN{oMy#slkMWhYn&X>6ABWs#H1-LZQKrn8Kloqr%$`x&+( z@>+jDNSDeD=)msG>Ka5B0^;SU$CVztUhq=7S_*gjn#GukD?UX*e1)|(U~^$7LM+w=y0_=Fz|856+9J3( zwJv9|Iovoa+-@E6JQhDA+oo99Y0V<~=nLXWUT${AvoxItI^$jaZFJ;N*H3MX`GUdI R)kCv7ShbJC4;XNx$uHn8MWz4% literal 0 HcmV?d00001 diff --git a/src/app_runtime/contracts/__pycache__/application.cpython-312.pyc b/src/app_runtime/contracts/__pycache__/application.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..35ebf3e1b05c5157754dee67380fc8d8f099143b GIT binary patch literal 1042 zcmZWoPixgc5Z}$e#TGVM-DBoz!^^MK8FGi{c5!bn_$7O=2`Y+3*$>b}!lVWBP>q;6eZTR#tI)_ZM zkS8oOq$#b$O89|wG&_B6LmM-8Vq9cGXJYzJyR=?aSsHg_bi)|$;mSe{d`cY;*@XMX z{+d4L6(W~jqh#W3m*7ED)G2N#6xJwD6p5FlVy7rooyKY~{6xwvC9`cs4ZO2qu%#rZ zKxFb=@J#MS6_l@KT&rkrH4s&$f+`z{KtW7VG^-!PWzpRezIefWP)%Bk`0U@gre{F= zH?ZZ@KzPrlh%6@m^7>!obA9g;ytgI1L7or2-9|PN?HILX-462KOJmw2xnn7^dhB%s z(@|5GfU<$+%Qs*FP3yw!V=8Lzd9ZkCSHk|?>*Vzt68d;j!%g&tI5rN*x`CoKaXNep zmgs;(u#C`J!?KY6T5rmzK8o^gqR?NAqTNPhvy6*3UrDGZKqC)5f?EW{9jISN z6kPHM=uvA32khAH9ox&l4Q_ApsmYw%1pQNs;!_7>N^R-Xug=v4sl?ECiPd(4I*=jI z&(fM=6a!6LN2Xo5^z8?6334#I!){`=;EvZ-u4nEOE%qs_U@e+Bbr0dx*`;>((o)ca1idsM8}KYbTS0nRMDY@lVKy_nM)J#<$^yGU<1v`gtzBhUA`(EDrG#YIYIA`xaq+j6viow6*^@MH? zft(UaB+W=pV@i=bnUk}aO{AyTIqNmzLF0#m?-+!%y&;OC zw8ExkVM3>62Zu-PSj>#oBC)x$uSyA7?4W3pzP!gPExNf4@hMRxrjo>tVvCX%mS|okWk+l2^KXtrBba@_r<$%0yK*6OqvgMqJm0O>-52aQBM_ z8T2-(n%2!qAy+%so1Ng}5Uh*?@Rn=<$rMKA>#oVwSf+`60Ry`iz?5EwUOVKx5V_*K z-QqkiWt}14=KM`9vi^(4h3XLkE?tiiwh_>(g8>k;3$P}iy`5hU_4YT6_`5%m^#zza z4ToqmuVG?fqj57W6SG9)Ks%q-w@sB63+>}eXK>y0a`>Qc^P%2@6dbI%1@MDWN-xNr VZ{+R;+57D?y8rIk9|BkB{})2wQleR%g~IU)d`Z18&pj*xy2 z$p&)BNdYBE39;NOyppE0^izL`X%UoR8WQl}KIHxy@<7sK>NL{PDvU>8Y!H*~f~cz6 z2(zp!9eW+UySLwMi@7#RWTup6QRkR7c~YxqQazBOFpJFTI6M<2{dJ5tAR#3=q@JWX z&B@w7KFdx0l^6TnMW?V|S4J)8t*KRpot|4LNO~WCZD2)W?AOb))>8;~zFFoD*xXq>wq*`l z92%BU_I^4l=9#iH9qM$~{^>gT({=JG+N_zpE~zlpVFcitv>ZLQdeu UXSjI+w||6`u-Eh_;Ae343)YXen*aa+ literal 0 HcmV?d00001 diff --git a/src/app_runtime/contracts/__pycache__/queue.cpython-312.pyc b/src/app_runtime/contracts/__pycache__/queue.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f91ff163cc5aecb684379dea9e2376130a4b842a GIT binary patch literal 1592 zcmah}&5j#I5bmBo+w1j){160zXp|h1LD{vmhwKFrg&@Jn;`d^NM(yeL?j$`kPIu2L zwsJw*2jI3MFMy&v0WZJ-UI2}h6GDjFg5i=ARXyYJZlowJxu&}M>+0{TF88m4!3Kfx z%g;Zij!VezI9Uz9cIa{h&N1PHQ%N#9rWAcgI+;6mGjHtKxhwrF7zft(WGCy6yOcQO z6Tw=<+@|$3&1Z-!Q-tO~ zgb#7=&gdz5>QtTAvw3Ia)C>hSve7_;2HX+{`7A{L3EC%bsz!cA1pFT>OzJ+K# zUcTO|U#9yxli?I9A^0m!j$B&`h0?0N5=PBqE+m_4Odp0}T@)`zK3i)(IKK>tHKByo zG+O&IpPQ>YQY_cALYZwT4uz~6F4XvE(XN8srZH{9!bgx^oLkedfp^aEh&(Gs zbs$;=pa}Y+^6>T}y8q+{96#Lw&?Q{=r?Oy1y$AlHk7>ilwoxKFih^|>^)>=FEx1A0 zJ?bjNp_}afEuJRk8+ajZh43tV8}+Jg9Oo<(aa?W0aaQnBqQ4o(4@)NN6@{;vdIQay zXx>6&1-*+NcB^eRdkw0GQ#AO~9Ly2<&A<4ML;d%cE(tE3pm!30w*>EY8@p{|h}&&r zyDe_JjcvEMJsbNQcI?#z@W@fLfl{U=6cwZ>?2*c4lSJXOVzpF(na`lJRe)<(PQ3v; z?HjOT@IkRRR7*N5^F{ZAx>z5o8;}GWKx2hZTuSM4a_vuY{W-b(2f6icaFOnP@BT;N HV@duG_jP!a literal 0 HcmV?d00001 diff --git a/src/app_runtime/contracts/__pycache__/runner.cpython-312.pyc b/src/app_runtime/contracts/__pycache__/runner.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..79e3067bd400a63124899a3597abfb7da7ff3254 GIT binary patch literal 1070 zcmah|&1w`u5bmCynO(Bk_?JWxghkiGfbM|t;2{Vb1@W?n1TP~pOlCSdGJkgWgzO#! zVc#LXfh>6xUqA+O@*v(;S1&nP)stUbNFTnb{<^E`tD5Ss?e+qJ_3_DKZ z)drnyfU!>^648XD)T0z_BQa9uvDEZTy=RG)+MZ2`L0*u^9Fxf6?10rVyvERW8b5L4 z(5e+=S*`*V=b3bjiuE_1RZGFPR3Zpf%GJ9(f?KSg4a3^K#^YEgdN}cDL_CAD$cpIL zJUG7VnVK=qF`y)}BX*9lho;l0uJwx|i9@Yqv&=FsLTJK;1xe}51ZtlQsbh@pZ>d12 zPH^VFlcjBH%R!N`<(Ckg~;FYP)UFH zkqgnJTO`|oD@BMqUzC}OQ|^X&rn@3tU_f857*>`<38S0;iSOxyCghtL0H4SVkX%|) zcT45Pr4q{dr8-tNxhh50sVnV3F{+fk!TiK_ zeLT7~FEu)1%CqDHm@`}G_?0=xGwv8-1#XO%qBvBqVe}q!VBX%qP&9o%3sUa;)q?M* zc~mB7FZ%w6GDzwZflos$AzVjTM$rAyS+O$$e}jew7;+t8Oukt+ejC(!JY&ROo}x9i z!9s5Blv_LHx|;h1r(NAa9d6(>QP@riy#iqZUq!`&?NH#9YGswJhDDsc6*e+j$VG8S o&Govlr{WG=!3mYO0j7*ndPwg6AomW*>L0sByPw%#0w10KAChnZt^fc4 literal 0 HcmV?d00001 diff --git a/src/app_runtime/contracts/__pycache__/tasks.cpython-312.pyc b/src/app_runtime/contracts/__pycache__/tasks.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..74b93f74ca9e5d1a680c58c75e88bb23034996a2 GIT binary patch literal 1136 zcmZuwOKTKC5bmDonceK>;Uh#bJ_aA)fMmdER<*VLvO2}ta zc9v5EI(z_Nl?WneLQ)!1ir7l5)DG>Gg{;!;#7W)IO&ejO(pl0>N5TJI&$MqD`@+Rb zW}GkSLh-mrWpOJPU>*^?;BmsWHcq!E69Fgf3t71UyQ7o5(D8Q8YTM34hjXB=5=lZT zNN6oW$I=$oELepNh#`W0m~`GaJN<-i<#W+jHvFbx3tZnZHk4#o##6aGE_!is9rQCE z)NsQz`n*hXjzvuf+2w^ ztzy(7HOPuyDuX!BDr$95Kv#OEU#d1-SBWM7eHg?O@~JiU_}a$U^m{h_;n>Ny3+u1H4K~ z>RZd+mHRRt6w(uU%6plYa624bp+Ik5aTLY3{+-VC(oqssJ-afhhjep{80)Ut_rA`!G!A2 zj+g+TCqb-`4QKy%i#oy3Chp`nMD+oT?f9cO65Ro0gON};z{ciFu|giIJf{8k4@j+C zG*^@17G=Me-BkE^^;1^Kk&%A|{cg_I<2I*GfdhZ8J`ZBcrj%}y<6p>$kL2_wnfm4u jtNmJlMK#3ioek*Z`#5{_k0uBNP1Rk0Mor#!W) zSz0x1r3J5edR4ddYR1Y4UiGrooRzEQtvvAiTx~(I3NoRZo4&6v!w&e!W&sJZ*I)77Dv86hW)&DGS|u>UM)}&r`ZRVz*w9_0fELx86p- zx&Lq6+LWT4%=mopD58nr5j{KJE`)2H`vG4UC z^55lGa>EamVb%?j>kwX*dqK#b3Ij-`<{6+%gc3_~i6!5F!lWrz9)npiN{JB^rmMod zmT;<@22Mw=p_rK{e;wUB58l!{VU&WrK{{~CuDDT}*Bl=P6I)pj7@r#xkff+sa~SnQ zI}y(Ab1UWW6Hpj2&iai{TWVP+3|VEe9#U>C^YWo^3}xOET5dE5T1?s$o$ z%eHq^lWnsD;3XSG(uD+r!g`Qk-~=hafFA<#CHbS+Gk=;?a=Vt#NzmhGQ4(d zWN!Rn_wcGR60gREdq?v%kZea2Vx+jzg3@k01!h!N-Z7&#QgE)mo$-4QYCd3%a%Vk? zs>Om_A`QuuXN~iWLh&1p;nUj&3xd!PF*mSkAP=Jx;=s@j3a8#wlnH8-#XW|m4MRjB z(qXlYt7nJy@M5$j-49EexbBNMgk^AN*VF0V!M1zln-$O9+qV=u3`Qa?E(Uf47>M+$nlnJRYwEc>6#F%e9g0v*cU%mX?q)!iZOby-mxh&fb0Etnon44}chY&3M3oJIj)s<3!bXkWlcFw{PC0ciw5Q^ljv+EjXRz}T;kJsq z+a4@JzjHI!nTD5P2UbFYEymJF5CPenKq3XLu(y%#NYDwKLxKi4f?cb}bH(}bwIO5f z^4-}L#aJ5{oV#%M_=+<4XwY~tusV2pZhTQ+QBFS^I`rVk>d?u#i+9hgDktN#p~1bA zbq6HROcp|{f*}?VtF}i1ULjoyRqv-;w~M8@I_PJTbSaR^ZJF+7L6#Hg%A19#OQh>c zFnyJB?o87ixl(vYYmvs`nyvFFU5ynY)q`w!>~-))WJd&)y@f2sbHdE*nTp*BVDGf_ zJL7)}1xT=a_-P=m_-70Am)G)L^B=@$i|Kft(4(zgp-*5^Y7T*9JM=N~S_$4-ion^T z2oDgS*8&zk6JifugG;-@JAl<-q7M>$y(cFa7SHmX*jl*FDj~HKKu#d6z5VDesi@vI zggw)>1G`=Gf@vd3Y2p`q7viy(H;LN+c#}v)-rC+Epq>SDqLTz#Mhf2It!bexK=dML znQCXroWr#jkaV;sHY7uW1;_J17T}fvc-+(^)r^}B#S^9+gEanQnSFTuPt^g{;reF? zlu^o=EHed00R!H#juYs(mBuV<6yz&=0{FoejOkRCVjHV_@M>ljL=N9`{rTtH2MLMxg zfWTcK53D1*SxieK-yYs1@Y(1`%_FPQz!O=MPOTFlsQJP=vKzhI(lkneBvKe{_N$V) M`0>94J|cGi0YwYRcmMzZ literal 0 HcmV?d00001 diff --git a/src/app_runtime/contracts/__pycache__/worker.cpython-312.pyc b/src/app_runtime/contracts/__pycache__/worker.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c698272429872515fc6a97fa0b1b323cd5ac61d5 GIT binary patch literal 2750 zcmb7G&2QX96d!-C_cIZs8%n=S3#Fzc3vFqks)`DwZ4k<#rBpIPmgAXCOuhDYY?p4t zA*ex$I3RBQ6Ht{N_#2QoacPwb1xu(%91ypZ1tI0cdt>h=X-gw4?a%MaGjHB+=FR?C zuU7~>KmG8n|E5OBZ>S6&(D9M_-vizvlu#)k9m$p?l;uF~D7Mm3ZB^(>pmlUx@09G4 z(AB``lDpcDEqu5oDbRpGb!gN}?V=PNJ3n4N&PiT3O(8{u$TbA<|vFlHa ztwL<=vDk(frw+abQf1P5dZ~Ejd%pyz_3>aA)F#yI#^^ z$y`LiT&8Z~dVw3qnb!7MfHvh!*vm>E_zB}~U@4g#UCj*2X1Pll&B|#w$J`*9>seZ6 z#EHujKb*}}p9U;bXVSQb)~MS>Ye`6qeh3L^aS$c3S1eb&io~V!=@L|lWjZV+Nn=<-6a|^#aX;}rH<(_pzz62%=5!Q75M_z$2e+kcgtE4q z28q*#az?zj-k`phdDcS;cWW5lsV>YmRd*b%Vk);y9bw zavZ)Ja_1$0OvPcj3F2+i2PErLt;;dvamx+Zjn-Lq-Rbh^Q|2YHbNxum?RMiljjyQVtBq%FPOt9V zvv6+d{S|f3&%4a;#_sPrzHoMF^1gbUzdZQ*$(-8Y-kwunQU|t5f>*=VXc@Fh!K=P~MMl0O3W1Ndys&)bN)OOoW37q8x`%dSw8ZQ~VmhpsdyAirQS= z(Oj5XicxxcZE>6}wN=3YkJSGfaHF43nnE_v!%1`j=;Hgk$!(*4g zQl&MjE^5OO46xOywkfI%mIl=~St@YOMlwCGyH~V2ktzb5a;cB0-y%K9lIITqGcy4* z^L=RI88F)sH>vN=hEWX1E}ongHHCAg#w=*ZIHbu%agpNrg5pX17Fp{fzhC4WY&-wS zD|68P7&ntSKOSTO5@**^pED|wDSDgfq0F&-|$f?1rH(AhoK zP6HE2@H-s_L?q0_)?F;vlKD{>1FndOdo0F*&72P42f~JYpfUm8O<8QW8+Ko0NymQp}tVcHm=FKL?Wx=fxz`_>C>| zPQwkVbFn7Tlc08|F#!J$6mq8GWnxL^ES0}C@a0i&Z$yd4Jop-0fmMB^D`f_>e%PBy zkK^AM(fv5?MScR|Bm#Czj!8e-q_8bKzXDh!t6F_kd-hLR(vI{MqK_do)>Kf} zbdU<)l(f}3C82i=KRYNqYbXg@e>fD{#3&-}@G$Q1@Y@MtdkAq8c`wD}rma!DH`^)f z5x6%w>|yBR?u^H=p@?N?)!}~_7IPiV;^x$o9{va623pDp;|O@ms^Y%E=@+v9cXHqXIrM<+gVaZwcbiA=HI6M*R~tL-HqYN{Tv(_+ r)QMdEX6(My{72TL6Kezj7AKFdq55$2Y2Lp^cS;jWm;NU35@q-o4#jt@ literal 0 HcmV?d00001 diff --git a/src/app_runtime/contracts/application.py b/src/app_runtime/contracts/application.py new file mode 100644 index 0000000..82441f6 --- /dev/null +++ b/src/app_runtime/contracts/application.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod + +from app_runtime.core.registration import ModuleRegistry + + +class ApplicationModule(ABC): + @property + @abstractmethod + def name(self) -> str: + """Module name used for runtime status and diagnostics.""" + + @abstractmethod + def register(self, registry: ModuleRegistry) -> None: + """Register workers, queues, handlers, services, and health contributors.""" diff --git a/src/app_runtime/contracts/config.py b/src/app_runtime/contracts/config.py new file mode 100644 index 0000000..1272984 --- /dev/null +++ b/src/app_runtime/contracts/config.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + + +class ConfigProvider(ABC): + @abstractmethod + def load(self) -> dict[str, Any]: + """Return a config fragment.""" diff --git a/src/app_runtime/contracts/health.py b/src/app_runtime/contracts/health.py new file mode 100644 index 0000000..2ae01d3 --- /dev/null +++ b/src/app_runtime/contracts/health.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from app_runtime.contracts.worker import WorkerHealth + + +class HealthContributor(ABC): + @abstractmethod + def health(self) -> WorkerHealth: + """Return contributor health state.""" diff --git a/src/app_runtime/contracts/queue.py b/src/app_runtime/contracts/queue.py new file mode 100644 index 0000000..498796d --- /dev/null +++ b/src/app_runtime/contracts/queue.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +from app_runtime.contracts.tasks import Task + + +class TaskQueue(ABC): + @abstractmethod + def publish(self, task: Task) -> None: + """Push a task into the queue.""" + + @abstractmethod + def consume(self, timeout: float = 0.1) -> Task | None: + """Return the next available task or None.""" + + @abstractmethod + def ack(self, task: Task) -> None: + """Confirm successful task processing.""" + + @abstractmethod + def nack(self, task: Task, retry_delay: float | None = None) -> None: + """Signal failed task processing.""" + + @abstractmethod + def stats(self) -> dict[str, Any]: + """Return transport-level queue statistics.""" diff --git a/src/app_runtime/contracts/tasks.py b/src/app_runtime/contracts/tasks.py new file mode 100644 index 0000000..7f552ba --- /dev/null +++ b/src/app_runtime/contracts/tasks.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(slots=True) +class Task: + name: str + payload: dict[str, Any] + metadata: dict[str, Any] = field(default_factory=dict) + + +class TaskHandler(ABC): + @abstractmethod + def handle(self, task: Task) -> None: + """Execute domain logic for a task.""" diff --git a/src/app_runtime/contracts/trace.py b/src/app_runtime/contracts/trace.py new file mode 100644 index 0000000..e9c8fee --- /dev/null +++ b/src/app_runtime/contracts/trace.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Protocol + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +@dataclass(slots=True) +class TraceContext: + trace_id: str + span_id: str + parent_span_id: str | None = None + attributes: dict[str, Any] | None = None + + +class TraceContextFactory(ABC): + @abstractmethod + def new_root(self, operation: str) -> TraceContext: + """Create a new root trace context.""" + + @abstractmethod + def child_of(self, parent: TraceContext, operation: str) -> TraceContext: + """Create a child trace context.""" + + +@dataclass(frozen=True) +class TraceContextRecord: + trace_id: str + alias: str + parent_id: str | None = None + type: str | None = None + event_time: datetime = field(default_factory=utc_now) + attrs: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class TraceLogMessage: + trace_id: str + step: str + status: str + message: str + level: str + event_time: datetime = field(default_factory=utc_now) + attrs: dict[str, Any] = field(default_factory=dict) + + +class TraceTransport(Protocol): + def write_context(self, record: TraceContextRecord) -> None: + """Persist trace context record.""" + + def write_message(self, record: TraceLogMessage) -> None: + """Persist trace log message.""" diff --git a/src/app_runtime/contracts/worker.py b/src/app_runtime/contracts/worker.py new file mode 100644 index 0000000..ade5449 --- /dev/null +++ b/src/app_runtime/contracts/worker.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any, Literal + + +HealthState = Literal["ok", "degraded", "unhealthy"] +WorkerState = Literal["starting", "idle", "busy", "stopping", "stopped"] + + +@dataclass(slots=True) +class WorkerHealth: + name: str + status: HealthState + critical: bool + detail: str | None = None + meta: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class WorkerStatus: + name: str + state: WorkerState + in_flight: int = 0 + detail: str | None = None + meta: dict[str, Any] = field(default_factory=dict) + + +class Worker(ABC): + @property + @abstractmethod + def name(self) -> str: + """Stable worker name for diagnostics.""" + + @property + @abstractmethod + def critical(self) -> bool: + """Whether this worker is required for healthy app operation.""" + + @abstractmethod + def start(self) -> None: + """Start worker execution.""" + + @abstractmethod + def stop(self, force: bool = False) -> None: + """Request graceful or immediate stop.""" + + @abstractmethod + def health(self) -> WorkerHealth: + """Return current health state.""" + + @abstractmethod + def status(self) -> WorkerStatus: + """Return current runtime status.""" diff --git a/src/app_runtime/control/__init__.py b/src/app_runtime/control/__init__.py new file mode 100644 index 0000000..09b35fe --- /dev/null +++ b/src/app_runtime/control/__init__.py @@ -0,0 +1,5 @@ +from app_runtime.control.base import ControlActionSet, ControlChannel +from app_runtime.control.http_channel import HttpControlChannel +from app_runtime.control.service import ControlPlaneService + +__all__ = ["ControlActionSet", "ControlChannel", "ControlPlaneService", "HttpControlChannel"] diff --git a/src/app_runtime/control/__pycache__/__init__.cpython-312.pyc b/src/app_runtime/control/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0ba4dc530cd380782c872a52d6c039bc6f6b1768 GIT binary patch literal 455 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 Oi_dcH4+&Ik|Nj6<8IPj? literal 0 HcmV?d00001 diff --git a/src/app_runtime/control/__pycache__/service.cpython-312.pyc b/src/app_runtime/control/__pycache__/service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9fced0b9a6f5426ad47355aa4155707f21d59b83 GIT binary patch literal 3442 zcmb_f-ESMm5#PNp9w~~}S4le72jyB850OaAjgzz}0vNI&2S$aUPJ@K4d!TtInk;o> z_l`=b%1)3Ng;S((g#uL`^56ggT)|1xe<3eLfxP%i7$gohkRT6j-<&W&0zYMDkEhTS z)xLBd_I77?W_Nx&GduUm@Nk3xo%;Lj(%%$9KF2{olHZ}7m!Y#u3}Vm<*`PT}u`g7F zoB&_3B5p`IiSwk2yrJZjjZiK`i77D!=H-gI5zd7v5y&brl zqux;7vZ_wrDOD|d4aUPSzVgC~zI=XV`NH#StD4}AELSauRV!x;xaOkic(E>L`3lII z6%dO3v~D@24f7{?D_=BO!QaSlySviPIT-H}ljNvDz=p6vuS+@6h!_HVC1c1C;VT1Q zg0Esk4H>>6;4AP|fggfzxS?vp-gx%}FI4iDdC_E>rGiw%~#CdWY3wKdW}`fX2G%b&C}U@t!8Iy zmCO08%?dcunQwfy-~*KfSv*jt*6>taFIgo=*S97I961xr4#T!~7@F5fJG*p4`a`UP zr!91`!#*ydz_|!O@!u~!qfWwjTo7aAStsNb^uO7KM^w!WH&$TAz+=;nc z#pXojo|^Sx>GfkYk;7s@=l>1M9ngt%7$__Wlk6xo-Vgv%t@(H`?yWphd=HUuZYwz$ zf@%%5*t5_ag1`xP;zV=e_5=HAxfdOnL<;PpRD{ePE7Tb?Ek_S#FcLwjyqT}mO;4&oD)Ll6 z*{}w~z+xwG8`ZY*HTz1{VROhk$+z~E*xsEcCgPBtgzpQ2Hbiy*&3a9N9UWP+70E?Rz>#S#dKf&CF^ue*Pcl@4oblyO&J&rS-cPif+6J>s#?B-T0HOc-D<) zoAISDhC?Hfha?n^9E>a|JII^(LjDsv7YK#ua`s~jykI~S0at>>eFR+8)knY`5K$k{ zJ6#P|1xVEc;_Do_1YUDh+!o82X8LGb2>kX?SNxqU!};HylHhrJhP)m6 zP<)kY!kQ-Wdu5ZLgw0@c92zae@U8KNec#~jRR<5@$vSw-y(mw2-GuMOx}EM~{$g;o zumwI1T}RLJ3dv>|b~f4x-qdxcCt$Rd8k8YXXYkUbQSg`0{F{99I=Me~ygmLzYdr0a zr`uykTVwO?*gSW&Id^o9PigMB_ElIOkK717jFRN!?zNq3UlJPDZmfdD?&8kktx$6; z-HN8&X!;8gCfd>TBO9~I8)uK5ou?m9(X(^P$I~kGA1k2n_}o1l=p1klGRQrGtAQ&7 zbFX45Mx^^FNE-t#aI*IT{W?X&Sl@(HVH!T_{2sBa=zWFRG%&Vi4uLm#w@#zfDQJ9j z6EnA@+p+gz%|sRSe4mFKH319IgC@ zsq0=u*ZmX<{iv@0x}LB2GYsDohUvP;$1!m+bTtFchzr+=jW*hk?xwhTN>RlKNp jC{juvkmH||*aMRK7g_mC9C;u;^R<$uvtJTuxQcH9zTndU literal 0 HcmV?d00001 diff --git a/src/app_runtime/control/base.py b/src/app_runtime/control/base.py new file mode 100644 index 0000000..06e9566 --- /dev/null +++ b/src/app_runtime/control/base.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from app_runtime.core.types import HealthPayload + +ActionHandler = Callable[[], Awaitable[str]] +HealthHandler = Callable[[], Awaitable[HealthPayload]] + + +@dataclass(slots=True) +class ControlActionSet: + health: HealthHandler + start: ActionHandler + stop: ActionHandler + status: ActionHandler + + +class ControlChannel(ABC): + @abstractmethod + async def start(self, actions: ControlActionSet) -> None: + """Start the control channel and bind handlers.""" + + @abstractmethod + async def stop(self) -> None: + """Stop the control channel and release resources.""" diff --git a/src/app_runtime/control/http_app.py b/src/app_runtime/control/http_app.py new file mode 100644 index 0000000..70aa5b1 --- /dev/null +++ b/src/app_runtime/control/http_app.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import time +from collections.abc import Awaitable, Callable + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from app_runtime.core.types import HealthPayload + + +class HttpControlAppFactory: + def create( + self, + health_provider: Callable[[], Awaitable[HealthPayload]], + action_provider: Callable[[str], Awaitable[JSONResponse]], + ) -> FastAPI: + app = FastAPI(title="PLBA Control API") + + @app.middleware("http") + async def log_api_call(request: Request, call_next): # type: ignore[no-untyped-def] + started = time.monotonic() + response = await call_next(request) + response.headers["X-Response-Time-Ms"] = str(int((time.monotonic() - started) * 1000)) + return response + + @app.get("/health") + async def health() -> JSONResponse: + payload = await health_provider() + status_code = 200 if payload.get("status") == "ok" else 503 + return JSONResponse(content=payload, status_code=status_code) + + @app.get("/actions/{action}") + @app.post("/actions/{action}") + async def action(action: str) -> JSONResponse: + return await action_provider(action) + + return app diff --git a/src/app_runtime/control/http_channel.py b/src/app_runtime/control/http_channel.py new file mode 100644 index 0000000..9d5a374 --- /dev/null +++ b/src/app_runtime/control/http_channel.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import asyncio + +from fastapi.responses import JSONResponse + +from app_runtime.control.base import ControlActionSet, ControlChannel +from app_runtime.control.http_app import HttpControlAppFactory +from app_runtime.control.http_runner import UvicornThreadRunner + + +class HttpControlChannel(ControlChannel): + def __init__(self, host: str, port: int, timeout: int) -> None: + self._timeout = timeout + self._runner = UvicornThreadRunner(host, port, timeout) + self._factory = HttpControlAppFactory() + self._actions: ControlActionSet | None = None + + async def start(self, actions: ControlActionSet) -> None: + self._actions = actions + app = self._factory.create(self._health_response, self._action_response) + await self._runner.start(app) + + async def stop(self) -> None: + await self._runner.stop() + + @property + def port(self) -> int: + return self._runner.port + + async def _health_response(self) -> dict[str, object]: + if self._actions is None: + return {"status": "unhealthy", "detail": "control actions are not configured"} + return await asyncio.wait_for(self._actions.health(), timeout=float(self._timeout)) + + async def _action_response(self, action: str) -> JSONResponse: + if self._actions is None: + return JSONResponse(content={"status": "error", "detail": f"{action} handler is not configured"}, status_code=404) + callbacks = { + "start": self._actions.start, + "stop": self._actions.stop, + "status": self._actions.status, + } + callback = callbacks.get(action) + if callback is None: + return JSONResponse(content={"status": "error", "detail": f"unsupported action: {action}"}, status_code=404) + try: + detail = await asyncio.wait_for(callback(), timeout=float(self._timeout)) + except asyncio.TimeoutError: + return JSONResponse(content={"status": "error", "detail": f"{action} handler timeout"}, status_code=504) + except Exception as exc: + return JSONResponse(content={"status": "error", "detail": str(exc)}, status_code=500) + return JSONResponse(content={"status": "ok", "detail": detail or f"{action} action accepted"}, status_code=200) diff --git a/src/app_runtime/control/http_runner.py b/src/app_runtime/control/http_runner.py new file mode 100644 index 0000000..bca3afd --- /dev/null +++ b/src/app_runtime/control/http_runner.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import asyncio +from threading import Thread + +from fastapi import FastAPI +from uvicorn import Config, Server + + +class UvicornThreadRunner: + def __init__(self, host: str, port: int, timeout: int) -> None: + self._host = host + self._port = port + self._timeout = timeout + self._server: Server | None = None + self._thread: Thread | None = None + self._error: BaseException | None = None + + async def start(self, app: FastAPI) -> None: + if self._thread is not None and self._thread.is_alive(): + return + self._error = None + config = Config(app=app, host=self._host, port=self._port, log_level="warning") + self._server = Server(config) + self._thread = Thread(target=self._serve, name="plba-http-control", daemon=True) + self._thread.start() + await self._wait_until_started() + + async def stop(self) -> None: + if self._server is None or self._thread is None: + return + self._server.should_exit = True + await asyncio.to_thread(self._thread.join, self._timeout) + self._server = None + self._thread = None + + @property + def port(self) -> int: + if self._server is None or not getattr(self._server, "servers", None): + return self._port + socket = self._server.servers[0].sockets[0] + return int(socket.getsockname()[1]) + + async def _wait_until_started(self) -> None: + if self._server is None: + raise RuntimeError("Server is not initialized") + deadline = asyncio.get_running_loop().time() + max(float(self._timeout), 1.0) + while not self._server.started: + if self._error is not None: + raise RuntimeError("HTTP control server failed to start") from self._error + if asyncio.get_running_loop().time() >= deadline: + raise TimeoutError("HTTP control server startup timed out") + await asyncio.sleep(0.05) + + def _serve(self) -> None: + if self._server is None: + return + try: + asyncio.run(self._server.serve()) + except BaseException as exc: # noqa: BLE001 + self._error = exc diff --git a/src/app_runtime/control/service.py b/src/app_runtime/control/service.py new file mode 100644 index 0000000..821e6c9 --- /dev/null +++ b/src/app_runtime/control/service.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +from app_runtime.control.base import ControlActionSet, ControlChannel + +if TYPE_CHECKING: + from app_runtime.core.runtime import RuntimeManager + + +class ControlPlaneService: + def __init__(self) -> None: + self._channels: list[ControlChannel] = [] + + def register_channel(self, channel: ControlChannel) -> None: + self._channels.append(channel) + + def start(self, runtime: RuntimeManager) -> None: + if not self._channels: + return + asyncio.run(self._start_async(runtime)) + + def stop(self) -> None: + if not self._channels: + return + asyncio.run(self._stop_async()) + + def snapshot(self, runtime: RuntimeManager) -> dict[str, object]: + health = runtime.current_health() + return { + "runtime": {"state": runtime._state.value}, + "modules": list(runtime.registry.modules), + "services": runtime.services.snapshot(), + "workers": runtime.workers.snapshot(), + "health": runtime.health.snapshot(runtime.workers.healths()) | {"status": health["status"]}, + "config": runtime.configuration.get(), + } + + async def _start_async(self, runtime: RuntimeManager) -> None: + actions = ControlActionSet( + health=runtime.health_status, + start=runtime.start_runtime, + stop=runtime.stop_runtime, + status=runtime.runtime_status, + ) + for channel in self._channels: + await channel.start(actions) + + async def _stop_async(self) -> None: + for channel in reversed(self._channels): + await channel.stop() diff --git a/src/app_runtime/core/__init__.py b/src/app_runtime/core/__init__.py new file mode 100644 index 0000000..c9c2ef6 --- /dev/null +++ b/src/app_runtime/core/__init__.py @@ -0,0 +1 @@ +__all__: list[str] = [] diff --git a/src/app_runtime/core/__pycache__/__init__.cpython-312.pyc b/src/app_runtime/core/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..14ee7858f244c73f2ad9e11b372e6296aa633638 GIT binary patch literal 244 zcmX@j%ge<81lKk%%aj1pk3k$5V1_b2O92_v8A2J-8KM|c8B>`gf&5hF6b2h0UCF4) zbc;PcJ~1aJKHg80rHBbAc#9<`v$*6Ib8$)0Eq93ySiyQj<%H|F3%cV zfzR?T&ly4?oDDN1M9vY7UnH8a5%Mf$#rbeb{1X=`#_3W_vuG=JzGzyo6wfGzp-dS% z@a#wCJWM@jisp1a_bDyT-o!K}fT^>(W9nb6P}W zVdk}Nje}W$nTI(HvjDTG^=OhNF7nw9tyk-Soldx7pR>z5xkTOTT~bUX2j?>_7eJTb z4ooBMo}VKXA_ro36?~mXSrY<6=E*uUkz$=rdA8}7xIn8U@HJjb-M(`a^C@%BTyZIDeq~EnI}Y0trI{1wZ3RAz8b&1 z$Hi`YAhkJ=Li9$e=YNar`u6iX2X9|}92rK$MxII0YWF_@!g#@tX&eEjM(h9{$dfiu z_^$dchA2AbCI#&Q&TFAwn_zRgT^?xb2}mH(iPZ<>CTSPN2{p)F_=ba@d&~dc>M9|> zF5cW3bC`|*@gu^SN~($K;pY^i z1o2W!l7Sg}^S>b(PCta^6k#Mc*Ibb+xK5z-rL)?g}xwQiTF>2MQ8Q%`Yj`=r>K=`kel z0fAlY5#UMl$#&%mHr^|iO^+nu={7leB#GLSEh-_Gf8De5L+?*spqQo(3;OgR9)cO@ z=^~dB=ooGsM}qypl!?70 IKwRa&0gkkV{Qv*} literal 0 HcmV?d00001 diff --git a/src/app_runtime/core/__pycache__/registration.cpython-312.pyc b/src/app_runtime/core/__pycache__/registration.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ef75b124b8247ef98a4848500ab28cea3aadc8cb GIT binary patch literal 2291 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 literal 0 HcmV?d00001 diff --git a/src/app_runtime/core/__pycache__/types.cpython-312.pyc b/src/app_runtime/core/__pycache__/types.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8204dbd1e6e84de9fc1879503a5b45f6462cd707 GIT binary patch literal 923 zcmY+Cy-yTD7{+IAKe^-XM5Ac5Ach3b7&o!7A|~n~pyVVTSGa^smYqS@?3XjU#I?eJ zJsp;I#^}FcWovPngvwZ$SeP6%RKBw-C{sN1d*Atb=b8E1Y}OI9Utc~1DMskK3XYpu z0Uo&kdx#>6Llj{bW5uhS0`z#d}A#T2=< z0hGx!TAPJzN!khNB^fYkDk`jwEt?C>3$y3TUJ)i9m72)7GzAoq3u#i8c|j-*6|>iq zVIZ9epE@Lwf%5-X6Bnd!_OYid;mTh8JHiQ@)*8^YIrmS z`p;vEU2xddspc9~cTH-zR$+E5*;)!Vm|yrI>nqEo4bJf_h@VM4pdpj`dM*l;P100l zt&$k+b!^^*BX~4cnKt7KV+9t!^b{*qVGZU#(M1dYv)#TpM|ob9gSUewWl4{6Mb$ur$G4 zsF)iNR0i9M%F|9)aAeO`kA8 None: + self._providers: list[ConfigProvider] = [] + self._subscribers: list[Callable[[dict[str, Any]], None]] = [] + self._config: dict[str, Any] = {} + + def add_provider(self, provider: ConfigProvider) -> None: + self._providers.append(provider) + + def subscribe(self, callback: Callable[[dict[str, Any]], None]) -> None: + self._subscribers.append(callback) + + def load(self) -> dict[str, Any]: + merged: dict[str, Any] = {} + for provider in self._providers: + merged = self._deep_merge(merged, provider.load()) + self._config = merged + return dict(self._config) + + def reload(self) -> dict[str, Any]: + config = self.load() + for callback in self._subscribers: + callback(dict(config)) + return config + + def get(self) -> dict[str, Any]: + return dict(self._config) + + def section(self, name: str, default: Any = None) -> Any: + return self._config.get(name, default) + + def _deep_merge(self, left: dict[str, Any], right: dict[str, Any]) -> dict[str, Any]: + merged = dict(left) + for key, value in right.items(): + current = merged.get(key) + if isinstance(current, dict) and isinstance(value, dict): + merged[key] = self._deep_merge(current, value) + continue + merged[key] = value + return merged diff --git a/src/app_runtime/core/registration.py b/src/app_runtime/core/registration.py new file mode 100644 index 0000000..9be2a1e --- /dev/null +++ b/src/app_runtime/core/registration.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from app_runtime.contracts.health import HealthContributor +from app_runtime.contracts.queue import TaskQueue +from app_runtime.contracts.tasks import TaskHandler +from app_runtime.contracts.worker import Worker +from app_runtime.core.service_container import ServiceContainer + + +class ModuleRegistry: + def __init__(self, services: ServiceContainer) -> None: + self.services = services + self.queues: dict[str, TaskQueue] = {} + self.handlers: dict[str, TaskHandler] = {} + self.workers: list[Worker] = [] + self.health_contributors: list[HealthContributor] = [] + self.modules: list[str] = [] + + def register_module(self, name: str) -> None: + self.modules.append(name) + + def add_queue(self, name: str, queue: TaskQueue) -> None: + self.queues[name] = queue + + def add_handler(self, name: str, handler: TaskHandler) -> None: + self.handlers[name] = handler + + def add_worker(self, worker: Worker) -> None: + self.workers.append(worker) + + def add_health_contributor(self, contributor: HealthContributor) -> None: + self.health_contributors.append(contributor) diff --git a/src/app_runtime/core/runtime.py b/src/app_runtime/core/runtime.py new file mode 100644 index 0000000..aaad5e0 --- /dev/null +++ b/src/app_runtime/core/runtime.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from app_runtime.config.providers import FileConfigProvider +from app_runtime.contracts.application import ApplicationModule +from app_runtime.control.service import ControlPlaneService +from app_runtime.core.configuration import ConfigurationManager +from app_runtime.core.registration import ModuleRegistry +from app_runtime.core.service_container import ServiceContainer +from app_runtime.core.types import HealthPayload, LifecycleState +from app_runtime.health.registry import HealthRegistry +from app_runtime.logging.manager import LogManager +from app_runtime.tracing.service import TraceService +from app_runtime.workers.supervisor import WorkerSupervisor + + +class RuntimeManager: + def __init__( + self, + configuration: ConfigurationManager | None = None, + services: ServiceContainer | None = None, + traces: TraceService | None = None, + health: HealthRegistry | None = None, + logs: LogManager | None = None, + workers: WorkerSupervisor | None = None, + control_plane: ControlPlaneService | None = None, + ) -> None: + self.configuration = configuration or ConfigurationManager() + self.services = services or ServiceContainer() + self.traces = traces or TraceService() + self.health = health or HealthRegistry() + self.logs = logs or LogManager() + self.workers = workers or WorkerSupervisor() + self.control_plane = control_plane or ControlPlaneService() + self.registry = ModuleRegistry(self.services) + self._started = False + self._state = LifecycleState.IDLE + self._core_registered = False + self._workers_registered = False + self._register_core_services() + + def register_module(self, module: ApplicationModule) -> None: + self.registry.register_module(module.name) + module.register(self.registry) + + def add_config_file(self, path: str) -> FileConfigProvider: + provider = FileConfigProvider(path) + self.configuration.add_provider(provider) + return provider + + def start(self) -> None: + if self._started: + return + self._state = LifecycleState.STARTING + config = self.configuration.load() + self.logs.apply_config(config) + self._register_health_contributors() + self._register_workers() + self.workers.start() + self.control_plane.start(self) + self._started = True + self._refresh_state() + + def stop(self, timeout: float = 30.0, force: bool = False, stop_control_plane: bool = True) -> None: + if not self._started: + return + self._state = LifecycleState.STOPPING + self.workers.stop(timeout=timeout, force=force) + if stop_control_plane: + self.control_plane.stop() + self._started = False + self._state = LifecycleState.STOPPED + + def status(self) -> dict[str, object]: + self._refresh_state() + return self.control_plane.snapshot(self) + + def current_health(self) -> HealthPayload: + self._refresh_state() + return self.health.payload(self._state, self.workers.healths()) + + async def health_status(self) -> HealthPayload: + return self.current_health() + + async def start_runtime(self) -> str: + if self._started: + return "runtime already running" + self.start() + return "runtime started" + + async def stop_runtime(self) -> str: + if not self._started: + return "runtime already stopped" + self.stop(stop_control_plane=False) + return "runtime stopped" + + async def runtime_status(self) -> str: + self._refresh_state() + return self._state.value + + def _register_core_services(self) -> None: + if self._core_registered: + return + self.services.register("configuration", self.configuration) + self.services.register("traces", self.traces) + self.services.register("health", self.health) + self.services.register("logs", self.logs) + self.services.register("workers", self.workers) + self.services.register("control_plane", self.control_plane) + self._core_registered = True + + def _register_health_contributors(self) -> None: + for contributor in self.registry.health_contributors: + self.health.register(contributor) + + def _register_workers(self) -> None: + if self._workers_registered: + return + for worker in self.registry.workers: + self.workers.register(worker) + self._workers_registered = True + + def _refresh_state(self) -> None: + if not self._started or self._state == LifecycleState.STOPPING: + return + self._state = self.workers.lifecycle_state() diff --git a/src/app_runtime/core/service_container.py b/src/app_runtime/core/service_container.py new file mode 100644 index 0000000..401ced3 --- /dev/null +++ b/src/app_runtime/core/service_container.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import Any + + +class ServiceContainer: + def __init__(self) -> None: + self._services: dict[str, Any] = {} + + def register(self, name: str, service: Any) -> None: + if name in self._services: + raise ValueError(f"Service '{name}' is already registered") + self._services[name] = service + + def get(self, name: str) -> Any: + try: + return self._services[name] + except KeyError as exc: + raise KeyError(f"Service '{name}' is not registered") from exc + + def require(self, name: str, expected_type: type[Any]) -> Any: + service = self.get(name) + if not isinstance(service, expected_type): + raise TypeError(f"Service '{name}' is not of type {expected_type.__name__}") + return service + + def snapshot(self) -> dict[str, str]: + return {name: type(service).__name__ for name, service in self._services.items()} diff --git a/src/app_runtime/core/types.py b/src/app_runtime/core/types.py new file mode 100644 index 0000000..2654e9d --- /dev/null +++ b/src/app_runtime/core/types.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from enum import Enum +from typing import TypedDict + + +class HealthPayload(TypedDict, total=False): + status: str + detail: str + state: str + components: list[dict[str, object]] + + +class LifecycleState(str, Enum): + STARTING = "starting" + IDLE = "idle" + BUSY = "busy" + STOPPING = "stopping" + STOPPED = "stopped" diff --git a/src/app_runtime/health/__init__.py b/src/app_runtime/health/__init__.py new file mode 100644 index 0000000..2f252fa --- /dev/null +++ b/src/app_runtime/health/__init__.py @@ -0,0 +1,3 @@ +from app_runtime.health.registry import HealthRegistry + +__all__ = ["HealthRegistry"] diff --git a/src/app_runtime/health/__pycache__/__init__.cpython-312.pyc b/src/app_runtime/health/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e00a79238b2324494844d4dac88fa14342b2a57c GIT binary patch literal 266 zcmX@j%ge<81hvPOXNCgl#~=<2FhLog#ej_I3@HpLj5!Rsj8Tk?3@J?Mj8ROL%$h7O zL5egPZ}EAgCgzl61f`~D7MB!N7BK^b{WO_xNhcN*#21z3m1O3o>Sch{=oKL=y2TzJ zpO}*qAHR~}Gsvu89{QohsYS*5i8-ki`Yx$u@dZWsS*gh-#qniE`asi)^$T*667`FV zl0iI}+4>N(_2c6+^D;}~_zNAK>)NKN~!<= literal 0 HcmV?d00001 diff --git a/src/app_runtime/health/__pycache__/registry.cpython-312.pyc b/src/app_runtime/health/__pycache__/registry.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..acf3b12d0bd688079cec47c836fe17ec67c56aaf GIT binary patch literal 3957 zcmbssTTC0-_1<~dgAF!W9w9ahgiRl2!KRztgbgg)D3GK}L`fi2HBBwYGXWF7dS?tV zAyQQ3V=JwcjkGFRHPvRn7L=`;kACcDTd7j3e$*xqTcfTTwW`$qC81IqzIx6b+p&$& zO6~nPbMEWh^FC+(*wo}8Fm`|US#+p@kiX%iG$ae5JLdpdBPvm8jKpb>QpAOr5Ep|Y z$HkZwmxD6LrI;;l58C5OP@zO1XNW4_BC73yU{MP;Wb8iWpE&VR(-}@Al1A8wCKEbL z51iG)F=OFWGGVaj<+PDxu-N#$B)h6HYs)8?O%^s4&cu>o)pVVUUeO|%NKBgsLR!Rn z#)8E+>CPC;*N8@fR3$+{ZBXegDJZHARe-OgI#m(Avf8Lh@U^K;z_S#zt1i_BUqyAR zcK9}A6koIHvYx-7%|~^EWg@iVL3~dngzmh;-}h#H~L%E4-m{iO}CH__4jH%Z~x(EHN1;@LrNBSp0#j^bD%od9GF z1X{$^nz4wdYQ`#kP*e>i8N!^f?Gsodd`ug#t|inBfi>aOS*Z_vyQhb_a6_@=(bygl zN*hQ{ZwCM(D2;i=lT$o-<#0|peE;=r#m|~ytI9$$EN~0`0FeE}86&r8M3@IfTmnPh zpsTc=fDeEIjyu8!trDEQL8k#yjxE;+Y}tpkz;V4&!j=lrL)IaErMJdjb%-;tb0Fn=9_zS&Ar>rM>gFPS!Ln{d~^v;-ahD)sw``VMXrjE00C7{ zux+I?!zzxFm@9za5a3KbTMH#yuL__%v8X%{_l5{JQ0X;eRV>{A^|f*Ji7LG(lvOA$ zgS$*ZxZ89H>$Gk7DH+@V)!J>!CcEc zgOn6(9w?J4FG*2Di*r5oz$m((o(QM(g`}~^{Yo1c=K2HxFhghG`b>UcJU1}@Xy8n) z^GsGb^rYRBZ|~2w_h$!2AGN<*cz5ct=lnzC?wfgUf6m*#KC|t8W7Bi~nJhLto)WRa z@#3$Z^Ezs4z2l^8i&SbFgOywBS70Ns7Fyz~tR=2eqa4eN=ma(6>Ng2t%_>y|aB*AB zCQMcFo;VGjQ9(u7d#YlW7nAA}3&Ij%!>Xjp_22jz`T0JtuZrMH;Iljs(U&BU88b%B zSe5qplBC-1Nx(T-#7LvU0guLW><9vkf93^Hyj`)N@Bm>L;--Ci z=DSl<<71}06pp1eQ<(90If1b$u$8-MiZ0qTq^YjzXkwRzh{kJ|F{&e{MK-M!@IMnrt_hWD2)Xd|qi(kBX z*T{Dd=DG(Twr+PH+w8jdtU+#dJtcCJ>#2(z^i~CAn`dqA&fLCnX_qiAC5X%CGU=YB zFP=My_X5?i>ip(-$Ejw=Z;y7IYE!DxDux*k)G0vLh*1tw=s6&i#>xq`9@BL!v_%h}=Oxu4QCO34SW zNhCvmAudxW+ONSbq+-iOl?aO((MUM9A((=H&~lB6nzq|8GOs1HYbiFmaV?;E$T|K$4Jt2wuC(>=Vd{xZH1f8-v{xq>fv z%Kq^;e3Ua;UpGTQv8zjL4(3iW>~(m7cHC ze;+>W18|f4+35m>X3u_q%N5*m1`91+d}TH}c5%x!yXBlMw7gnyw^%JzrQcxP;10h8 z2=)>6I>h?E2CEn@H3L@b)FN1|Q+uFikf*Uz*pN*r5sqt?ar{7%CrML^Lq=uCU|v$y zLBOk2g~@NlTdS%C-cs{E0xhefy>F5yt)8`sI}?TW&b9cRc%h@~6ZgmN(#+a>=Conj zur)Ziu6Pf_G$YLtrZfo+4CL@upv6#f_BMhO0DwEhcVCIYZ^c~YFV7{oz%%i1>B8;- z>~7}Z_oKfjc^Q8XF8OR$%EpXebDE(tfd)_HWg4ak*I^a4!La49SQ){y4*YpQWrjkg zBNU1!)pQK;#!%=->2S>2VOUvN8v<;`7$hCSFnD<#;T2^AnhRXOW}aaK!vR`R$9TF$ zb_6QFy`?8SC(7<<+96`68XhRU<}x~k_KG@I}DSb*;(-$0EWZidyWSL>bB=?J6UdYDU><*uX@uV>x~* dQcAxhy?-Ysz9et`g?#(pwn6H6P5|)B{{>U|Yq literal 0 HcmV?d00001 diff --git a/src/app_runtime/health/registry.py b/src/app_runtime/health/registry.py new file mode 100644 index 0000000..280b0aa --- /dev/null +++ b/src/app_runtime/health/registry.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from app_runtime.contracts.health import HealthContributor +from app_runtime.contracts.worker import WorkerHealth +from app_runtime.core.types import HealthPayload, LifecycleState + + +class HealthRegistry: + def __init__(self) -> None: + self._contributors: list[HealthContributor] = [] + + def register(self, contributor: HealthContributor) -> None: + self._contributors.append(contributor) + + def contributor_healths(self) -> list[WorkerHealth]: + return [contributor.health() for contributor in self._contributors] + + def snapshot(self, worker_healths: list[WorkerHealth]) -> dict[str, object]: + component_healths = worker_healths + self.contributor_healths() + return { + "status": self._aggregate_status(component_healths), + "components": [self._health_dict(item) for item in component_healths], + } + + def payload(self, state: LifecycleState, worker_healths: list[WorkerHealth]) -> HealthPayload: + component_healths = worker_healths + self.contributor_healths() + if state == LifecycleState.STOPPED: + return {"status": "unhealthy", "detail": "state=stopped", "state": state.value} + if state in {LifecycleState.STARTING, LifecycleState.STOPPING}: + return { + "status": "degraded", + "detail": f"state={state.value}", + "state": state.value, + "components": [self._health_dict(item) for item in component_healths], + } + return { + "status": self._aggregate_status(component_healths), + "state": state.value, + "components": [self._health_dict(item) for item in component_healths], + } + + def _aggregate_status(self, component_healths: list[WorkerHealth]) -> str: + if any(item.status == "unhealthy" and item.critical for item in component_healths): + return "unhealthy" + if any(item.status in {"degraded", "unhealthy"} for item in component_healths): + return "degraded" + return "ok" + + def _health_dict(self, health: WorkerHealth) -> dict[str, object]: + return { + "name": health.name, + "status": health.status, + "critical": health.critical, + "detail": health.detail, + "meta": health.meta, + } diff --git a/src/app_runtime/logging/__init__.py b/src/app_runtime/logging/__init__.py new file mode 100644 index 0000000..514319b --- /dev/null +++ b/src/app_runtime/logging/__init__.py @@ -0,0 +1,3 @@ +from app_runtime.logging.manager import LogManager + +__all__ = ["LogManager"] diff --git a/src/app_runtime/logging/__pycache__/__init__.cpython-312.pyc b/src/app_runtime/logging/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5682507dfc6bbc988043a624251dcd7b7e736b9e GIT binary patch literal 262 zcmX@j%ge<81p7BH%k%}(k3k$5V1hC}ivbza8B!Qh7;_kM8KW2(8B&~wisQ?S^ns=o>lfrCCF&O! zC4+b{v-P27>&M4u=4F<|$LkeT{^GF7%}*)KNwq8D1e(kU#KrtT;sY}yBja5LqX*pL M9eR!IMeIN^076Dc_5c6? literal 0 HcmV?d00001 diff --git a/src/app_runtime/logging/__pycache__/manager.cpython-312.pyc b/src/app_runtime/logging/__pycache__/manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2938c5bcc384c45253bbd2780bcaa53d8043461a GIT binary patch literal 2152 zcmZ`)O-v+36t3#-nPHd)R>;ba3$*<1a9|9J2bCp3cU_}67qgP((zesh*pr^_vAPGC z2__sku!)f)@#LP28@w1#p3Hji;)Mz9f-Ro*iDp9FH%5;@dTyqAu%6T)t%5mf#QTZLB3J08b zh^z`VE}QrZH(Bat6w|aE#nCO(&KG5j0Q*P(7Rs2vfm7>N9w8a>`aC z#E{P>+>~Jzi@I5K(?!hzC%^%>LzYdYtjV&Ql2Ht-UfED=M_yM9U6l)#xuzE%Q&+Gx zW6j39q>J}*_ic^YImOU6b9c0Lxx%cHR&Z>2eJ-a|Dt4}7e5&MZR=}Ois-~ltwOnw0 zuIvXtTdCpox~V&|T<-&if~?Gj%nrECz5!&Lob`{K^xtgs-~4{ysDEx(_)r9gqzw>m7aZSHSHLyHTDgL5u32vS?q)%&pu=uji6_9OmIym~x z(APtc?j8-!>~{a&Gkhvu+gpCPd?HE>Q9688J{CVY6?@OQWP18c?Ayyf%%6yp4RP|3 zc`UwuHZ}9z;?dNbyLXPo@#y4mLmWOFx&2gJ{P(P9;058*(~)efA&wo6zke)#0IU;n zq9IN^6Q|A-pmSj(oPJp9Tv{Mc7Uti+#s4x#fnIvTqR#sjbfNP|3Fh{(O}(y(I<|zT z+D0kDe=NA#T8z{h0nA4o2$&!7(ChsnVk@{RMLieu$+97 z{|9_24@w@~JY2As;U+eS1pQ${NW4OOFRBL*_6jiTBQ2=70&Hl){s;(wBhzz|8Ea(5 z4l^?z2>A30y^I3&Wa@TfDy}sJjkaRne&ZFSM?g6`d**C~f6H5~RWRwEUXE`^? zG^ff;SIAqYmKFSf@KIo+NF*THBum5OcFJd}BR=MfYH~Lz*0ym~VDu64QSVB@-*uS4U0wk-;r%Rd5MzVc1}PTZgST>$kUW tb{xggRTmu#zI%&o3Iv#Y8%qHRp!82N_MCM8L9YLk9H1-o1wrba{SP*b&+q^M literal 0 HcmV?d00001 diff --git a/src/app_runtime/logging/manager.py b/src/app_runtime/logging/manager.py new file mode 100644 index 0000000..eb48eda --- /dev/null +++ b/src/app_runtime/logging/manager.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import logging +import logging.config + + +class LogManager: + def __init__(self) -> None: + self._logger = logging.getLogger(__name__) + self._last_valid_config: dict | None = None + + def apply_config(self, config: dict) -> None: + logging_config = config.get("log") + if not logging_config: + self._logger.warning("Config has no 'log' section; default logging remains active.") + return + try: + logging.config.dictConfig(logging_config) + self._last_valid_config = dict(logging_config) + self._logger.info("Logging configuration applied") + except Exception: + self._logger.exception("Failed to apply logging configuration") + self._restore_last_valid() + + def _restore_last_valid(self) -> None: + if self._last_valid_config is None: + return + try: + logging.config.dictConfig(self._last_valid_config) + except Exception: + self._logger.exception("Failed to restore previous logging configuration") diff --git a/src/app_runtime/queue/__init__.py b/src/app_runtime/queue/__init__.py new file mode 100644 index 0000000..95084b1 --- /dev/null +++ b/src/app_runtime/queue/__init__.py @@ -0,0 +1,3 @@ +from app_runtime.queue.in_memory import InMemoryTaskQueue + +__all__ = ["InMemoryTaskQueue"] diff --git a/src/app_runtime/queue/__pycache__/__init__.cpython-312.pyc b/src/app_runtime/queue/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e5630ff0cbd2378bc113df77a9f6c5b7fd6fde8c GIT binary patch literal 268 zcmX@j%ge<81Pc}~%Zvunk3k$5V1hC}ivbza8B!Qh7;_kM8KW2(8B&nbR-C~cAPt3`Qk6+2~8D!iqcm2@f)S_bj#GKR$eV5d-_=2MRtkmR^;`lNneV}Q@ z`UN>jiTcGw$siugY<;lV`tk9Zd6^~g@p=W7zc_4i^HWN5QtgU3f#xy-aj^i9_`uA_ V$at5*@&UKp1upqU_9AwmAOLA5ONRge literal 0 HcmV?d00001 diff --git a/src/app_runtime/queue/__pycache__/in_memory.cpython-312.pyc b/src/app_runtime/queue/__pycache__/in_memory.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..98aa8634d1f1940d106ff0d949c7dbda0db0480d GIT binary patch literal 2543 zcmcIl&2Jk;6rb5I@7kNXC2gFKCNWJ>*-~P)J@imSIDh~NETO`oj?l{WY>JchM`qV; zZCNEkIkXW`o0HQD0a7JI6#fDJ1};RUs#&!X5<;lAf`f#5;=S38apLp_b9nRKn>Qct zH}B(zfq^)I@%6oLik~Wk{EEWPP(y}xvcR+mBaFJFM01oPFS$}l&dDVurwCbg)smLe z1h2TUlAhBkk;vPGsmp|Ej=jDug%+jBrV9_9vFPAHR-Y-_lo+Sn9TcxUB57ZB8 zPR)U3)mS$wC6qvc({^w-V@jP-L=igs7dUuj!}?s zhZsVNo=X5pSOdJnQ74v^A@sG(UuzScH3Hq#uH433o;av~}}VawWNb zdP_gLnnWpaGjS_5m6ckD~1qkR{SK4~hU-N!(uC(uY?QQ8+x*$G7zH2l}K4hiswuZ@Z4)g(F(zy7YfI z-kTH8$)p%uzi0HrrSJn_BVviCkV^vr5HTP42#_GbA;Ri5bKHF0L*LRnX{BK4Y7F&b zi26ye^v(i75K{=7xni!Eru?x~o=WRe-x`rwI&Jnf)?se;h!I`LSn! zR@^!?UoW4{}yr;e8qYmmhnInC740!7sW}$Vi(~+UG$0#hbQ5d zu+WF~N%f#-dW}>+l49W{G(QbwiTpA${?+82$@Yv<{0~=F0Uvg|4 z`l4N`u$qg!X+yKjyWt9d0bhsd$S07zh~zkuSAc8~;huny None: + self._queue: Queue[Task] = Queue() + self._published = 0 + self._acked = 0 + self._nacked = 0 + + def publish(self, task: Task) -> None: + self._published += 1 + self._queue.put(task) + + def consume(self, timeout: float = 0.1) -> Task | None: + try: + return self._queue.get(timeout=timeout) + except Empty: + return None + + def ack(self, task: Task) -> None: + del task + self._acked += 1 + self._queue.task_done() + + def nack(self, task: Task, retry_delay: float | None = None) -> None: + del retry_delay + self._nacked += 1 + self._queue.put(task) + self._queue.task_done() + + def stats(self) -> dict[str, int]: + return { + "published": self._published, + "acked": self._acked, + "nacked": self._nacked, + "queued": self._queue.qsize(), + } diff --git a/src/app_runtime/tracing/__init__.py b/src/app_runtime/tracing/__init__.py new file mode 100644 index 0000000..4048ccc --- /dev/null +++ b/src/app_runtime/tracing/__init__.py @@ -0,0 +1,16 @@ +from app_runtime.contracts.trace import TraceContext, TraceContextRecord, TraceLogMessage, TraceTransport +from app_runtime.tracing.service import TraceManager, TraceService +from app_runtime.tracing.store import ActiveTraceContext, TraceContextStore +from app_runtime.tracing.transport import NoOpTraceTransport + +__all__ = [ + "ActiveTraceContext", + "NoOpTraceTransport", + "TraceContext", + "TraceContextRecord", + "TraceContextStore", + "TraceLogMessage", + "TraceManager", + "TraceService", + "TraceTransport", +] diff --git a/src/app_runtime/tracing/__pycache__/__init__.cpython-312.pyc b/src/app_runtime/tracing/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c81725e3e75baa6dbf39aee8fba2f0b908352634 GIT binary patch literal 649 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 literal 0 HcmV?d00001 diff --git a/src/app_runtime/tracing/__pycache__/service.cpython-312.pyc b/src/app_runtime/tracing/__pycache__/service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..288c8ed943573850629542ccb31af0977d4c5b9e GIT binary patch literal 9017 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 literal 0 HcmV?d00001 diff --git a/src/app_runtime/tracing/__pycache__/store.cpython-312.pyc b/src/app_runtime/tracing/__pycache__/store.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..841df78b79df8b906a9be6170b40497a3527490b GIT binary patch literal 3113 zcmbVO-ER{|5Z}EoUwlrTIDrsIf=R;1QHa%2RfQr@DWyU{kQ(5@RdscJm*CW2-8)Cb za->vBB}ZzNMtz7Md8&%wp;G^Vz9RJ{5tcBgRIOC;K%XeCgv3ibvv=n@A5hwrZgzHO zcK2p}JG1jsXJ?o|`|*cwGiim8-*6B#*<)z)1~4ncBqq(098FS+ypR=gVp8P1n3ZyJ zQqCz!g^#6dD5oY>N(5rbrhG@aO})JY@;)&`3&d1!3*O9auOrD0F6#hUIFKd7vzi`x zgc>?^wNyS|a8gdDkhftNez%Z!tQ*d?6w?K_!%R7;bT(z%ZirdMY$|PmPV5Q;##j8n zN*9=U1*VlrRw!Hfw0|`3L~lTw2Y^{27D-Z*B!#PBv?ZFtNtiXoq-07kl5sSv=pi@u zPTI-LSnbEK5C~m`@zY%5inhaC$#$&bxGuY@VdPUe%P`!qVdM&CDT{o>Fg`1#vc4p0 z7;UB+273i|yhWNo$+L5btG31LL@I0DNSw81j3O&cTWQBOW?oOEibXq7%zlze*es1B zgO&15CTAraa6~3Qm9U)xv&M?E3}NS_0~os-$QR_-$k07$sL>UhKU*DH7-zkK&IV8U zz|kgp;U7<#s!125q-b`S0`!s@Hbv-VQ!^##6_}Ty51A2DfnJ>r>78zOyXPkG-_rg~ zfxQWl-~(W2^CB=S#A(S)0OyVn+>;71T9P&$(Udmm_&*NN5nUd4JJBbWkuEW2nm9mbjc@i6L)TgWmj@CtnzXIs_+OA@7=`9M-TG>e(&yTYmo|2KtXB@*Q@7`kF znBmL1K+}!`nJ4R~&)-d~rl;zsuUDlnJJ-)#y!+9rIaxn5Rh7QdI0ze@f@W~1ya~(- zsgP;tH@9j3KDW;Yp*BHo(Q#d110Zz8Da&C)z_ptY^35#wZ?(V|;oY#&-g(l9_AJdU z&fSm3*P`)z(cx8f_yvD`4c73#Zb1eUehdC^j#en!S)tP{$N~*QrV)uvZ2}U~1YyH4 z4N#3j$21_W>H(QC!P>nzp`>`c+PH7r$E(Y?$Ox3n!_T$4rPBu+*mw|_-@RjHV0qx1 zp{nvgPgG|-U+iCt?q5}pe}4st=Z$t-&<2j-@4)=4Hw3`AeKoAXHti>9Z{*RI2gQo; z`QGQO!wj{lRX~4|mSHwX)s#4x8hESFJ2aS|lVDf=XarKPpWbd)DOg2GMVcVGFs@S$ zjjnhw4Z>nJ0tE6TTtuFg@LuXN2aM~ogD97Iz`3DP5mLQn+GufKG2xp>w)#wXiw*tq% zMC=Olbe1@17;hDUN0T4VbK-w+u-N8cF^DEC4#36e5C#<-X>+mUxfnHg46!4?*?5`2 z#ldR@VFz!SRow2i3S?ur?kyl2<45bRo&v|N$NFmrZaVeYkrxLCyg67GIVF~NrSU@E zf~@=oZ2GK!YZVo*#M?B=VJM=4DalI;m~QxTo;1J%+|16_EWVC<2aDnazP1c&92Q5y zeRT-AqexyuatsN+^&D`XQy3QJI;@D%@ zS-isZQYHVxjeq@2J?Yhzr`X%D9lo;maUhRHO6kvJ_#qkog&clJPW+(|LHl~NPWzq; a0_}NBfHXxS?0SsDr{Vpy?+F3IP5K-A5PJv! literal 0 HcmV?d00001 diff --git a/src/app_runtime/tracing/__pycache__/transport.cpython-312.pyc b/src/app_runtime/tracing/__pycache__/transport.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..161cf01bdaf1a66a07bc232a36bad2b8769a1a52 GIT binary patch literal 871 zcmah|y>1jS5FYPePRU&Y7oY%yPITMcN|Xqt0SO7gAw|$^3Ts(Ar$hS}+n3}h5XHSi zcmkvoB@e&@P*GxuiUQFgZD>;AvF{K830!$|&YGz;VC8wN4a8(CG= zDpQlXlCiJ*PehjUyLF}bvD)Q%EtuZ2-yYWEM_kHm%=M=IV4#vsEmZD!9eU1?_PTLS zfI~_cq#g?xnFp!QLgpD8%=|dg{Zak6`GaeD{}-hZP1?wEy(BYOkkJAbo(?20_H`tz zLW!3KgR=+8Q^|!)vVtEc_xK@gME#uSO47qyN!BznX^LlAB1Mi771K&hN}ecF@}wH0 zbp;KZneH8liQ+VOop>?$Tk7!Ki8Ujk#p%3)Pu+fN9LWDui@-694TP?NWffw}_^b4# z>-ay?FHcyii%8WsFp2}Qi9vu|rb}unDjkgKim!H1s<@1B#XubD4y9FAa!U0&rDe^g z1^Qh|52jh+cEklde+j{=##C0W=%9yylag4?99oZr+ozEMtI2MU;x(k-pBh1iUf`3| z*M1wu5F2jhq2v$^w-pF07O7pL^U2RTcbwcEfp5 None: + self._logger = logging.getLogger(__name__) + self._transport = transport + + def write_context(self, record: TraceContextRecord) -> None: + try: + self._transport.write_context(record) + except Exception: + self._logger.exception("Trace transport failed to write context %s", record.trace_id) + + def write_message(self, record: TraceLogMessage) -> None: + try: + self._transport.write_message(record) + except Exception: + self._logger.exception("Trace transport failed to write message for %s", record.trace_id) + + +class TraceService(TraceContextFactory): + def __init__(self, transport: TraceTransport | None = None, store: TraceContextStore | None = None) -> None: + self.transport = transport or NoOpTraceTransport() + self.store = store or TraceContextStore() + self._writer = TraceRecordWriter(self.transport) + + def create_context( + self, + *, + alias: str, + parent_id: str | None = None, + kind: str | None = None, + attrs: dict[str, Any] | None = None, + ) -> str: + record = TraceContextRecord( + trace_id=uuid4().hex, + alias=str(alias or ""), + parent_id=parent_id, + type=kind, + event_time=utc_now(), + attrs=dict(attrs or {}), + ) + self.store.push(record) + self._writer.write_context(record) + return record.trace_id + + @contextmanager + def open_context( + self, + *, + alias: str, + parent_id: str | None = None, + kind: str | None = None, + attrs: dict[str, Any] | None = None, + ) -> Iterator[str]: + trace_id = self.create_context(alias=alias, parent_id=parent_id, kind=kind, attrs=attrs) + try: + yield trace_id + finally: + self.store.pop() + + def current_trace_id(self) -> str | None: + return self.store.current_trace_id() + + def close_context(self) -> str | None: + previous = self.store.pop() + return previous.record.trace_id if previous else None + + def step(self, name: str) -> None: + self.store.set_step(str(name or "")) + + def info(self, message: str, *, status: str, attrs: dict[str, Any] | None = None) -> None: + self._write_message("INFO", message, status, attrs) + + def warning(self, message: str, *, status: str, attrs: dict[str, Any] | None = None) -> None: + self._write_message("WARNING", message, status, attrs) + + def error(self, message: str, *, status: str, attrs: dict[str, Any] | None = None) -> None: + self._write_message("ERROR", message, status, attrs) + + def exception(self, message: str, *, status: str = "failed", attrs: dict[str, Any] | None = None) -> None: + self._write_message("ERROR", message, status, attrs) + + def new_root(self, operation: str) -> TraceContext: + trace_id = self.create_context(alias=operation, kind="source", attrs={"operation": operation}) + return TraceContext(trace_id=trace_id, span_id=trace_id, attributes={"operation": operation}) + + def child_of(self, parent: TraceContext, operation: str) -> TraceContext: + trace_id = self.create_context( + alias=operation, + parent_id=parent.trace_id, + kind="worker", + attrs={"operation": operation}, + ) + return TraceContext( + trace_id=trace_id, + span_id=trace_id, + parent_span_id=parent.span_id, + attributes={"operation": operation}, + ) + + def attach(self, task_metadata: dict[str, object], context: TraceContext) -> dict[str, object]: + updated = dict(task_metadata) + updated["trace_id"] = context.trace_id + updated["span_id"] = context.span_id + updated["parent_span_id"] = context.parent_span_id + return updated + + def resume(self, task_metadata: dict[str, object], operation: str) -> TraceContext: + trace_id = str(task_metadata.get("trace_id") or uuid4().hex) + span_id = str(task_metadata.get("span_id") or trace_id) + parent_id = task_metadata.get("parent_span_id") + self.create_context( + alias=operation, + parent_id=str(parent_id) if parent_id else None, + kind="handler", + attrs=dict(task_metadata), + ) + return TraceContext( + trace_id=trace_id, + span_id=span_id, + parent_span_id=str(parent_id) if parent_id else None, + attributes={"operation": operation}, + ) + + def _write_message( + self, + level: str, + message: str, + status: str, + attrs: dict[str, Any] | None, + ) -> None: + active = self.store.current() + if active is None: + raise RuntimeError("Trace context is not bound. Call create_context() first.") + record = TraceLogMessage( + trace_id=active.record.trace_id, + step=active.step, + status=str(status or ""), + message=str(message or ""), + level=level, + event_time=utc_now(), + attrs=dict(attrs or {}), + ) + self._writer.write_message(record) + + +class TraceManager(TraceService): + """Compatibility alias during the transition from ConfigManager-shaped naming.""" diff --git a/src/app_runtime/tracing/store.py b/src/app_runtime/tracing/store.py new file mode 100644 index 0000000..ad62f0f --- /dev/null +++ b/src/app_runtime/tracing/store.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from contextvars import ContextVar +from dataclasses import dataclass, replace + +from app_runtime.contracts.trace import TraceContextRecord + + +@dataclass(frozen=True) +class ActiveTraceContext: + record: TraceContextRecord + step: str = "" + + +class TraceContextStore: + def __init__(self) -> None: + self._current: ContextVar[ActiveTraceContext | None] = ContextVar("trace_current", default=None) + self._stack: ContextVar[tuple[ActiveTraceContext, ...]] = ContextVar("trace_stack", default=()) + + def current(self) -> ActiveTraceContext | None: + return self._current.get() + + def current_trace_id(self) -> str | None: + active = self.current() + return active.record.trace_id if active else None + + def push(self, record: TraceContextRecord) -> ActiveTraceContext: + active = self.current() + stack = self._stack.get() + if active is not None: + self._stack.set(stack + (active,)) + updated = ActiveTraceContext(record=record) + self._current.set(updated) + return updated + + def pop(self) -> ActiveTraceContext | None: + stack = self._stack.get() + if not stack: + self._current.set(None) + return None + previous = stack[-1] + self._stack.set(stack[:-1]) + self._current.set(previous) + return previous + + def set_step(self, step: str) -> ActiveTraceContext | None: + active = self.current() + if active is None: + return None + updated = replace(active, step=step) + self._current.set(updated) + return updated diff --git a/src/app_runtime/tracing/transport.py b/src/app_runtime/tracing/transport.py new file mode 100644 index 0000000..07416e7 --- /dev/null +++ b/src/app_runtime/tracing/transport.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from app_runtime.contracts.trace import TraceContextRecord, TraceLogMessage, TraceTransport + + +class NoOpTraceTransport(TraceTransport): + def write_context(self, record: TraceContextRecord) -> None: + del record + + def write_message(self, record: TraceLogMessage) -> None: + del record diff --git a/src/app_runtime/workers/__init__.py b/src/app_runtime/workers/__init__.py new file mode 100644 index 0000000..69099d4 --- /dev/null +++ b/src/app_runtime/workers/__init__.py @@ -0,0 +1,4 @@ +from app_runtime.workers.queue_worker import QueueWorker +from app_runtime.workers.supervisor import WorkerSupervisor + +__all__ = ["QueueWorker", "WorkerSupervisor"] diff --git a/src/app_runtime/workers/__pycache__/__init__.cpython-312.pyc b/src/app_runtime/workers/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3b1f2ec3dd661e4551fea9c60a3b3f83d7e63576 GIT binary patch literal 351 zcmX@j%ge<81fs{5XD$KKk3k$5V1hC}s{k3(8B!Qh7;_kM8KW2(L2RZRrd;MIW+0n6 zg(aOSilvfOlkFu)izeeO?!eO2($w($qU_WnAXfmw3N9^3Eh@__&Mzur2C6G!0TOr3ku?kO7luGb5r%o!J3No3PC2sLwI22x8$%XD@K?JGVT_8e0*X~PJH}IhR;AQ z!!J+$(BjmhV*SLN)Czr<)Ux=3qWrAX=9?TW`P*>>3 z$7kkcmc+;F6;%G>u*uC&Da}c>E8+&4%m~EAVnE^pGb1D8T?UK04Au|0B`!$mT;S4e KWG~_bN&x`A=w~PZ literal 0 HcmV?d00001 diff --git a/src/app_runtime/workers/__pycache__/queue_worker.cpython-312.pyc b/src/app_runtime/workers/__pycache__/queue_worker.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..38fd42300ac38707a9c8fcf058f9cc0b088a7a63 GIT binary patch literal 7748 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 SD;wpw5$>(7{~-)u=>89vGy|;w literal 0 HcmV?d00001 diff --git a/src/app_runtime/workers/__pycache__/runner.cpython-312.pyc b/src/app_runtime/workers/__pycache__/runner.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d7087b459cf70cd156a6d4e2573f829364df4f5b GIT binary patch literal 2814 zcmb6bOH3PA@U3^(>pzPP!NyP=ptRdmxJ^=3X+AxKe56E1ZK$gFvf6l;VB-&YyC#5K zi9-&gB1))K5&BW6D)l3J=rNqCs=f6Rt5mF3MT%5KRc>yfQk7F@c5MRz4t`)u5u_ZI@`07G2l0#SuqMD=L=3hUgH4o-U|-%}(c#_}u0 zV#!d9Y^kUN$)B6liiYH}S}$r!-WWSuDjGCwNrK)Q^w-VsF~f2XXKHPnN| zMWv|bH40>4gen>Bibf~187<>n>_C^ZF`K6VStJ@sGb%~Dyk!4Bl~ewvd!R^a^-9oOi)U-MRn#doB&UhQrYI^s4`y;jrJz}E`3CyR^2lS3n=C;# z&`Y}Imvy5wA#3P+%O_9J5^!}*wY>7ElFgSX$arK5&&(FDOKywPwfrazf_jU0lnM=< z?z&3fwniO|OxWw$h2(p>M)jnU*QSy~+9ckU(=vuGPaaJw6BBxJB7aRu>NJBCUTI_t zTJomtXFZ7~XmoI58qATi#jGLAGwnNrFxX%R;74zGGMFwtT=;m|1521bl55c0;K>WX69E_H9pX zC1jv^mJ1riKw567Rv;`-K{FIpF%-K{X^Va4l&R%CH)t>;EC1$kCF1GNY^>A-B+`Ar@ zs$pp@e8h}&ES_9AxgHs)Mh3poA4LX!jU1aBGFu{Z6;q6w0rBqT+n3h^J=H+ZGWT`x zi{SSi58Hl-t;QbuA0>y@0_SQjJ{U8_w#DE=a9!-Liv7zYYvL<49|^YJ9sY3m{*7uN zac3AcUAleg?(ps5`|#P}T8uRayy^TM0TZ* z!yE)L9VhcF`Mo7>wzewbTPG%T2`uUFFlU;JUG2{+U!33Kwgr~uEc}XQtB^( zYY%M+9?@4L9s1Ri5$z3}J?I3g#9N>mN=){BK?6QMv#rK!9 z=fCaRgCy)GV!M2|1YQJI48jir{FUZ}!uHIYcqsIIMiT_D1ZjjeE$gvG_e9QeR<9ih8|A)YCYxxgh<6zeS literal 0 HcmV?d00001 diff --git a/src/app_runtime/workers/__pycache__/supervisor.cpython-312.pyc b/src/app_runtime/workers/__pycache__/supervisor.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3b0efe3c969bfd69ed7b415be5eefa9a7c97ba05 GIT binary patch literal 3842 zcmc&%O>7&-6`on{lFL6)A|uL{9fy`})$1x$>^3UWN^%-VN&^aNP&qbAS72%GTB5xC z@$6EHSPBRNqmWSrmTELqiWK$D72KjNdT5Itdh4YZQHgDJp`hp=+TI+~Kx?1+-YiLz zl#=$=IlO%{Z|2Rszi(!L@ArEMwC>;CPH(sf`3rXHO|%(W{U$JFq7s$rBtzqrA}{Dd z#u0aL-l2;bDK2H4aVPJKx+^2c<%~P-&UoS;J|^kjj4$q^L?Evc)p?Dmt~-LgL)^b4 zEAD?{N1>KCna$?Rq?yiT4d^9ieR}jIS}Ns>69fIx~An7hvmGGV^=h0`Rwmk zw4`p%)qB&RrGPp*CexR-)KW^cz#1_wtDUL~zHYTkmEr4Iaw|cr(>~X9A+NE8w2@;e z+O#>^5Bt+^-IY?gQc*SV1=xi&S--*WrIfJNYVTfY?4T4(`F)3JlKBX zXkFxmZ4Eb&x5!Ta$rbS(e-#g#sznibw8pmRi@=n@o_QQ?Yx6aqHxHNSotkGTSt6_h zWQxE7@PQ1-=e4Z5DX>o7tyzHEMk1^W$7H6>rVUeL`y9V6LY2ldK;ZbY?}q=n|Gs>< zA|Kv3e^)-vLLg{~O~aB2kbIIQV`K(Gw@gcPKb!8rN^F#@J|nhUB^hRtnJf%)qsbK3 z3j@XQo*?kqXsdAq2$&G=Eoas<4+(W2TAA4K1#e7VpL}QPV+ZtYW15{T!5Zk(>LXx2 z#TanUvZLhK&zO?%gKI(ZiyD5zbv|m36NqpC?ZS-%$~MZUo}SR zTs;v!LA!Y68-y^g>8&eL9rYD&Q`6rV73-tm{&7XZQj#u10cv}~{5kXtCTt8sh2EKg zY~VqyD{K0yT&4ztLi7d8iJgMc9OWnO z;aM%Ky_sjD#gqHQI+u?Q>$y}?H%6n4nTO#N#z7$eApd@gYzJdM3$G28hu4R1y>vG; zPzlB!8wlBNA3Q{hr){N1L`|y^K+OmqF0z<5=hE4TIj2RKcD0~E#i$WPg?i)@?S$;#4IRGurCU9f z(8(2frz5x<2!cO;_4(h9{Cea!XFm+aJ`BWmI>Wnx1K;`9#@u$Ge>dE>b?p2H;R{=x z7j}JJYsXh(54$J@bCXG_VUU${r1jz0yA8Xo!| z5Egs|2#daI_EYOEGRNNrdjQvR*#e_@U{5aABv9|98UX_77(MgT%x30ZVEER=nzQU* z_kS<29T?t{habbIir&7_qlo{1dwH<;7|U~FzhHBO(u~i-`PpeCXMnU=fu`}OO+W?k zE2L2gAgQ%Fgd=zwoo1sx=5cjfnm2OCNgb*6rw8J8tLpT$5&<|CtfNTOAPOMMYgLL4{wRDjZKcT=a7}B&wcgv*I%1@6}c~e z?HqFAD{oAH-Ewis_?RNvHP1l+3*WwmG=tY<1BI=CUVj=V_?=aZ?5Tpb8BtJXybA=X zA=FbISs%H1c18X}pl3I9^nR$n66)Xb_3u3Y;=Qh+TSII8<$;?6TU|pdP9Qtp?u~H8 z`y8KnsuDW2>j|k<01L(>Y)4!oQM>hRFsZxl8!&o&lIerNy8QPtl9E8|@#GeMQr}=ISDbAy&Nj z5YHwvS|VY25{XPsE$GPm5{avYq;5+X03u-rkl None: + self._name = name + self._queue = queue + self._handler = handler + self._traces = traces + self._concurrency = concurrency + self._critical = critical + self._threads: list[Thread] = [] + self._stop_requested = Event() + self._force_stop = Event() + self._lock = Lock() + self._started = False + self._in_flight = 0 + self._processed = 0 + self._failures = 0 + + @property + def name(self) -> str: + return self._name + + @property + def critical(self) -> bool: + return self._critical + + def start(self) -> None: + if any(thread.is_alive() for thread in self._threads): + return + self._threads.clear() + self._stop_requested.clear() + self._force_stop.clear() + self._started = True + for index in range(self._concurrency): + thread = Thread(target=self._run_loop, name=f"{self._name}-{index + 1}", daemon=True) + self._threads.append(thread) + thread.start() + + def stop(self, force: bool = False) -> None: + self._stop_requested.set() + if force: + self._force_stop.set() + + def health(self) -> WorkerHealth: + status = self.status() + if self._started and not self._stop_requested.is_set() and self._alive_threads() == 0: + return WorkerHealth(self.name, "unhealthy", self.critical, "worker threads are not running", status.meta) + if self._failures > 0: + return WorkerHealth(self.name, "degraded", self.critical, "worker has processing failures", status.meta) + return WorkerHealth(self.name, "ok", self.critical, meta=status.meta) + + def status(self) -> WorkerStatus: + alive_threads = self._alive_threads() + with self._lock: + in_flight = self._in_flight + processed = self._processed + failures = self._failures + if self._started and alive_threads == 0: + state = "stopped" + elif self._stop_requested.is_set(): + state = "stopping" if alive_threads > 0 else "stopped" + elif not self._started: + state = "stopped" + elif in_flight > 0: + state = "busy" + else: + state = "idle" + return WorkerStatus( + name=self.name, + state=state, + in_flight=in_flight, + meta={ + "alive_threads": alive_threads, + "concurrency": self._concurrency, + "processed": processed, + "failures": failures, + }, + ) + + def _run_loop(self) -> None: + while True: + if self._force_stop.is_set() or self._stop_requested.is_set(): + return + task = self._queue.consume(timeout=0.1) + if task is None: + continue + with self._lock: + self._in_flight += 1 + self._traces.resume(task.metadata, f"worker:{self.name}") + try: + self._handler.handle(task) + except Exception: + with self._lock: + self._failures += 1 + self._queue.nack(task) + else: + with self._lock: + self._processed += 1 + self._queue.ack(task) + finally: + with self._lock: + self._in_flight -= 1 + if self._stop_requested.is_set(): + return + + def _alive_threads(self) -> int: + return sum(1 for thread in self._threads if thread.is_alive()) diff --git a/src/app_runtime/workers/supervisor.py b/src/app_runtime/workers/supervisor.py new file mode 100644 index 0000000..a73277b --- /dev/null +++ b/src/app_runtime/workers/supervisor.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from dataclasses import asdict +from time import monotonic, sleep + +from app_runtime.contracts.worker import Worker, WorkerHealth, WorkerStatus +from app_runtime.core.types import LifecycleState + + +class WorkerSupervisor: + def __init__(self) -> None: + self._workers: list[Worker] = [] + + def register(self, worker: Worker) -> None: + self._workers.append(worker) + + def start(self) -> None: + for worker in self._workers: + worker.start() + + def stop(self, timeout: float = 30.0, force: bool = False) -> None: + for worker in self._workers: + worker.stop(force=force) + if force: + return + deadline = monotonic() + timeout + while True: + if all(status.state == "stopped" for status in self.statuses()): + return + if monotonic() >= deadline: + raise TimeoutError("Workers did not stop within the requested timeout") + sleep(0.05) + + def snapshot(self) -> dict[str, object]: + return { + "registered": len(self._workers), + "workers": [asdict(status) for status in self.statuses()], + } + + def healths(self) -> list[WorkerHealth]: + return [worker.health() for worker in self._workers] + + def statuses(self) -> list[WorkerStatus]: + return [worker.status() for worker in self._workers] + + def lifecycle_state(self) -> LifecycleState: + statuses = self.statuses() + if not statuses: + return LifecycleState.IDLE + states = {status.state for status in statuses} + if "stopping" in states: + return LifecycleState.STOPPING + if "starting" in states: + return LifecycleState.STARTING + if "busy" in states: + return LifecycleState.BUSY + if states == {"stopped"}: + return LifecycleState.STOPPED + return LifecycleState.IDLE diff --git a/src/plba/__init__.py b/src/plba/__init__.py new file mode 100644 index 0000000..46f44cb --- /dev/null +++ b/src/plba/__init__.py @@ -0,0 +1,57 @@ +from plba.bootstrap import create_runtime +from plba.config import ConfigFileLoader, FileConfigProvider +from plba.control import ControlActionSet, ControlChannel, ControlPlaneService, HttpControlChannel +from plba.contracts import ( + ApplicationModule, + ConfigProvider, + HealthContributor, + Task, + TaskHandler, + TaskQueue, + TraceContext, + TraceContextRecord, + TraceLogMessage, + TraceTransport, + Worker, + WorkerHealth, + WorkerStatus, +) +from plba.core import ConfigurationManager, RuntimeManager, ServiceContainer +from plba.health import HealthRegistry +from plba.logging import LogManager +from plba.queue import InMemoryTaskQueue +from plba.tracing import NoOpTraceTransport, TraceService +from plba.workers import QueueWorker, WorkerSupervisor + +__all__ = [ + "ApplicationModule", + "ConfigFileLoader", + "ConfigProvider", + "ConfigurationManager", + "ControlActionSet", + "ControlChannel", + "ControlPlaneService", + "create_runtime", + "FileConfigProvider", + "HealthContributor", + "HealthRegistry", + "HttpControlChannel", + "InMemoryTaskQueue", + "LogManager", + "NoOpTraceTransport", + "QueueWorker", + "RuntimeManager", + "ServiceContainer", + "Task", + "TaskHandler", + "TaskQueue", + "TraceContext", + "TraceContextRecord", + "TraceLogMessage", + "TraceService", + "TraceTransport", + "Worker", + "WorkerHealth", + "WorkerStatus", + "WorkerSupervisor", +] diff --git a/src/plba/__pycache__/__init__.cpython-312.pyc b/src/plba/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5ff3d38a5a1bbcaa11bddf3949e447357786d3f6 GIT binary patch literal 1380 zcmaKrOK%fN5XXCL$8S5f^CAg(Kp-%O#S#Z35LyWlt%4NuNU}oe%cwnWoPn7BR>LpQ! z25mr-HlamZ(57verL)kX9q7_7^k@&}=p4+`d03zeut*nSi7vUlGvXX9(`8tpE3ise zU9Bw6!x~+4aYd}d2HkLRRcyiqdcnmtaS^uY7F?p2TwWKKVViEl6?(-ZV=`*I^yt-- zrr-J(NAx|@4i#sb2Wk@Q2)JJ`ox3uA5{-X~1mBZv#Fgn`oK+txc^uiAUvlfTlHyLN zBN-oXZQ6DXR_-3LIOf94<@rOwVt&BYaTIdX+toVxdZFJk-8)GlqL5*$fgDW)cipUx z>4~Ph%bCzeZfi80XsJx;kfqN|1G&rMk+528m^_;B2{+9{#X{7r`3r4&UuOF}lxk#V zU2RW}2Ru#L*q-SM7IB(LrA_&1cbwBI4&&N@!)rqatQBAhPX5D-7fP+*gpQo~aQ18BckbEOFn>9bd zu4y<6a`m0;eK1MzKS-tW*A-rVRYEinONcU}f~X>j2>WRe)kLfyYKZfQRm2)%191+q zjA$X+h*?Aj(M9wS>xenTJYoT{h}g8a@B5~mh#|W+lu}zqSmIQehAW27=1eu}GR9uk|}JO)WoC@yxT&bLSe5+$D8p(3_ldwUf~0o;=PO3i8t{6Bzvc1`;=^*lB;Lr<{7#1wzSbG{8~{z*hZfKw9AJL literal 0 HcmV?d00001 diff --git a/src/plba/__pycache__/bootstrap.cpython-312.pyc b/src/plba/__pycache__/bootstrap.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..154658fb4239195f85776f755a6147e0acc02ddf GIT binary patch literal 1250 zcmZ`&y>HV%6hFtG`KBp_l%{E*LRBP`@G&reK&Ys&v;&AG60+R*+Bi72b-q-zGE$NF z1K40-U_wG=WaCd@p#rJqL?^_+X4Fhfyt89kD)F4${oaS~z4v?PkHumRfjs;0#(E?o z^qniIC|rwRCUjP-}pf z4aZo=1S}J`JkPee@q5dzIkfT0(pL7;<*TiW@LaN<&mr_v<|$;B@4C*KwXS;x?XgMh z7^^ncdyq#52T#NbW1#n3pB`$SODHR+K3;%3q+Nkm4#H1Y;SXdA2k!yAKs5EC9niTv zQ3B!VM4bL~L&NyB`L53_qlyp1>p+3)XOlUSW1Y#fs@;hN?eEyLWm3}^rI@|+v zG~zyLW>%Ualj6RmxLxs+2<`c68U|t{lLe>ZyPp#+`f%BbdR`w z+@-$0ak*`Hp5ONDRio{bZUR_#Ud!8Lr7pn+#rpVQQiX$gdjBCT+h|ll<=M}r z`u3ert$b4Jp;q40=61EY*ZD8ng|F(F*f6`R&F*QfU9I)4P!Ffh@?Ujfq)NroNK~@9 zsD$Lgo;0&7&AeRtBF%^S%J#|uBtXp)nSzb*?<;P5D+i@Dg2syL;Ftm?LA`cN1QWp* zv%y3Q?j@&$)L>x>!E{KRcpl9x%jx>s5DZw?2_HLa01au2t?_hP$pt?;lIp)<1h-OJ z;z6JN*9{dIz;X+$>4%o|!8v@3$TTnSG%UZh>&akmka<@i(IrNI5y002CMV2L(7j*8l(j literal 0 HcmV?d00001 diff --git a/src/plba/__pycache__/config.cpython-312.pyc b/src/plba/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1ecc1a8bb9db0e61505abdd792d9295e8ca17c24 GIT binary patch literal 337 zcmX@j%ge<81TDLlWv&C#k3k$5V1hC}s{k3(8B!Qh7;_kM8KW2(L2RZRrd;MIW+0n6 zg(aOSilvfOlkFu)izeeO0q6X@w9It3%$!u8{KS;hBA}QMhzAi5D9SI(1d0|h1CPO4oIH_#wPATAaI5+9fu85!>~SOd`mZrKa+#uvCu8rh3Dfl>fZhhSg; literal 0 HcmV?d00001 diff --git a/src/plba/__pycache__/contracts.cpython-312.pyc b/src/plba/__pycache__/contracts.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cd692e06ca2011fc8531021c1f1975a0db8bee84 GIT binary patch literal 869 zcmZ{iJ&)5s5QcYcC(eiMkQ@}~XeiyB1r6duAjB0XafkRggtU#dXD>N(yRq4|L$2gk z(9{eWO~Ie*1s zX2b`Sqa1T2Fu@qK!yVxg*J_u0qD5L(d%P`t;#=L~9nmFS(IY(@w|QR#BoHA9MMNSo zAOoBC`6V$VLop&Fu|>8p8l$}P5tGZO-MIG?#)z>VKCCKUWV9~I$w8SnoS9_wsGPhm z#!qEAEpjHIY>!c1ADetFi=(D4A=y2o>f~9&8Wua+gFyFzdvua>)8^^mP|}P+7n{|3 z^l$W>Wu?sZz~=VL@c~ncj+u^Z!eFARQr5An+pkJ_!lVwC&*dbm=tWKIM#Z5tJ|pP? zS^yu=0ki>K0DKhL2Lyl+5CH}TPvR^3I<2ZyHj}y#Y$tG|NhK9OqKT5(Qe600ot}5@3Hy`Xm)qtnY;Lt< u@XN&*&(VW9x;004=IF+Ebo|`;e;6K<~2!4Z~ z;2#VG3oAP@T`KQBf`z%o;oNiYoO5B`JDoMadVG6{`UK#!Y?fs-WZp;e1R;b(KumlR z8@@rx-VkPN`BueEVaJZ|5V(cVdM18rYP-%?Y2g}rbC4!Vrs6D6QJM_7(%nWGj9HR! z;S#-dsZ{n)-ENd?!4f{?vWNoiTGB*cX_erVU?W_)ZN1B~jLJ!(qL}xBI?*`|&#{|Fk&UryIncne0<+M2P z&@J~eF=AdWgPJVw%chI=ah!$|!B1oto$w@5!GKF&GAya&N5U_HLQsDJ=`bj@!$3o_e7@&pMaL_;rhytGVcdC$&d!%I`#$+)t-*=J~KS*dRXvnC!$|GsiEUpdN( iq}KA-1Zaal4R#aZixNVna59Ah#BbLfI}g4YSch{=oKL=y2TzJ zpO}*qAHR~}Gsvu87W$#ZsYS*5i8-ki`Yx$u@dZWsS*gh-#qniE`asi)^$T*667`FV zk|7MJp#_z{IBatBQ%ZAE?TR>o1~CG0u>g?xz|6?Vc$dNK0k`x8E}2I5B6gr40I7>a AF#rGn literal 0 HcmV?d00001 diff --git a/src/plba/__pycache__/logging.cpython-312.pyc b/src/plba/__pycache__/logging.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..09ce28c967275bad95143958d482c0ee35011a5f GIT binary patch literal 246 zcmX@j%ge<81TDLlW%>f?#~=<2FhLog#ej_I3@HpLj5!Rsj8Tk?3@J?Mj8ROL%$h7O zL5egPZ*lqLr~4-6C8nnq6)^*a{4|+wNhcN*#21z3m1O3o>gD98r)TD+>*XRTy2TzJ zpO}*qAHR~}Gsv7@minQ^sYS*5i8-ki`Yx$u@dZWsS*gh-#qniE`asi)^$T*667`FV zk|7M3p#_z{IBatBQ%ZAE?TR>oCNTnWF+Y&_z|6?Vc$dNG0k?REUL$)EJ5US&DilIb literal 0 HcmV?d00001 diff --git a/src/plba/__pycache__/queue.cpython-312.pyc b/src/plba/__pycache__/queue.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..942582e2cbc91231ed59814a999468c310a5bd0e GIT binary patch literal 252 zcmX@j%ge<81TDLlWkv((#~=<2FhLog#ej_I3@HpLj5!Rsj8Tk?3@J?Mj8ROL%$h7O zL5egPZwY$l`KIRP7gdHN7H0>Rrk17_F#~1%G?{NnCl(aM7nSCfWag&o6@sMnGV|hd z!K%TEZn4M5C+6hD$FF4g3^MMQxqfJIYEiL%Voqv>zDsIZd_hruR%&udaeSGPKG3vc z{eqmNME&BTWC#OdXhG#K4x8Nkl+v73yCP1YIgCJDEC3`vFf%eT-es_Sz%6%yOTLl4 Ih#e>h08;iv4*&oF literal 0 HcmV?d00001 diff --git a/src/plba/__pycache__/tracing.cpython-312.pyc b/src/plba/__pycache__/tracing.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..84a4dfd88c2e43b67fe45ecb64e9578c950935ec GIT binary patch literal 331 zcmX@j%ge<81TDLlWiA2Ik3k$5V1hC}s{k3(8B!Qh7;_kM8KW2(L2RZRrd;MIW+0n6 zg(aOSilvfOlkFu)izeeOo{*x%Jym#0O?ZM#j4gCU+TZ9&pPqkh{QT(8ylI36uc<33^^` literal 0 HcmV?d00001 diff --git a/src/plba/__pycache__/workers.cpython-312.pyc b/src/plba/__pycache__/workers.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6183a0f40516ff58cb43547cf5b1eaef7832440d GIT binary patch literal 335 zcmX@j%ge<81TM#xXD$KKk3k$5V1hC}s{k3(8B!Qh7;_kM8KW2(L2RZRrd;MIW+0n6 zg(aOSilvfOlkFu)izeeO?!eO2($w($qU_WnAXfmw3N9^3Eh@__&Mzur2C6G!0TOr3ku?kO7luGb5r%o!J3No3PC2sLwI22x8$%XD@K?JGVT_8e0*X~PJH}IhR;AQ z!!Jwy(BjmhV*SLN)Czr<)Ux=3qWrAXw2F&_`%3mBd wx%nxjIjMF<+(45Ufw))04wld;{X5v literal 0 HcmV?d00001 diff --git a/src/plba/bootstrap.py b/src/plba/bootstrap.py new file mode 100644 index 0000000..80318da --- /dev/null +++ b/src/plba/bootstrap.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from app_runtime.control.http_channel import HttpControlChannel +from app_runtime.core.runtime import RuntimeManager +from app_runtime.contracts.application import ApplicationModule + + +def create_runtime( + module: ApplicationModule, + *, + config_path: str | None = None, + enable_http_control: bool = False, + control_host: str = "127.0.0.1", + control_port: int = 8080, + control_timeout: int = 5, +) -> RuntimeManager: + runtime = RuntimeManager() + if config_path is not None: + runtime.add_config_file(config_path) + if enable_http_control: + runtime.control_plane.register_channel( + HttpControlChannel( + host=control_host, + port=control_port, + timeout=control_timeout, + ) + ) + runtime.register_module(module) + return runtime diff --git a/src/plba/config.py b/src/plba/config.py new file mode 100644 index 0000000..eab5d39 --- /dev/null +++ b/src/plba/config.py @@ -0,0 +1,4 @@ +from app_runtime.config.file_loader import ConfigFileLoader +from app_runtime.config.providers import FileConfigProvider + +__all__ = ["ConfigFileLoader", "FileConfigProvider"] diff --git a/src/plba/contracts.py b/src/plba/contracts.py new file mode 100644 index 0000000..e525433 --- /dev/null +++ b/src/plba/contracts.py @@ -0,0 +1,28 @@ +from app_runtime.contracts.application import ApplicationModule +from app_runtime.contracts.config import ConfigProvider +from app_runtime.contracts.health import HealthContributor +from app_runtime.contracts.queue import TaskQueue +from app_runtime.contracts.tasks import Task, TaskHandler +from app_runtime.contracts.trace import ( + TraceContext, + TraceContextRecord, + TraceLogMessage, + TraceTransport, +) +from app_runtime.contracts.worker import Worker, WorkerHealth, WorkerStatus + +__all__ = [ + "ApplicationModule", + "ConfigProvider", + "HealthContributor", + "Task", + "TaskHandler", + "TaskQueue", + "TraceContext", + "TraceContextRecord", + "TraceLogMessage", + "TraceTransport", + "Worker", + "WorkerHealth", + "WorkerStatus", +] diff --git a/src/plba/control.py b/src/plba/control.py new file mode 100644 index 0000000..8b817ee --- /dev/null +++ b/src/plba/control.py @@ -0,0 +1,10 @@ +from app_runtime.control.base import ControlActionSet, ControlChannel +from app_runtime.control.http_channel import HttpControlChannel +from app_runtime.control.service import ControlPlaneService + +__all__ = [ + "ControlActionSet", + "ControlChannel", + "ControlPlaneService", + "HttpControlChannel", +] diff --git a/src/plba/core.py b/src/plba/core.py new file mode 100644 index 0000000..b544acb --- /dev/null +++ b/src/plba/core.py @@ -0,0 +1,9 @@ +from app_runtime.core.configuration import ConfigurationManager +from app_runtime.core.runtime import RuntimeManager +from app_runtime.core.service_container import ServiceContainer + +__all__ = [ + "ConfigurationManager", + "RuntimeManager", + "ServiceContainer", +] diff --git a/src/plba/health.py b/src/plba/health.py new file mode 100644 index 0000000..2f252fa --- /dev/null +++ b/src/plba/health.py @@ -0,0 +1,3 @@ +from app_runtime.health.registry import HealthRegistry + +__all__ = ["HealthRegistry"] diff --git a/src/plba/logging.py b/src/plba/logging.py new file mode 100644 index 0000000..514319b --- /dev/null +++ b/src/plba/logging.py @@ -0,0 +1,3 @@ +from app_runtime.logging.manager import LogManager + +__all__ = ["LogManager"] diff --git a/src/plba/queue.py b/src/plba/queue.py new file mode 100644 index 0000000..95084b1 --- /dev/null +++ b/src/plba/queue.py @@ -0,0 +1,3 @@ +from app_runtime.queue.in_memory import InMemoryTaskQueue + +__all__ = ["InMemoryTaskQueue"] diff --git a/src/plba/tracing.py b/src/plba/tracing.py new file mode 100644 index 0000000..73ee774 --- /dev/null +++ b/src/plba/tracing.py @@ -0,0 +1,4 @@ +from app_runtime.tracing.service import TraceService +from app_runtime.tracing.transport import NoOpTraceTransport + +__all__ = ["NoOpTraceTransport", "TraceService"] diff --git a/src/plba/workers.py b/src/plba/workers.py new file mode 100644 index 0000000..69099d4 --- /dev/null +++ b/src/plba/workers.py @@ -0,0 +1,4 @@ +from app_runtime.workers.queue_worker import QueueWorker +from app_runtime.workers.supervisor import WorkerSupervisor + +__all__ = ["QueueWorker", "WorkerSupervisor"] diff --git a/tests/__pycache__/test_runtime.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_runtime.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0031da0c9e27dd7d4e92214a69873a6e4cee91cc GIT binary patch literal 25461 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 literal 0 HcmV?d00001 diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..b85bc1f --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from threading import Event, Thread +from time import sleep + +from app_runtime.contracts.application import ApplicationModule +from app_runtime.contracts.health import HealthContributor +from app_runtime.contracts.tasks import Task, TaskHandler +from app_runtime.contracts.worker import WorkerHealth +from app_runtime.core.registration import ModuleRegistry +from app_runtime.core.runtime import RuntimeManager +from app_runtime.queue.in_memory import InMemoryTaskQueue +from app_runtime.tracing.transport import NoOpTraceTransport +from app_runtime.workers.queue_worker import QueueWorker + + +@dataclass +class CollectingHandler(TaskHandler): + processed: list[dict[str, object]] = field(default_factory=list) + + def handle(self, task: Task) -> None: + self.processed.append(task.payload) + + +class BlockingHandler(TaskHandler): + def __init__(self, started: Event, release: Event) -> None: + self._started = started + self._release = release + + def handle(self, task: Task) -> None: + del task + self._started.set() + self._release.wait(timeout=2.0) + + +class StaticHealthContributor(HealthContributor): + def health(self) -> WorkerHealth: + return WorkerHealth(name="example-module", status="ok", critical=False, meta={"kind": "test"}) + + +class ExampleModule(ApplicationModule): + def __init__(self) -> None: + self.handler = CollectingHandler() + self.queue = InMemoryTaskQueue() + + @property + def name(self) -> str: + return "example" + + def register(self, registry: ModuleRegistry) -> None: + traces = registry.services.get("traces") + registry.add_queue("incoming", self.queue) + registry.add_handler("collect", self.handler) + self.queue.publish(Task(name="incoming", payload={"id": 1}, metadata={})) + registry.add_worker(QueueWorker("collector", self.queue, self.handler, traces, concurrency=1)) + registry.add_health_contributor(StaticHealthContributor()) + + +class BlockingModule(ApplicationModule): + def __init__(self, started: Event, release: Event) -> None: + self.queue = InMemoryTaskQueue() + self.handler = BlockingHandler(started, release) + + @property + def name(self) -> str: + return "blocking" + + def register(self, registry: ModuleRegistry) -> None: + traces = registry.services.get("traces") + self.queue.publish(Task(name="incoming", payload={"id": 1}, metadata={})) + registry.add_worker(QueueWorker("blocking-worker", self.queue, self.handler, traces, concurrency=1)) + + +class RecordingTransport(NoOpTraceTransport): + def __init__(self) -> None: + self.contexts: list[object] = [] + self.messages: list[object] = [] + + def write_context(self, record) -> None: # type: ignore[override] + self.contexts.append(record) + + def write_message(self, record) -> None: # type: ignore[override] + self.messages.append(record) + + +def test_runtime_processes_tasks_and_exposes_status(tmp_path) -> None: + config_path = tmp_path / "config.yml" + config_path.write_text( + """ +platform: + workers: 1 +log: + version: 1 + disable_existing_loggers: false + handlers: + console: + class: logging.StreamHandler + root: + level: INFO + handlers: [console] +""".strip(), + encoding="utf-8", + ) + runtime = RuntimeManager() + runtime.add_config_file(config_path) + module = ExampleModule() + runtime.register_module(module) + + runtime.start() + sleep(0.2) + status = runtime.status() + runtime.stop() + + assert module.handler.processed == [{"id": 1}] + assert status["modules"] == ["example"] + assert status["runtime"]["state"] == "idle" + assert status["health"]["status"] == "ok" + assert status["config"] == {"platform": {"workers": 1}, "log": status["config"]["log"]} + + +def test_runtime_graceful_stop_waits_until_worker_finishes() -> None: + started = Event() + release = Event() + runtime = RuntimeManager() + runtime.register_module(BlockingModule(started, release)) + runtime.start() + assert started.wait(timeout=1.0) is True + assert runtime.status()["runtime"]["state"] == "busy" + + stop_thread = Thread(target=runtime.stop, kwargs={"timeout": 1.0}, daemon=True) + stop_thread.start() + sleep(0.1) + assert stop_thread.is_alive() is True + + release.set() + stop_thread.join(timeout=1.0) + assert stop_thread.is_alive() is False + assert runtime.status()["runtime"]["state"] == "stopped" + + +def test_trace_service_writes_contexts_and_messages() -> None: + from app_runtime.tracing.service import TraceService + + transport = RecordingTransport() + manager = TraceService(transport=transport) + + with manager.open_context(alias="worker", kind="task", attrs={"task": "incoming"}): + manager.step("parse") + manager.info("started", status="ok", attrs={"attempt": 1}) + + assert len(transport.contexts) == 1 + assert len(transport.messages) == 1 + assert transport.contexts[0].alias == "worker" + assert transport.messages[0].step == "parse" + + +def test_http_control_channel_exposes_health_and_actions() -> None: + from app_runtime.control.base import ControlActionSet + from app_runtime.control.http_channel import HttpControlChannel + + state = {"started": False} + + async def health(): + return {"status": "ok" if state["started"] else "unhealthy", "state": "idle" if state["started"] else "stopped"} + + async def start_handler() -> str: + state["started"] = True + return "started" + + async def stop_handler() -> str: + state["started"] = False + return "stopped" + + async def status_handler() -> str: + return "idle" if state["started"] else "stopped" + + async def scenario() -> None: + channel = HttpControlChannel("127.0.0.1", 0, 2) + await channel.start( + ControlActionSet( + health=health, + start=start_handler, + stop=stop_handler, + status=status_handler, + ) + ) + + start_response = await channel._action_response("start") + assert start_response.status_code == 200 + assert start_response.body == b'{"status":"ok","detail":"started"}' + + health_payload = await channel._health_response() + assert health_payload["status"] == "ok" + + status_response = await channel._action_response("status") + assert status_response.status_code == 200 + assert status_response.body == b'{"status":"ok","detail":"idle"}' + await channel.stop() + + asyncio.run(scenario()) + + +def test_public_plba_package_exports_runtime_builder(tmp_path) -> None: + from plba import ApplicationModule as PublicApplicationModule + from plba import QueueWorker as PublicQueueWorker + from plba import create_runtime + + config_path = tmp_path / "config.yml" + config_path.write_text("platform: {}\n", encoding="utf-8") + + class PublicExampleModule(PublicApplicationModule): + @property + def name(self) -> str: + return "public-example" + + def register(self, registry: ModuleRegistry) -> None: + queue = InMemoryTaskQueue() + traces = registry.services.get("traces") + handler = CollectingHandler() + queue.publish(Task(name="incoming", payload={"id": 2}, metadata={})) + registry.add_worker(PublicQueueWorker("public-worker", queue, handler, traces)) + + runtime = create_runtime(PublicExampleModule(), config_path=str(config_path)) + runtime.start() + sleep(0.2) + assert runtime.configuration.get() == {"platform": {}} + assert runtime.status()["workers"]["registered"] == 1 + runtime.stop() diff --git a/vision.md b/vision.md new file mode 100644 index 0000000..41b88b1 --- /dev/null +++ b/vision.md @@ -0,0 +1,119 @@ +# Vision + +## Purpose + +This project provides a reusable runtime platform for business applications. + +The runtime exists to solve service and infrastructure concerns that are needed by many applications but do not belong to business logic: +- start and stop +- status and lifecycle +- configuration +- worker execution +- trace propagation +- health checks +- control and administration +- later authentication and user management + +The runtime should allow a business application to focus only on domain behavior. + +## Vision statement + +We build a platform where business applications are assembled from small domain modules, while the runtime consistently provides lifecycle, workers, tracing, configuration, and control-plane capabilities. + +## Problem + +In the previous generation, the execution model was centered around a single `execute()` entry point called by a timer loop. + +That model works for simple periodic jobs, but it becomes too narrow when we need: +- queue-driven processing +- multiple concurrent workers +- independent task sources +- richer health semantics +- trace propagation across producer and consumer boundaries +- reusable runtime patterns across different applications +- future admin and authentication capabilities + +The old model couples orchestration and execution too tightly. + +## Desired future state + +The new runtime should provide: +- a reusable lifecycle and orchestration core +- a clean contract for business applications +- support for sources, queues, workers, and handlers +- explicit separation between platform and domain +- trace and health as first-class platform services +- the ability to evolve into an admin platform + +## Design principles + +### 1. Platform vs domain separation + +The runtime owns platform concerns. +Applications own domain concerns. + +The runtime must not contain business rules such as: +- email parsing policies +- order creation logic +- invoice decisions +- client-specific rules + +### 2. Composition over inheritance + +Applications should be composed by registering modules and services in the runtime, not by inheriting a timer-driven class and overriding one method. + +### 3. Explicit task model + +Applications should model processing as: +- task source +- task queue +- worker +- handler + +This is more scalable than one monolithic execute loop. + +### 4. Parallelism as a first-class concern + +The runtime should supervise worker pools and concurrency safely instead of leaving this to ad hoc application code. + +### 5. Observability by default + +Trace, logs, metrics, and health should be available as platform services from the start. + +### 6. Evolvability + +The runtime should be able to support: +- different queue backends +- different task sources +- different control planes +- different admin capabilities +without forcing business applications to change their domain code. + +## First target use case + +The first business application is `mail_order_bot`. + +Its business domain is: +- fetching incoming mail +- processing attachments +- executing order-related pipelines + +Its platform/runtime needs are: +- lifecycle management +- polling or IMAP IDLE source +- queueing +- worker pools +- tracing +- health checks +- future admin API + +This makes it a good pilot for the new runtime. + +## Success criteria + +We consider the runtime direction successful if: +- `mail_order_bot` business logic can run on top of it without leaking infrastructure details into domain code +- the runtime can manage concurrent workers +- the runtime can support a queue-based flow +- the runtime can expose status and health +- the runtime can later host admin/auth features without redesigning the core