diff --git a/docs/api/components.md b/docs/api/components.md new file mode 100644 index 0000000..15f97c2 --- /dev/null +++ b/docs/api/components.md @@ -0,0 +1,295 @@ +# AsyncFlow — Public API Reference: `components` + +This page documents the **public topology components** you can import from +`asyncflow.components` to construct a simulation scenario in Python. +These classes are Pydantic models with strict validation and are the +**only pieces you need** to define the *structure* of your system: nodes +(client/servers/LB), endpoints (steps), and network edges. + +> The builder (`AsyncFlow`) will assemble these into the internal graph for you. +> You **do not** need to import internal graph classes. + +--- + +## Imports + +```python +from asyncflow.components import ( + Client, + Server, + ServerResources, + LoadBalancer, + Endpoint, + Edge, +) +# Optional enums (strings are also accepted): +from asyncflow.enums import Distribution +``` + +--- + +## Quick example + +```python +from asyncflow.components import ( + Client, Server, ServerResources, LoadBalancer, Endpoint, Edge +) + +# Nodes +client = Client(id="client-1") + +endpoint = Endpoint( + endpoint_name="/predict", + steps=[ + {"kind": "ram", "step_operation": {"necessary_ram": 64}}, + {"kind": "initial_parsing", "step_operation": {"cpu_time": 0.002}}, + {"kind": "io_wait", "step_operation": {"io_waiting_time": 0.010}}, + ], +) + +server = Server( + id="srv-1", + server_resources=ServerResources(cpu_cores=2, ram_mb=2048), + endpoints=[endpoint], +) + +lb = LoadBalancer(id="lb-1", algorithms="round_robin", server_covered={"srv-1"}) + +# Edges (directed) +edges = [ + Edge( + id="gen-to-client", + source="rqs-1", # external sources allowed (e.g., generator id) + target="client-1", # targets must be declared nodes + latency={"mean": 0.003, "distribution": "exponential"}, + ), + Edge( + id="client-to-lb", + source="client-1", + target="lb-1", + latency={"mean": 0.002, "distribution": "exponential"}, + ), + Edge( + id="lb-to-srv1", + source="lb-1", + target="srv-1", + latency={"mean": 0.002, "distribution": "exponential"}, + ), + Edge( + id="srv1-to-client", + source="srv-1", + target="client-1", + latency={"mean": 0.003, "distribution": "exponential"}, + ), +] +``` + +You can then feed these to the `AsyncFlow` builder (not shown here) along with +workload and settings. + +--- + +## Component reference + +### `Client` + +```python +Client(id: str) +``` + +* Represents the client node. +* `type` is fixed internally to `"client"`. +* **Validation:** any non-standard `type` is rejected (guardrail). + +--- + +### `ServerResources` + +```python +ServerResources( + cpu_cores: int = 1, # ≥ 1 NOW MUST BE FIXED TO ONE + ram_mb: int = 1024, # ≥ 256 + db_connection_pool: int | None = None, +) +``` + +* Server capacity knobs used by the runtime (CPU tokens, RAM reservoir, optional DB pool). +* You may pass a **dict** instead of `ServerResources`; Pydantic will coerce it. + +**Bounds & defaults** + +* `cpu_cores ≥ 1` +* `ram_mb ≥ 256` +* `db_connection_pool` optional + +--- + +### `Endpoint` + +```python +Endpoint( + endpoint_name: str, # normalized to lowercase + steps: list[dict], # or Pydantic Step objects (dict is simpler) +) +``` + +Each step is a dict with **exactly one** operation: + +```python +{"kind": , "step_operation": { : }} +``` + +**Valid step kinds and operation keys** + +| Kind (enum string) | Operation dict (exactly 1 key) | Units / constraints | | +| --------------------- | -------------------------------- | ------------------- | ------- | +| `initial_parsing` | `{ "cpu_time": }` | seconds, > 0 | | +| `cpu_bound_operation` | `{ "cpu_time": }` | seconds, > 0 | | +| `ram` | \`{ "necessary\_ram": \ }\` | MB, > 0 | +| `io_task_spawn` | `{ "io_waiting_time": }` | seconds, > 0 | | +| `io_llm` | `{ "io_waiting_time": }` | seconds, > 0 | | +| `io_wait` | `{ "io_waiting_time": }` | seconds, > 0 | | +| `io_db` | `{ "io_waiting_time": }` | seconds, > 0 | | +| `io_cache` | `{ "io_waiting_time": }` | seconds, > 0 | | + +**Validation** + +* `endpoint_name` is lowercased automatically. +* `step_operation` must have **one and only one** entry. +* The operation **must match** the step kind (CPU ↔ `cpu_time`, RAM ↔ `necessary_ram`, IO ↔ `io_waiting_time`). +* All numeric values must be **strictly positive**. + +--- + +### `Server` + +```python +Server( + id: str, + server_resources: ServerResources | dict, + endpoints: list[Endpoint], +) +``` + +* Represents a server node hosting one or more endpoints. +* `type` is fixed internally to `"server"`. +* **Validation:** any non-standard `type` is rejected. + +--- + +### `LoadBalancer` (optional) + +```python +LoadBalancer( + id: str, + algorithms: Literal["round_robin", "least_connection"] = "round_robin", + server_covered: set[str] = set(), +) +``` + +* Declares a logical load balancer and the set of server IDs it can route to. +* **Graph-level rules** (checked when the payload is built): + + * `server_covered` must be a subset of declared server IDs. + * There must be an **edge from the LB to each covered server** (e.g., `lb-1 → srv-1`). + +--- + +### `Edge` + +```python +Edge( + id: str, + source: str, + target: str, + latency: dict | RVConfig, # recommend dict: {"mean": , "distribution": , "variance": } + edge_type: Literal["network_connection"] = "network_connection", + dropout_rate: float = 0.01, # in [0.0, 1.0] +) +``` + +* Directed link between two nodes. +* **Latency** is a random variable; most users pass a dict: + + * `mean: float` (required) + * `distribution: "poisson" | "normal" | "log_normal" | "exponential" | "uniform"` (default: `"poisson"`) + * `variance: float?` (for `normal`/`log_normal`, defaults to `mean` if omitted) + +**Validation** + +* `mean > 0` +* if provided, `variance ≥ 0` +* `dropout_rate ∈ [0.0, 1.0]` +* `source != target` + +**Graph-level rules** (enforced when the full payload is validated) + +* Every **target** must be a **declared node** (`client`, `server`, or `load_balancer`). +* **External IDs** (e.g., `"rqs-1"`) are allowed **only** as **sources**; they cannot appear as targets. +* **Unique edge IDs**. +* **No fan-out except LB**: only the load balancer is allowed to have multiple outgoing edges among declared nodes. + +--- + +## Type coercion & enums + +* You may pass strings for enums (`kind`, `distribution`, etc.); they will be validated against the allowed values. +* For `ServerResources` and `Edge.latency` you can pass dictionaries; Pydantic will coerce them to typed models. +* If you prefer, you can import and use the enums: + + ```python + from asyncflow.enums import Distribution + Edge(..., latency={"mean": 0.003, "distribution": Distribution.EXPONENTIAL}) + ``` + +--- + +## Best practices & pitfalls + +**Do** + +* Keep IDs unique across nodes of the same category and across edges. +* Ensure LB coverage and LB→server edges are in sync. +* Use small, measurable step values first; iterate once you see where queues and delays form. + +**Don’t** + +* Create multiple outgoing edges from non-LB nodes (graph validator will fail). +* Use zero/negative times or RAM (validators will raise). +* Target external IDs (only sources may be external). + +--- + +## Where these components fit + +You will typically combine these **components** with: + +* **workload** (`RqsGenerator`) from `asyncflow.workload` +* **settings** (`SimulationSettings`) from `asyncflow.settings` +* the **builder** (`AsyncFlow`) and **runner** (`SimulationRunner`) from the root package + +Example (wiring, abbreviated): + +```python +from asyncflow import AsyncFlow, SimulationRunner +from asyncflow.workload import RqsGenerator +from asyncflow.settings import SimulationSettings + +flow = ( + AsyncFlow() + .add_generator(RqsGenerator(...)) + .add_client(client) + .add_servers(server) + .add_edges(*edges) + .add_load_balancer(lb) # optional + .add_simulation_settings(SimulationSettings(...)) +) +payload = flow.build_payload() # validates graph-level rules +SimulationRunner(..., simulation_input=payload).run() +``` + +--- + +With these `components`, you can model any topology supported by AsyncFlow— +cleanly, type-checked, and with **clear, early** validation errors when something +is inconsistent. diff --git a/docs/api/enums.md b/docs/api/enums.md new file mode 100644 index 0000000..09aaeb6 --- /dev/null +++ b/docs/api/enums.md @@ -0,0 +1,197 @@ +# AsyncFlow — Public Enums API + +This page documents the **public, user-facing** enums exported from `asyncflow.enums`. These enums exist to remove “magic strings” from scenario code, offer IDE autocomplete, and make input validation more robust. Using them is optional — all Pydantic models still accept the corresponding string values — but recommended for Python users. + +```python +from asyncflow.enums import ( + Distribution, + LbAlgorithmsName, + SampledMetricName, + EventMetricName, + # advanced (optional, if you define steps in Python) + EndpointStepCPU, EndpointStepIO, EndpointStepRAM, StepOperation, +) +``` + +> **Stability:** Values in these enums form part of the **public input contract**. They are semver-stable: new members may be added in minor releases, existing members won’t be renamed or removed except in a major release. + +--- + +## 1) Distribution + +Enumeration of probability distributions accepted by `RVConfig`. + +* `Distribution.POISSON` → `"poisson"` +* `Distribution.NORMAL` → `"normal"` +* `Distribution.LOG_NORMAL` → `"log_normal"` +* `Distribution.EXPONENTIAL` → `"exponential"` +* `Distribution.UNIFORM` → `"uniform"` + +**Used in:** `RVConfig` (e.g., workload users / rpm, edge latency). + +**Notes & validation:** + +* `mean` is required (coerced to float). +* For `NORMAL` and `LOG_NORMAL`, missing `variance` defaults to `mean`. +* For **edge latency** specifically, `mean > 0` and (if present) `variance ≥ 0`. + +**Example** + +```python +from asyncflow.enums import Distribution +from asyncflow.schemas.common.random_variables import RVConfig + +rv = RVConfig(mean=0.003, distribution=Distribution.EXPONENTIAL) +``` + +--- + +## 2) LbAlgorithmsName + +Load-balancing strategies available to the `LoadBalancer` node. + +* `LbAlgorithmsName.ROUND_ROBIN` → `"round_robin"` +* `LbAlgorithmsName.LEAST_CONNECTIONS` → `"least_connection"` + +**Used in:** `LoadBalancer(algorithms=...)`. + +**Example** + +```python +from asyncflow.enums import LbAlgorithmsName +from asyncflow.schemas.topology.nodes import LoadBalancer + +lb = LoadBalancer(id="lb-1", algorithms=LbAlgorithmsName.ROUND_ROBIN, server_covered={"srv-1", "srv-2"}) +``` + +--- + +## 3) SampledMetricName + +Time-series metrics collected at a fixed cadence (`sample_period_s`). + +* `READY_QUEUE_LEN` → `"ready_queue_len"` +* `EVENT_LOOP_IO_SLEEP` → `"event_loop_io_sleep"` +* `RAM_IN_USE` → `"ram_in_use"` +* `EDGE_CONCURRENT_CONNECTION` → `"edge_concurrent_connection"` + +**Used in:** `SimulationSettings(enabled_sample_metrics=...)`. + +**Example** + +```python +from asyncflow.enums import SampledMetricName +from asyncflow.schemas.settings.simulation import SimulationSettings + +settings = SimulationSettings( + total_simulation_time=300, + sample_period_s=0.01, + enabled_sample_metrics={ + SampledMetricName.READY_QUEUE_LEN, + SampledMetricName.RAM_IN_USE, + }, +) +``` + +--- + +## 4) EventMetricName + +Per-event metrics (not sampled). + +* `RQS_CLOCK` → `"rqs_clock"` +* `LLM_COST` → `"llm_cost"` (reserved for future accounting) + +**Used in:** `SimulationSettings(enabled_event_metrics=...)`. + +**Example** + +```python +from asyncflow.enums import EventMetricName +SimulationSettings(enabled_event_metrics={EventMetricName.RQS_CLOCK}) +``` + +--- + +## 5) (Advanced) Endpoint step enums + +You only need these if you create `Endpoint` steps **programmatically** in Python. In YAML you’ll write strings; both modes are supported. + +### 5.1 EndpointStepCPU + +CPU-bound step kinds: + +* `INITIAL_PARSING` → `"initial_parsing"` +* `CPU_BOUND_OPERATION` → `"cpu_bound_operation"` + +### 5.2 EndpointStepRAM + +RAM step kind: + +* `RAM` → `"ram"` + +### 5.3 EndpointStepIO + +I/O-bound step kinds: + +* `TASK_SPAWN` → `"io_task_spawn"` +* `LLM` → `"io_llm"` +* `WAIT` → `"io_wait"` +* `DB` → `"io_db"` +* `CACHE` → `"io_cache"` + +### 5.4 StepOperation + +Operation keys allowed inside `Step.step_operation`: + +* `CPU_TIME` → `"cpu_time"` (seconds, positive) +* `NECESSARY_RAM` → `"necessary_ram"` (MB, positive) +* `IO_WAITING_TIME` → `"io_waiting_time"` (seconds, positive) + +**Validation rules (enforced by the schema):** + +* Every `Step` must have **exactly one** operation key. +* The operation must **match** the step kind: + + * CPU step → `CPU_TIME` + * RAM step → `NECESSARY_RAM` + * I/O step → `IO_WAITING_TIME` + +**Example** + +```python +from asyncflow.enums import EndpointStepCPU, EndpointStepIO, EndpointStepRAM, StepOperation +from asyncflow.schemas.topology.endpoint import Endpoint + +ep = Endpoint( + endpoint_name="/predict", + steps=[ + { "kind": EndpointStepRAM.RAM, "step_operation": { StepOperation.NECESSARY_RAM: 128 } }, + { "kind": EndpointStepCPU.INITIAL_PARSING, "step_operation": { StepOperation.CPU_TIME: 0.002 } }, + { "kind": EndpointStepIO.DB, "step_operation": { StepOperation.IO_WAITING_TIME: 0.012 } }, + ], +) +``` + +--- + +## Usage patterns & tips + +* **Strings vs Enums:** All models accept both. Enums help with IDE hints and prevent typos; strings keep YAML compact. Mix as you like. +* **Keep it public, not internal:** Only the enums above are considered public and stable. Internals like `SystemNodes`, `SystemEdges`, `ServerResourceName`, etc. are intentionally **not exported** (they may change). +* **Forward compatibility:** New enum members may appear in minor releases (e.g., a new `SampledMetricName`). Your existing configs remain valid; just opt in when you need them. + +--- + +## Quick Reference + +| Enum | Where it’s used | Members (strings) | +| ------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `Distribution` | `RVConfig` | `poisson`, `normal`, `log_normal`, `exponential`, `uniform` | +| `LbAlgorithmsName` | `LoadBalancer.algorithms` | `round_robin`, `least_connection` | +| `SampledMetricName` | `SimulationSettings.enabled_sample_metrics` | `ready_queue_len`, `event_loop_io_sleep`, `ram_in_use`, `edge_concurrent_connection` | +| `EventMetricName` | `SimulationSettings.enabled_event_metrics` | `rqs_clock`, `llm_cost` | +| `EndpointStep*` | `Endpoint.steps[*].kind` (Python) | CPU: `initial_parsing`, `cpu_bound_operation`; RAM: `ram`; IO: `io_task_spawn`, `io_llm`, `io_wait`, `io_db`, `io_cache` | +| `StepOperation` | `Endpoint.steps[*].step_operation` | `cpu_time`, `necessary_ram`, `io_waiting_time` | + +--- diff --git a/docs/api/high-level.md b/docs/api/high-level.md new file mode 100644 index 0000000..65c3d24 --- /dev/null +++ b/docs/api/high-level.md @@ -0,0 +1,299 @@ +# AsyncFlow — High-Level API (`AsyncFlow`, `SimulationRunner`) + +This page explains how to programmatically **assemble a validated simulation payload** and **run** it, returning metrics and plots through the analyzer. + +* **Builder**: `AsyncFlow` – compose workload, topology, and settings into a single `SimulationPayload`. +* **Runner**: `SimulationRunner` – wire actors, start processes, collect metrics, and return a `ResultsAnalyzer`. + +--- + +## Imports + +```python +# High-level API +from asyncflow import AsyncFlow, SimulationRunner + +# Public leaf schemas (components & workload) +from asyncflow.components import Client, Server, Endpoint, Edge +from asyncflow.workload import RqsGenerator, RVConfig +from asyncflow.settings import SimulationSettings +``` + +> These are the **only** imports end users need. Internals (actors, registries, etc.) remain private. + +--- + +## Quick start + +A minimal end-to-end example: + +```python +from __future__ import annotations +import simpy + +from asyncflow import AsyncFlow, SimulationRunner +from asyncflow.components import Client, Server, Endpoint, Edge +from asyncflow.workload import RqsGenerator, RVConfig +from asyncflow.settings import SimulationSettings + +# 1) Workload +rqs = RqsGenerator( + id="rqs-1", + avg_active_users=RVConfig(mean=50, # Poisson by default + # or Distribution.NORMAL with variance auto=mean + ), + avg_request_per_minute_per_user=RVConfig(mean=30), # MUST be Poisson + user_sampling_window=60, # seconds +) + +# 2) Topology components +client = Client(id="client-1") + +endpoint = Endpoint( + endpoint_name="/hello", + steps=[ + {"kind": "ram", "step_operation": {"necessary_ram": 32}}, + {"kind": "initial_parsing", "step_operation": {"cpu_time": 0.002}}, + {"kind": "io_wait", "step_operation": {"io_waiting_time": 0.010}}, + ], +) + +server = Server( + id="srv-1", + server_resources={"cpu_cores": 1, "ram_mb": 1024}, + endpoints=[endpoint], +) + +edges = [ + Edge(id="gen-client", source="rqs-1", target="client-1", + latency={"mean": 0.003, "distribution": "exponential"}), + Edge(id="client-srv1", source="client-1", target="srv-1", + latency={"mean": 0.003, "distribution": "exponential"}), + Edge(id="srv1-client", source="srv-1", target="client-1", + latency={"mean": 0.003, "distribution": "exponential"}), +] + +# 3) Settings (baseline sampled metrics are mandatory) +settings = SimulationSettings( + total_simulation_time=300, # seconds (≥ 5) + sample_period_s=0.01, # 0.001 ≤ value ≤ 0.1 + # enabled_sample_metrics and enabled_event_metrics: safe defaults already set +) + +# 4) Build (validates everything with Pydantic) +payload = ( + AsyncFlow() + .add_generator(rqs) + .add_client(client) + .add_servers(server) + .add_edges(*edges) + .add_simulation_settings(settings) + .build_payload() +) + +# 5) Run +env = simpy.Environment() +results = SimulationRunner(env=env, simulation_input=payload).run() + +# 6) Use the analyzer (examples) +print(results.get_latency_stats()) +ts, rps = results.get_throughput_series() +sampled = results.get_sampled_metrics() +``` + +--- + +## `AsyncFlow` — builder (public) + +`AsyncFlow` helps you construct a **self-consistent** `SimulationPayload` with fluent, chainable calls. Every piece you add is type-checked; the final `build_payload()` validates the full graph and settings. + +### API + +```python +class AsyncFlow: + def add_generator(self, rqs_generator: RqsGenerator) -> Self: ... + def add_client(self, client: Client) -> Self: ... + def add_servers(self, *servers: Server) -> Self: ... + def add_edges(self, *edges: Edge) -> Self: ... + def add_simulation_settings(self, sim_settings: SimulationSettings) -> Self: ... + def add_load_balancer(self, load_balancer: LoadBalancer) -> Self: ... + def build_payload(self) -> SimulationPayload: ... +``` + +### Validation performed by `build_payload()` + +On build, the composed payload is validated by the Pydantic schemas: + +1. **Presence** + + * Generator, client, **≥ 1 server**, **≥ 1 edge**, settings are required. + +2. **Unique IDs** + + * Duplicate server IDs or edge IDs are rejected. + +3. **Node types** + + * Fixed enums: `client`, `server`, `load_balancer`; validated on each node. + +4. **Edge integrity** + + * Every edge **target** must be a declared node ID. + * **External IDs** (e.g., the generator id) are allowed **only as sources**. + * **No self-loops** (`source != target`). + +5. **Load balancer sanity** (if present) + + * `server_covered ⊆ declared servers`. + * There must be an **edge from the LB to every covered server**. + +6. **(Engine rule)** “No fan-out except LB” + + * Only the LB may have multiple outgoing edges among declared nodes. + +7. **Latency RV constraints (edges)** + + * `latency.mean > 0`, and if `variance` exists, `variance ≥ 0`. + +If a rule fails, a **descriptive `ValueError`** points at the offending entity/field. + +### Typical errors you might see + +* Missing parts: + `ValueError: The generator input must be instantiated before the simulation` +* Type mis-match: + `TypeError: All the instances must be of the type Server` +* Graph violations: + `ValueError: Edge client-1->srv-X references unknown target node 'srv-X'` +* LB coverage: + `ValueError: Servers ['srv-2'] are covered by LB 'lb-1' but have no outgoing edge from it.` + +--- + +## `SimulationRunner` — orchestrator (public) + +`SimulationRunner` takes a validated `SimulationPayload`, **instantiates all runtimes**, **wires** edges to their target mailboxes, **starts** every actor, **collects** sampled metrics, and advances the SimPy clock. + +### API + +```python +class SimulationRunner: + def __init__(self, *, env: simpy.Environment, simulation_input: SimulationPayload) -> None: ... + def run(self) -> ResultsAnalyzer: ... + @classmethod + def from_yaml(cls, *, env: simpy.Environment, yaml_path: str | Path) -> "SimulationRunner": ... +``` + +* **`env`**: your SimPy environment (you control its lifetime). + +* **`simulation_input`**: the payload returned by `AsyncFlow.build_payload()` (or parsed from YAML). + +* **`run()`**: + + * Builds and wires all runtime actors (`RqsGeneratorRuntime`, `ClientRuntime`, `ServerRuntime`, `LoadBalancerRuntime`, `EdgeRuntime`). + * Starts the **SampledMetricCollector** (baseline sampled metrics are mandatory and collected automatically). + * Runs until `SimulationSettings.total_simulation_time`. + * Returns a **`ResultsAnalyzer`** with helpers like: + + * `get_latency_stats()` + * `get_throughput_series()` + * `get_sampled_metrics()` + * plotting helpers (`plot_latency_distribution`, `plot_throughput`, …). + +* **`from_yaml`**: convenience constructor for loading a full payload from a YAML file and running it immediately. + +### Determinism & RNG + +* The runner uses `numpy.random.default_rng()` internally. + Seeding is not yet exposed as a public parameter; exact reproducibility across runs is **not guaranteed** in this version. + +--- + +## Extended example: with Load Balancer + +```python +from asyncflow.components import Client, Server, Endpoint, Edge +from asyncflow.components import LoadBalancer +from asyncflow import AsyncFlow, SimulationRunner +from asyncflow.workload import RqsGenerator, RVConfig +from asyncflow.settings import SimulationSettings +import simpy + +client = Client(id="client-1") + +srv1 = Server( + id="srv-1", + server_resources={"cpu_cores": 1, "ram_mb": 1024}, + endpoints=[Endpoint(endpoint_name="/api", steps=[{"kind":"ram","step_operation":{"necessary_ram":64}}])] +) +srv2 = Server( + id="srv-2", + server_resources={"cpu_cores": 2, "ram_mb": 2048}, + endpoints=[Endpoint(endpoint_name="/api", steps=[{"kind":"io_db","step_operation":{"io_waiting_time":0.012}}])] +) + +lb = LoadBalancer(id="lb-1", algorithms="round_robin", server_covered={"srv-1","srv-2"}) + +edges = [ + Edge(id="gen-client", source="rqs-1", target="client-1", latency={"mean":0.002,"distribution":"exponential"}), + Edge(id="client-lb", source="client-1", target="lb-1", latency={"mean":0.002,"distribution":"exponential"}), + Edge(id="lb-srv1", source="lb-1", target="srv-1", latency={"mean":0.002,"distribution":"exponential"}), + Edge(id="lb-srv2", source="lb-1", target="srv-2", latency={"mean":0.002,"distribution":"exponential"}), + Edge(id="srv1-client", source="srv-1", target="client-1", latency={"mean":0.003,"distribution":"exponential"}), + Edge(id="srv2-client", source="srv-2", target="client-1", latency={"mean":0.003,"distribution":"exponential"}), +] + +payload = ( + AsyncFlow() + .add_generator(RqsGenerator( + id="rqs-1", + avg_active_users=RVConfig(mean=120), + avg_request_per_minute_per_user=RVConfig(mean=20), + user_sampling_window=60, + )) + .add_client(client) + .add_servers(srv1, srv2) + .add_load_balancer(lb) + .add_edges(*edges) + .add_simulation_settings(SimulationSettings(total_simulation_time=600, sample_period_s=0.02)) + .build_payload() +) + +env = simpy.Environment() +results = SimulationRunner(env=env, simulation_input=payload).run() +``` + +--- + +## Performance tips + +* **Sampling cost** grows with `total_simulation_time / sample_period_s × (#sampled metrics × entities)`. + For long runs, consider a larger `sample_period_s` (e.g., `0.02–0.05`) to reduce memory while keeping the baseline metrics intact. + +* **Validation first**: prefer failing early by letting `build_payload()` validate everything before the runner starts. + +--- + +## Error handling (what throws) + +* **Type errors** on builder inputs (`TypeError`) when passing the wrong class to `add_*`. +* **Validation errors** (`ValueError`) on `build_payload()` if the graph is inconsistent (unknown targets, duplicates, LB edges missing, self-loops, illegal fan-out, latency rules, etc.). +* **Runtime wiring errors** (`TypeError`) if an unknown runtime target/source type appears while wiring edges (should not occur with a validated payload). + +--- + +## YAML path (alternative) + +You can construct the payload in YAML (see “YAML Input Guide”), then: + +```python +import simpy +from asyncflow import SimulationRunner + +env = simpy.Environment() +runner = SimulationRunner.from_yaml(env=env, yaml_path="scenario.yml") +results = runner.run() +``` + +--- + diff --git a/docs/api/settings.md b/docs/api/settings.md new file mode 100644 index 0000000..2970596 --- /dev/null +++ b/docs/api/settings.md @@ -0,0 +1,200 @@ + +# AsyncFlow — Public API Reference: `settings` + +This page documents the **public settings schema** you import from: + +```python +from asyncflow.settings import SimulationSettings +``` + +These settings control **simulation duration**, **sampling cadence**, and **which metrics are collected**. The model is validated with Pydantic and ships with safe defaults. + +> **Contract note** +> The four **baseline sampled metrics** are **mandatory** in the current release: +> +> * `ready_queue_len` +> * `event_loop_io_sleep` +> * `ram_in_use` +> * `edge_concurrent_connection` +> Future metrics may be opt-in; these four must remain enabled. + +--- + +## Imports + +```python +from asyncflow.settings import SimulationSettings + +# Optional: use enums instead of strings (recommended for IDE/type-checking) +from asyncflow.enums import SampledMetricName, EventMetricName +``` + +--- + +## Quick start + +```python +from asyncflow.settings import SimulationSettings +from asyncflow.enums import SampledMetricName as S, EventMetricName as E + +settings = SimulationSettings( + total_simulation_time=300, # seconds (≥ 5) + sample_period_s=0.01, # seconds, 0.001 ≤ value ≤ 0.1 + # Baseline sampled metrics are mandatory (may include more in future): + enabled_sample_metrics={S.READY_QUEUE_LEN, + S.EVENT_LOOP_IO_SLEEP, + S.RAM_IN_USE, + S.EDGE_CONCURRENT_CONNECTION}, + # Event metrics (RQS_CLOCK is the default/mandatory one today): + enabled_event_metrics={E.RQS_CLOCK}, +) +``` + +Pass the object to the builder: + +```python +from asyncflow import AsyncFlow + +payload = ( + AsyncFlow() + # … add workload, topology, edges … + .add_simulation_settings(settings) + .build_payload() +) +``` + +--- + +## Schema reference + +### `SimulationSettings` + +```python +SimulationSettings( + total_simulation_time: int = 3600, # ≥ 5 + sample_period_s: float = 0.01, # 0.001 ≤ value ≤ 0.1 + enabled_sample_metrics: set[SampledMetricName] = { + "ready_queue_len", + "event_loop_io_sleep", + "ram_in_use", + "edge_concurrent_connection", + }, + enabled_event_metrics: set[EventMetricName] = {"rqs_clock"}, +) +``` + +**Fields** + +* **`total_simulation_time`** *(int, default `3600`)* + Simulation horizon in **seconds**. **Validation:** `≥ 5`. + +* **`sample_period_s`** *(float, default `0.01`)* + Sampling cadence for time-series metrics (seconds). + **Validation:** `0.001 ≤ sample_period_s ≤ 0.1`. + **Trade-off:** lower ⇒ higher temporal resolution but more samples/memory. + +* **`enabled_sample_metrics`** *(set of enums/strings; default = baseline 4)* + **Must include at least the baseline set** shown above. You can pass enum + values or the corresponding strings. + +* **`enabled_event_metrics`** *(set of enums/strings; default `{"rqs_clock"}`)* + Per-event KPIs (not tied to `sample_period_s`). `rqs_clock` is required today; + `llm_cost` is reserved for future use. + +--- + +## Supported metric enums + +You may pass **strings** or import the enums (recommended). + +### Sampled (time-series) + +* `ready_queue_len` — event-loop ready-queue length +* `event_loop_io_sleep` — time spent waiting on I/O in the loop +* `ram_in_use` — MB of RAM in use (per server) +* `edge_concurrent_connection` — concurrent connections per edge + +```python +from asyncflow.enums import SampledMetricName as S +baseline = {S.READY_QUEUE_LEN, S.EVENT_LOOP_IO_SLEEP, S.RAM_IN_USE, S.EDGE_CONCURRENT_CONNECTION} +``` + +### Event (per-event) + +* `rqs_clock` — start/end timestamps for each request (basis for latency) +* `llm_cost` — reserved for future cost accounting + +```python +from asyncflow.enums import EventMetricName as E +SimulationSettings(enabled_event_metrics={E.RQS_CLOCK}) +``` + +--- + +## Practical presets + +* **Lean but compliant (fast inner-loop dev)** + Keep baseline metrics; increase the sampling period to reduce cost: + + ```python + SimulationSettings( + total_simulation_time=10, + sample_period_s=0.05, # fewer samples + # enabled_* use defaults with mandatory baseline & rqs_clock + ) + ``` + +* **High-resolution debugging (short runs)** + + ```python + SimulationSettings( + total_simulation_time=60, + sample_period_s=0.002, # finer resolution + ) + ``` + +* **Long scenarios (memory-friendly)** + + ```python + SimulationSettings( + total_simulation_time=1800, + sample_period_s=0.05, # fewer samples over long runs + ) + ``` + +--- + +## YAML ⇄ Python mapping + +| YAML (`sim_settings`) | Python (`SimulationSettings`) | +| -------------------------- | ------------------------------ | +| `total_simulation_time` | `total_simulation_time` | +| `sample_period_s` | `sample_period_s` | +| `enabled_sample_metrics[]` | `enabled_sample_metrics={...}` | +| `enabled_event_metrics[]` | `enabled_event_metrics={...}` | + +Strings in YAML map to the same enum names used by Python. + +--- + +## Validation & guarantees + +* `total_simulation_time ≥ 5` +* `0.001 ≤ sample_period_s ≤ 0.1` +* `enabled_sample_metrics ⊇ {ready_queue_len, event_loop_io_sleep, ram_in_use, edge_concurrent_connection}` +* `enabled_event_metrics` must include `rqs_clock` (current contract) +* Enum names are part of the public contract (stable; new values may be added in minor versions) + +--- + +## Tips & pitfalls + +* **Memory/CPU budgeting**: total samples per metric ≈ + `total_simulation_time / sample_period_s`. Long runs with very small + sampling periods produce large arrays. +* **Use enums for safety**: strings work, but enums enable IDE completion and mypy checks. +* **Forward compatibility**: additional sampled/event metrics may become available; the four baseline sampled metrics remain mandatory for the engine’s collectors. + +--- + +This reflects your current implementation: baseline sampled metrics are **required**; event metrics currently require `rqs_clock`; and sampling bounds match the `SamplePeriods` constants. diff --git a/docs/api/workload.md b/docs/api/workload.md new file mode 100644 index 0000000..c560a65 --- /dev/null +++ b/docs/api/workload.md @@ -0,0 +1,197 @@ +# AsyncFlow — Public Workload API + +This page documents the **workload models** exported from: + +```python +from asyncflow.workload import RqsGenerator, RVConfig +``` + +Use these to describe **how traffic is generated** (active users, per-user RPM, and the re-sampling window). The workload is independent from your topology and settings and plugs into the builder or payload directly. + +> **Stability:** This is part of the public API. Fields and enum values are semver-stable (new options may be added in minor releases; breaking changes only in a major). + +--- + +## Quick start + +```python +from asyncflow.workload import RqsGenerator, RVConfig + +rqs = RqsGenerator( + id="rqs-1", + avg_active_users=RVConfig(mean=100, distribution="poisson"), # or "normal" + avg_request_per_minute_per_user=RVConfig(mean=20, distribution="poisson"), + user_sampling_window=60, # seconds, re-sample active users every 60s +) + +# … then compose with the builder +from asyncflow.builder.asyncflow_builder import AsyncFlow +payload = (AsyncFlow() + .add_generator(rqs) + # .add_client(...).add_servers(...).add_edges(...).add_simulation_settings(...) + .build_payload()) +``` + +--- + +## `RqsGenerator` (workload root) + +```python +class RqsGenerator(BaseModel): + id: str + type: SystemNodes = SystemNodes.GENERATOR # fixed + avg_active_users: RVConfig # Poisson or Normal + avg_request_per_minute_per_user: RVConfig # Poisson (required) + user_sampling_window: int = 60 # seconds, bounds [1, 120] +``` + +### Field reference + +| Field | Type | Allowed / Bounds | Description | +| --------------------------------- | --------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `id` | `str` | — | Identifier used by edges (e.g., `source="rqs-1"`). | +| `type` | enum (fixed) | `generator` | Constant; not user-set. | +| `avg_active_users` | `RVConfig` | **Distribution**: `poisson` or `normal` | Random variable for active concurrent users. If `normal`, variance is auto-filled (see `RVConfig`). | +| `avg_request_per_minute_per_user` | `RVConfig` | **Distribution**: **must be** `poisson` | Per-user rate (RPM). Enforced to Poisson by validator. | +| `user_sampling_window` | `int` (seconds) | `1 ≤ value ≤ 120` | How often to re-sample `avg_active_users`. Larger windows → slower drift; smaller windows → more volatility. | + +> Units: RPM = requests per **minute**; times are in **seconds**. + +--- + +## `RVConfig` (random variables) + +```python +class RVConfig(BaseModel): + mean: float + distribution: Distribution = "poisson" + variance: float | None = None +``` + +### Behavior & validation + +* **`mean`** is required and coerced to `float`. (Generic numeric check; positivity is **contextual**. For example, edge latency enforces `mean > 0`, while workloads accept `mean ≥ 0` and rely on samplers to truncate at 0 when needed.) +* **`distribution`** defaults to `"poisson"`. +* **Variance auto-fill:** if `distribution` is `"normal"` or `"log_normal"` **and** `variance` is omitted, it is set to `variance = mean`. + +### Supported distributions + +* `"poisson"`, `"normal"`, `"log_normal"`, `"exponential"`, `"uniform"` + (For **workload**: `avg_active_users` → Poisson/Normal; `avg_request_per_minute_per_user` → **Poisson only**.) + +--- + +## How the workload is sampled (engine semantics) + +AsyncFlow provides **joint samplers** for the two main cases: + +1. **Poisson–Poisson** (`avg_active_users ~ Poisson`, `rpm ~ Poisson`) + +* Every `user_sampling_window` seconds, draw users: + `U ~ Poisson(mean_users)`. +* Aggregate rate: `Λ = U * (rpm_per_user / 60)` (requests/second). +* Within the window, inter-arrival gaps are exponential: + `Δt ~ Exponential(Λ)` (via inverse CDF). +* If `U == 0`, no arrivals until the next window. + +2. **Gaussian–Poisson** (`avg_active_users ~ Normal`, `rpm ~ Poisson`) + +* Draw users with **truncation at 0** (negative draws become 0): + `U ~ max(N(mean, variance), 0)`. +* Then same steps as above: `Λ = U * (rpm_per_user / 60)`, `Δt ~ Exponential(Λ)`. + +**Implications of `user_sampling_window`:** + +* Smaller windows → more frequent changes in `U` (bursty arrivals). +* Larger windows → steadier rate within each window, fewer rate shifts. + +--- + +## Examples + +### A. Steady mid-load (Poisson–Poisson) + +```python +rqs = RqsGenerator( + id="steady", + avg_active_users=RVConfig(mean=80, distribution="poisson"), + avg_request_per_minute_per_user=RVConfig(mean=15, distribution="poisson"), + user_sampling_window=60, +) +``` + +### B. Bursty users (Gaussian–Poisson) + +```python +rqs = RqsGenerator( + id="bursty", + avg_active_users=RVConfig(mean=50, distribution="normal", variance=200), # bigger var → burstier + avg_request_per_minute_per_user=RVConfig(mean=18, distribution="poisson"), + user_sampling_window=15, # faster re-sampling → faster drift +) +``` + +### C. Tiny smoke test + +```python +rqs = RqsGenerator( + id="smoke", + avg_active_users=RVConfig(mean=1, distribution="poisson"), + avg_request_per_minute_per_user=RVConfig(mean=2, distribution="poisson"), + user_sampling_window=30, +) +``` + +--- + +## YAML / JSON equivalence + +If you configure via YAML/JSON, the equivalent block is: + +```yaml +rqs_input: + id: rqs-1 + avg_active_users: + mean: 100 + distribution: poisson # or normal + # variance: 100 # optional; auto=mean if normal/log_normal + avg_request_per_minute_per_user: + mean: 20 + distribution: poisson # must be poisson + user_sampling_window: 60 # [1..120] seconds +``` + +--- + +## Validation & error messages (what you can expect) + +* `avg_request_per_minute_per_user.distribution != poisson` + → `ValueError("At the moment the variable avg request must be Poisson")` +* `avg_active_users.distribution` not in `{poisson, normal}` + → `ValueError("At the moment the variable active user must be Poisson or Gaussian")` +* Non-numeric `mean` in any `RVConfig` + → `ValueError("mean must be a number (int or float)")` +* `user_sampling_window` outside `[1, 120]` + → Pydantic range validation error with clear bounds in the message. + +> Note: Positivity for means is enforced **contextually**. For workload, negative draws are handled by the samplers (e.g., truncated Normal). For edge latency, positivity is enforced at the edge model level. + +--- + +## Common pitfalls & tips + +* **Using Normal without variance:** If you set `distribution="normal"` and omit `variance`, it auto-fills to `variance=mean`. Set it explicitly if you want heavier or lighter variance than the default. +* **Confusing units:** RPM is **per minute**, not per second. The engine converts internally. +* **Over-reactive windows:** Very small `user_sampling_window` (e.g., `1–5s`) makes the rate jumpy; this is fine for “bursty” scenarios but can be noisy. +* **Zero arrivals:** If a window samples `U=0`, you’ll get no arrivals until the next window; this is expected. + +--- + +## Interplay with Settings & Metrics + +* The workload **does not** depend on the sampling cadence of time-series metrics (`SimulationSettings.sample_period_s`). Sampling controls **observability**, not arrivals. +* **Baseline sampled metrics are mandatory** in the current release (ready-queue length, I/O sleep, RAM, edge concurrency). Future metrics can be opt-in. + +--- + +With `RqsGenerator` + `RVConfig` you can describe steady, bursty, or sparse loads with a few lines—then reuse the same topology and settings to compare how architecture choices behave under different traffic profiles. diff --git a/docs/dev-workflow-guide.md b/docs/dev-workflow-guide.md new file mode 100644 index 0000000..ff0e3ce --- /dev/null +++ b/docs/dev-workflow-guide.md @@ -0,0 +1,273 @@ +# **Development Workflow & Architecture Guide** + +This document describes the development workflow, repository architecture, and branching strategy for **AsyncFlow** +--- + +## 1) Repository Layout + +### 1.1 Project tree (backend) + +``` +AsyncFlow-backend/ +├─ examples/ # runnable examples (YAML & Python) +│ └─ data/ +├─ scripts/ # helper bash scripts (lint, quality, etc.) +│ └─ quality-check.sh +├─ docs/ # product & technical docs +├─ tests/ # unit & integration tests +│ ├─ unit/ +│ └─ integration/ +├─ src/ +│ └─ asyncflow/ # Python package (library) +│ ├─ __init__.py # public "high-level" facade (re-exports) +│ ├─ builder/ +│ │ └─ asyncflow_builder.py # internal builder implementation +│ ├─ components/ # PUBLIC FACADE: leaf Pydantic components +│ │ └─ __init__.py # (barrel: re-exports Client, Server, Endpoint, Edge) +│ ├─ config/ +│ │ ├─ constants.py # enums/constants (source of truth) +│ │ └─ plot_constants.py +│ ├─ enums/ # PUBLIC FACADE: selected enums +│ │ └─ __init__.py # (barrel: re-exports Distribution, SampledMetricName, …) +│ ├─ metrics/ +│ │ ├─ analyzer.py # results post-processing +│ │ ├─ collector.py # sampling collectors +│ │ ├─ client.py +│ │ ├─ edge.py +│ │ └─ server.py +│ ├─ resources/ +│ │ ├─ registry.py +│ │ └─ server_containers.py +│ ├─ runtime/ +│ │ ├─ simulation_runner.py # engine entry-point +│ │ ├─ rqs_state.py +│ │ ├─ actors/ # INTERNAL: Client/Server/Edge/Generator actors +│ │ └─ routing/ +│ │ └─ lb_algorithms.py +│ ├─ samplers/ +│ │ ├─ poisson_poisson.py +│ │ ├─ gaussian_poisson.py +│ │ └─ common_helpers.py +│ ├─ schemas/ # INTERNAL: full Pydantic schema impls +│ │ ├─ payload.py +│ │ ├─ common/ +│ │ │ └─ random_variables.py +│ │ ├─ settings/ +│ │ │ └─ simulation.py +│ │ ├─ topology/ +│ │ │ ├─ edges.py +│ │ │ ├─ endpoint.py +│ │ │ ├─ graph.py +│ │ │ └─ nodes.py +│ │ └─ workload/ +│ │ └─ rqs_generator.py +│ ├─ settings/ # PUBLIC FACADE: SimulationSettings +│ │ └─ __init__.py +│ └─ workload/ # PUBLIC FACADE: RqsGenerator +│ └─ __init__.py +├─ poetry.lock +├─ pyproject.toml +└─ README.md +``` + +**Public API surface (what you guarantee as stable):** + +* High-level: + + ```py + from asyncflow import AsyncFlow, SimulationRunner + ``` +* Components: + + ```py + from asyncflow.components import Client, Server, Endpoint, Edge + ``` +* Workload & Settings: + + ```py + from asyncflow.workload import RqsGenerator + from asyncflow.settings import SimulationSettings + ``` +* Enums: + + ```py + from asyncflow.enums import Distribution, SampledMetricName, EventMetricName, LbAlgorithmsName + ``` + +> Everything under `asyncflow.schemas/`, `asyncflow.runtime/actors/`, `asyncflow.builder/` is **internal** (implementation details). The facades re-export only what users should import. + +### 1.2 What each top-level area does + +| Area | Purpose | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| **builder/** | Internal implementation of the pybuilder used by `AsyncFlow`. Users shouldn’t import from here directly. | +| **components/** | **Public facade** for *leaf* Pydantic models used in payloads: `Client`, `Server`, `Endpoint`, `Edge`. | +| **config/** | Constants & enums source-of-truth (kept internal; only *selected* enums are re-exported via `asyncflow.enums`). | +| **enums/** | **Public facade** re-exporting the enums that appear in public payloads (`Distribution`, `SampledMetricName`, `EventMetricName`, …). | +| **metrics/** | Post-processing & visualization support (analyzer & collectors). | +| **resources/** | Runtime SimPy resource wiring (CPU/RAM containers, registries). | +| **runtime/** | The simulation engine entry-point (`SimulationRunner`), request lifecycle, and internal actors. | +| **samplers/** | Random-variable samplers for the generator and steps. | +| **schemas/** | Full Pydantic schema implementation and validation rules (internal). | +| **settings/** | **Public facade** re-exporting `SimulationSettings`. | +| **workload/** | **Public facade** re-exporting `RqsGenerator`. | + +--- + +## 2) Branching Strategy: Git Flow (+ `refactor/*`) + +We use **Git Flow** with an extra branch family for clean refactors. + +### Diagram + +```mermaid +--- +title: Git Flow (with refactor/*) +--- +gitGraph + commit id: "Initial commit" + branch develop + checkout develop + commit id: "Setup Project" + + branch feature/user-authentication + checkout feature/user-authentication + commit id: "feat: Add login form" + commit id: "feat: Add form validation" + checkout develop + merge feature/user-authentication + + branch refactor/performance-cleanup + checkout refactor/performance-cleanup + commit id: "refactor: simplify hot path" + commit id: "refactor: remove dead code" + checkout develop + merge refactor/performance-cleanup + + branch release/v1.0.0 + checkout release/v1.0.0 + commit id: "fix: Pre-release bug fixes" tag: "v1.0.0" + checkout main + merge release/v1.0.0 + checkout develop + merge release/v1.0.0 + + checkout main + branch hotfix/critical-login-bug + checkout hotfix/critical-login-bug + commit id: "fix: Critical production bug" tag: "v1.0.1" + checkout main + merge hotfix/critical-login-bug + checkout develop + merge hotfix/critical-login-bug +``` + +### Branch families + +* **main** – production-ready, tagged releases only (no direct commits). +* **develop** – integration branch; base for `feature/*` and `refactor/*`. +* **feature/**\* – user-visible features (new endpoints/behavior, DB changes). +* **refactor/**\* – **no new features**; internal changes, performance, renames, file moves, debt trimming. Use `refactor:` commit prefix. +* **release/**\* – freeze, harden, docs; merge into `main` (tag) and back into `develop`. +* **hotfix/**\* – urgent production fixes; branch off `main` tag; merge into `main` (tag) and `develop`. + +**When to pick which:** + +* New behavior or external contract → `feature/*` +* Internal cleanup only → `refactor/*` +* Ship a version → `release/*` +* Patch production now → `hotfix/*` + +--- + +## 3) CI/CD Pipeline + +A layered pipeline (GitHub Actions recommended) mirrors the branching model. + +### 3.1 CI on PRs to `develop` (feature/refactor) + +**Quick Suite** (fast, no external services): + +* **Ruff** (or Black/isort/Flake8) → style/lint +* **mypy** → type checking +* **pytest** unit-only: `pytest -m "not integration"` + +### 3.2 CI on push to `develop` + +**Full Suite** (slower; with services): + +* Full tests, including `@pytest.mark.integration` +* Spin up **PostgreSQL**/**Redis** if required by integration tests +* Build multi-stage Docker image & smoke test +* Optionally build docs (mkdocs) to catch docstring regressions + +### 3.3 CI on `release/*` + +* Always run **Full Suite** +* Build and publish versioned images/artifacts +* Generate release notes/changelog + +### 3.4 CI on `hotfix/*` + +* Run **Full Suite** +* Tag patch release on merge to `main` +* Merge back to `develop` + +> Refactors should be **behavior-preserving**. If a refactor touches hot paths, add micro-benchmarks or targeted integration tests and consider running the Full Suite pre-merge. + +--- + +## 4) Quality Gates & Conventions + +* **Style & Lint**: Ruff (or Black + isort + Flake8). No violations. +* **Types**: mypy clean. +* **Tests**: + + * Unit tests for new/refactored code paths + * Integration tests for end-to-end behavior +* **Commits**: Conventional commits (`feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `chore:`). +* **PRs**: Review required; refactors must include rationale and scope. +* **Docs**: Update `docs/` and public API references when you touch public facades. + +--- + +## 5) Public API & Stability Contract + +Only **facade modules** are considered public and stable: + +* High-level: + + ```py + from asyncflow import AsyncFlow, SimulationRunner + ``` +* Components: + + ```py + from asyncflow.components import Client, Server, Endpoint, Edge + ``` +* Workload & Settings: + + ```py + from asyncflow.workload import RqsGenerator + from asyncflow.settings import SimulationSettings + ``` +* Enums: + + ```py + from asyncflow.enums import Distribution, SampledMetricName, EventMetricName, LbAlgorithmsName + ``` + +Everything else—`schemas/`, `runtime/actors/`, `builder/`, `samplers/`, `resources/`—is **internal** and can change without notice. Use **SemVer** for releases; any change to the public facades that breaks compatibility requires a **major** bump. + +--- + +## 6) Developer Commands (Poetry) + +* Install: `poetry install` +* Lint/format: `bash scripts/quality-check.sh` (or your Ruff/Black commands) +* Test (unit only): `pytest -m "not integration"` +* Test (full): `pytest` +* Run example: `python examples/single_server_pybuilder.py` + +--- + diff --git a/docs/dev_workflow_guide.md b/docs/dev_workflow_guide.md deleted file mode 100644 index 8c1974c..0000000 --- a/docs/dev_workflow_guide.md +++ /dev/null @@ -1,256 +0,0 @@ -# **Development Workflow & Architecture Guide** - -This document outlines the standardized development workflow, repository architecture, and branching strategy for the backend of the AsyncFlow project. Adhering to these guidelines ensures consistency, maintainability, and a scalable development process. - - -## 1. Repository Layout - -### 1.1 Backend Service (`AsyncFlow-backend`) - -The repository hosts the entire FastAPI backend for AsyncFlow. -Its job is to expose the REST API, run the discrete-event simulation, talk to the database, and provide metrics. - -``` -AsyncFlow-backend/ -├── example/ # examples of working simulations -│ ├── data -├── scripts/ # helper bash scripts (lint, dev-startup, …) -│ └── quality-check.sh -├── docs/ # project vision & low-level docs -│ └── AsyncFlow-documentation/ -├── tests/ # unit & integration tests -│ ├── unit/ -│ └── integration/ -├── src/ # application code lives here -│ └── app/ -│ ├── config/ # Pydantic Settings + constants -│ ├── metrics/ # logic to compute/aggregate simulation KPIs -│ ├── resources/ # SimPy resource registry (CPU/RAM containers, etc.) -│ ├── runtime/ # simulation core -│ │ ├── rqs_state.py # RequestState & Hop -│ │ ├── simulation_runner.py # logic to initialize the whole simulation -│ │ └── actors/ # SimPy “actors”: Edge, Server, Client, RqsGenerator -│ ├── pybuilder/ # Pythonic way to build the simulation payload -│ ├── samplers/ # stochastic samplers (Gaussian-Poisson, etc.) -│ ├── schemas/ # Pydantic input/output models -├── poetry.lock -├── pyproject.toml -└── README.md -``` - -> Note: If your package name under `src/` is `asyncflow/` (instead of `app/`), the structure is identical—only the package folder name changes. - -### What each top-level directory in `src/app` does - -| Directory | Purpose | -| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`config/`** | Centralised configuration layer. Contains Pydantic `BaseSettings` classes for reading environment variables and constants/enums used across the simulation engine. | -| **`metrics/`** | Post-processing and analytics. Aggregates raw simulation traces into KPIs such as latency percentiles, throughput, resource utilisation, and other performance metrics. | -| **`resources/`** | Runtime resource registry for simulated hardware components (e.g., SimPy `Container`s for CPU and RAM). Decouples resource management from actor behaviour. | -| **`runtime/`** | Core simulation engine. Orchestrates SimPy execution, maintains request state, and wires together simulation components. Includes: | -| | - **`rqs_state.py`** — Defines `RequestState` and `Hop` for tracking request lifecycle. | -| | - **`simulation_runner.py`** — Entry point for initialising and running simulations. | -| | - **`actors/`** — SimPy actor classes representing system components (`RqsGenerator`, `Client`, `Server`, `Edge`) and their behaviour. | -| **`pybuilder/`** | Pythonic builder to programmatically construct validated simulation payloads (alternative to YAML). | -| **`samplers/`** | Random-variable samplers for stochastic simulation. Supports Poisson, Normal, and mixed distributions for modelling inter-arrival times and service steps. | -| **`schemas/`** | Pydantic models for input/output validation and serialisation. Includes scenario definitions, topology graphs, simulation settings, and results payloads. | - ---- - -### Other Top-Level Directories - -| Directory | Purpose | -| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`example/`** | Ready-to-run simulation scenarios and example configurations. Includes `data/` with YAML definitions and scripts to demonstrate engine usage. | -| **`scripts/`** | Utility shell scripts for development workflow, linting, formatting, and local startup (`quality-check.sh`, etc.). | -| **`docs/`** | Project documentation. Contains both high-level vision documents and low-level technical references (`AsyncFlow-documentation/`). | -| **`tests/`** | Automated test suite, split into **unit** and **integration** tests to verify correctness of both individual components and end-to-end scenarios. | - ---- - -## 2. Branching Strategy: Git Flow - -To manage code development and releases in a structured manner, we use the **Git Flow** branching model—with an additional **refactor** branch type for non-feature refactoring work. - -### Git Flow Workflow Diagram - -```mermaid ---- -title: Git Flow (with refactor/*) ---- - -gitGraph - commit id: "Initial commit" - branch develop - checkout develop - commit id: "Setup Project" - - %% feature branch - branch feature/user-authentication - checkout feature/user-authentication - commit id: "feat: Add login form" - commit id: "feat: Add form validation" - checkout develop - merge feature/user-authentication - - %% refactor branch (no new features, code cleanup/improvements) - branch refactor/performance-cleanup - checkout refactor/performance-cleanup - commit id: "refactor: simplify hot path" - commit id: "refactor: remove dead code" - checkout develop - merge refactor/performance-cleanup - - %% release branch - branch release/v1.0.0 - checkout release/v1.0.0 - commit id: "fix: Pre-release bug fixes" tag: "v1.0.0" - checkout main - merge release/v1.0.0 - checkout develop - merge release/v1.0.0 - - %% hotfix branch - checkout main - branch hotfix/critical-login-bug - checkout hotfix/critical-login-bug - commit id: "fix: Critical production bug" tag: "v1.0.1" - checkout main - merge hotfix/critical-login-bug - checkout develop - merge hotfix/critical-login-bug -``` - ---- - -### Git Flow Explained - -This workflow is built upon two long-lived branches and several temporary, supporting branches. - -#### Main Branches - -1. **`main`** - **Purpose**: Production-ready, stable code. Every commit on `main` represents an official release. - **Rules**: Never commit directly to `main`. It only receives merges from `release/*` and `hotfix/*`. Each merge should be **tagged** (e.g., `v1.0.0`). - -1. **`develop`** - **Purpose**: The main integration branch for ongoing development. It contains all completed and tested changes planned for the next release. - **Rules**: Base for `feature/*` and `refactor/*` branches. Reflects the most up-to-date development state. - -#### Supporting Branches - -3. **`feature/*`** (e.g., `feature/user-authentication`) - **Purpose**: Develop a new, specific feature in isolation. - **Lifecycle**: - - 1. Branched off **`develop`**. - 1. When complete, open a **Pull Request (PR)** back into **`develop`**. - 3. Delete the branch after merge. - -3. **`refactor/*`** (e.g., `refactor/performance-cleanup`) **← new** - **Purpose**: Perform **non-functional code changes** (no new features), such as internal restructurings, performance optimisations, reducing technical debt, renaming, file moves, or dependency hygiene. - **Rules**: - - * Must **not** introduce user-visible features or breaking API/DB changes. - * Prefer commit prefix `refactor:`; avoid `feat:`. - * Keep changes scoped and well-described to simplify review. - **Lifecycle**: - - 1. Branched off **`develop`**. - 1. Open a **PR** back into **`develop`** (same review gates as features). - 3. Delete the branch after merge. - -5. **`release/*`** (e.g., `release/v1.1.0`) - **Purpose**: Prepare a production release—final bug fixes, docs, and last-minute tests. The feature set is frozen here. - **Lifecycle**: - - 1. Branched off **`develop`** when feature-complete. - 1. Merge into **`main`** (tag version) and back into **`develop`**. - 3. Delete after merges. - -6. **`hotfix/*`** (e.g., `hotfix/critical-login-bug`) - **Purpose**: Quickly patch a critical bug in production. - **Lifecycle**: - - 1. Branched off **`main`** (from a specific tag). - 1. Merge into **`main`** (tag a patch version, e.g., `v1.0.1`) **and** into **`develop`**. - 3. Delete after merges. - -**When to choose which branch?** - -* **New behavior / endpoints / DB migrations** → `feature/*` -* **Internal code improvements only** → `refactor/*` -* **Release prep** → `release/*` -* **Production emergency** → `hotfix/*` - ---- - -## 3. Continuous Integration / Continuous Delivery (CI/CD) Pipeline - -A robust CI/CD pipeline guarantees that every change is automatically validated, packaged, and—when appropriate—promoted to the next environment. Our pipeline is built with **GitHub Actions** and mirrors the branching model. - -We start with the CI part related to pushes and PRs in the backend service. - -### 3.1 CI for project-backend on `develop` - -#### 3.1.1 Goals - -* **Fast feedback** – linting, type-checking, and unit tests finish quickly for every Pull Request. -* **Confidence in integration** – migrations, integration tests, and Docker smoke-tests run on every push to `develop`. -* **Deployment safety** – only artifacts from a green pipeline can be released/deployed. -* **Parity with production** – the same multi-stage Dockerfile is built and probed in CI. - -#### 3.1.1 Pipeline Layers - -* **Quick Suite (PR to `develop`)** - *Runs in seconds; no external services or containers.* - - * Black, isort, Flake8 (or Ruff if adopted) - * mypy static type-checking - * Unit tests only (`pytest -m "not integration"`) - -* **Full Suite (push to `develop`)** - *Runs in a few minutes; includes real services and Docker.* - - * Full test suite, including `@pytest.mark.integration` tests - * Database migrations (PostgreSQL) against a disposable instance - * Redis available for tests if required - * Build multi-stage Docker image and run a quick smoke test - -### 3.1 CI for `feature/*` and `refactor/*` - -* **On PR to `develop`**: run the **Quick Suite** (lint, type-checking, unit tests). -* **Optional (recommended for large changes)**: allow a manual or scheduled **Full Suite** run for the branch to catch integration issues early. -* **On merge to `develop`**: the **Full Suite** runs (as described above). - -> `refactor/*` branches should maintain **zero behavior change**. If a refactor has the potential to alter behavior (e.g., performance-sensitive code), add targeted tests and consider a manual Full Suite run before merge. - -### 3.3 CI for `release/*` - -* Always run the **Full Suite**. -* Build and publish versioned artifacts/images to the registry with the release tag. -* Prepare release notes and changelog generation. - -### 3.3 CI for `hotfix/*` - -* Run the **Full Suite** against the hotfix branch. -* Tag the patch release on merge to `main` and propagate the merge back to `develop`. - ---- - -## 4. Quality Gates & Conventions - -* **Static Analysis**: mypy (no new type errors). -* **Style**: Black/Flake8/isort or Ruff; no lint violations. -* **Tests**: - - * Unit tests for new logic or refactor touch points. - * Integration tests for cross-layer behavior. -* **Commits**: Conventional commits (`feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `chore:` …). -* **Code Review**: PRs must be reviewed and approved; refactors must include rationale in the PR description (what changed, why safe). -* **Documentation**: Update `README`, `docs/`, and API docs when applicable. - ---- - - -By following this workflow—now with the **refactor** branch type—you keep feature development cleanly separated from codebase improvements, reduce merge friction, and maintain a predictable, high-quality delivery pipeline. diff --git a/docs/fastsim_vision.md b/docs/fastsim_vision.md deleted file mode 100644 index bf5b88a..0000000 --- a/docs/fastsim_vision.md +++ /dev/null @@ -1,41 +0,0 @@ -## 1 Why AsyncFlow? - -FastAPI + Uvicorn gives Python teams a lightning-fast async stack, yet sizing it for production still means guess-work, costly cloud load-tests or late surprises. **AsyncFlow** fills that gap by becoming a **digital twin** of your actual service: - -* It **replays** your FastAPI + Uvicorn event-loop behavior in SimPy, generating exactly the same kinds of asynchronous steps (parsing, CPU work, I/O, LLM calls) that happen in real code. -* It **models** your infrastructure primitives—CPU cores (via a SimPy `Resource`), database pools, rate-limiters, even GPU inference quotas—so you can see queue lengths, scheduling delays, resource utilization, and end-to-end latency. -* It **outputs** the very metrics you’d scrape in production (p50/p95/p99 latency, ready-queue lag, current & max concurrency, throughput, cost per LLM call), but entirely offline, in seconds. - -With AsyncFlow you can ask *“What happens if traffic doubles on Black Friday?”*, *“How many cores to keep p95 < 100 ms?”* or *“Is our LLM-driven endpoint ready for prime time?”*—and get quantitative answers **before** you deploy. - -**Outcome:** data-driven capacity planning, early performance tuning, and far fewer “surprises” once you hit production. - ---- - -## 2 Project Goals - -| # | Goal | Practical Outcome | -| - | ------------------------- | ------------------------------------------------------------------------ | -| 1 | **Pre-production sizing** | Know core-count, pool-size, replica-count to hit SLA. | -| 2 | **Scenario lab** | Explore traffic models, endpoint mixes, latency distributions, RTT, etc. | -| 3 | **Twin metrics** | Produce the same metrics you’ll scrape in prod (latency, queue, CPU). | -| 4 | **Rapid iteration** | One YAML/JSON config or REST call → full report. | -| 5 | **Educational value** | Visualise how GIL lag, queue length, concurrency react to load. | - ---- - -## 3 Who benefits & why (detailed) - -| Audience | Pain-point solved | AsyncFlow value | -| ------------------------------ | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Backend engineers** | Unsure if 4 vCPU container survives a marketing spike | Run *what-if* load, tweak CPU cores / pool size, get p95 & max-concurrency before merging. | -| **DevOps / SRE** | Guesswork in capacity planning; cost of over-provisioning | Simulate 1 → N replicas, autoscaler thresholds, DB-pool size; pick the cheapest config meeting SLA. | -| **ML / LLM product teams** | LLM inference cost & latency hard to predict | Model the LLM step with a price + latency distribution; estimate \$/req and GPU batch gains without real GPU. | -| **Educators / Trainers** | Students struggle to “see” event-loop internals | Visualise GIL ready-queue lag, CPU vs I/O steps, effect of blocking code—perfect for live demos and labs. | -| **Consultants / Architects** | Need a quick PoC of new designs for clients | Drop endpoint definitions in YAML and demo throughput / latency under projected load in minutes. | -| **Open-source community** | Lacks a lightweight Python simulator for ASGI workloads | Extensible codebase; easy to plug in new resources (rate-limit, cache) or traffic models (spike, uniform ramp). | -| **System-design interviewees** | Hard to quantify trade-offs in whiteboard interviews | Prototype real-time metrics—queue lengths, concurrency, latency distributions—to demonstrate in interviews how your design scales and where bottlenecks lie. | - ---- - -**Bottom-line:** AsyncFlow turns abstract architecture diagrams into concrete numbers—*before* spinning up expensive cloud environments—so you can build, validate and discuss your designs with full confidence. diff --git a/docs/pybuilder.md b/docs/guides/builder.md similarity index 52% rename from docs/pybuilder.md rename to docs/guides/builder.md index 19b7b24..e90fe22 100644 --- a/docs/pybuilder.md +++ b/docs/guides/builder.md @@ -1,17 +1,16 @@ -# AsyncFlow – Programmatic Input Guide (pybuilder) +# AsyncFlow – Programmatic Input Guide (builder) This guide shows how to **build the full simulation input in Python** using the -`AsyncFlow` builder (the “pybuilder”), with the same precision and validation -guarantees as the YAML flow. You’ll see **all components, valid values, units, -constraints, and how validation is enforced**. +`AsyncFlow` builder, with the same precision and validation guarantees as the YAML flow. +You’ll see **all components, valid values, units, constraints, and how validation is enforced**. Under the hood, the builder assembles a single `SimulationPayload`: ```python SimulationPayload( - rqs_input=RqsGeneratorInput(...), # traffic generator (workload) - topology_graph=TopologyGraph(...), # system architecture as a graph - sim_settings=SimulationSettings(...), # global settings and metrics + rqs_input=RqsGenerator(...), # traffic generator (workload) + topology_graph=TopologyGraph(...), # system architecture as a graph + sim_settings=SimulationSettings(...), # global settings and metrics ) ``` @@ -28,18 +27,15 @@ from __future__ import annotations import simpy -from asyncflow.pybuilder.input_builder import AsyncFlow -from asyncflow.runtime.simulation_runner import SimulationRunner -from asyncflow.schemas.full_simulation_input import SimulationPayload -from asyncflow.schemas.rqs_generator_input import RqsGeneratorInput -from asyncflow.schemas.simulation_settings_input import SimulationSettings -from asyncflow.schemas.system_topology.endpoint import Endpoint -from asyncflow.schemas.system_topology.full_system_topology import ( - Client, Edge, Server, +# Public, user-facing API +from asyncflow import AsyncFlow, SimulationRunner +from asyncflow.components import ( + RqsGenerator, SimulationSettings, Endpoint, Client, Server, Edge ) +from asyncflow.schemas.payload import SimulationPayload # optional, for typing # 1) Workload -generator = RqsGeneratorInput( +generator = RqsGenerator( id="rqs-1", avg_active_users={"mean": 50, "distribution": "poisson"}, avg_request_per_minute_per_user={"mean": 30, "distribution": "poisson"}, @@ -50,11 +46,10 @@ generator = RqsGeneratorInput( client = Client(id="client-1") endpoint = Endpoint( endpoint_name="/hello", - probability=1.0, # per-endpoint weight on this server steps=[ - {"kind": "ram", "step_operation": {"necessary_ram": 32}}, - {"kind": "initial_parsing", "step_operation": {"cpu_time": 0.002}}, - {"kind": "io_wait", "step_operation": {"io_waiting_time": 0.010}}, + {"kind": "ram", "step_operation": {"necessary_ram": 32}}, + {"kind": "initial_parsing", "step_operation": {"cpu_time": 0.002}}, + {"kind": "io_wait", "step_operation": {"io_waiting_time": 0.010}}, ], ) server = Server( @@ -103,8 +98,9 @@ payload: SimulationPayload = ( AsyncFlow() .add_generator(generator) .add_client(client) - .add_servers(server) - .add_edges(*edges) + .add_servers(server) # varargs; supports multiple + .add_edges(*edges) # varargs; supports multiple + # .add_load_balancer(lb) # optional .add_simulation_settings(settings) .build_payload() ) @@ -138,8 +134,7 @@ dictionary that Pydantic converts into an `RVConfig`: * `mean` is **required** and numeric; coerced to `float`. * If `distribution` is `"normal"` or `"log_normal"` and `variance` is absent, it defaults to **`variance = mean`**. -* For **edge latency** (see §3.3): **`mean > 0`** and, if provided, - **`variance ≥ 0`**. +* For **edge latency**: **`mean > 0`** and, if provided, **`variance ≥ 0`**. **Units** @@ -148,30 +143,30 @@ dictionary that Pydantic converts into an `RVConfig`: --- -## 2) Workload: `RqsGeneratorInput` +## 2) Workload: `RqsGenerator` ```python -from asyncflow.schemas.rqs_generator_input import RqsGeneratorInput +from asyncflow.components import RqsGenerator -generator = RqsGeneratorInput( +generator = RqsGenerator( id="rqs-1", avg_active_users={ "mean": 100, - "distribution": "poisson", # or "normal" + "distribution": "poisson", # or "normal" # "variance": , # optional; auto=mean if "normal" }, avg_request_per_minute_per_user={ "mean": 20, - "distribution": "poisson", # must be poisson in current samplers + "distribution": "poisson", # must be poisson in current samplers }, - user_sampling_window=60, # [1 .. 120] seconds + user_sampling_window=60, # [1 .. 120] seconds ) ``` **Semantics** -* `avg_active_users`: active users as a random variable (Poisson or Normal). -* `avg_request_per_minute_per_user`: per-user RPM (Poisson). +* `avg_active_users`: active users as a random variable (**Poisson** or **Normal**). +* `avg_request_per_minute_per_user`: per-user RPM (**Poisson** only). * `user_sampling_window`: re-sample active users every N seconds. --- @@ -184,7 +179,7 @@ LB) and edges (network links). ### 3.1 Client ```python -from asyncflow.schemas.system_topology.full_system_topology import Client +from asyncflow.components import Client client = Client(id="client-1") # type is fixed to 'client' ``` @@ -192,25 +187,23 @@ client = Client(id="client-1") # type is fixed to 'client' ### 3.2 Server & Endpoints ```python -from asyncflow.schemas.system_topology.endpoint import Endpoint -from asyncflow.schemas.system_topology.full_system_topology import Server +from asyncflow.components import Endpoint, Server endpoint = Endpoint( - endpoint_name="/api", # normalized to lowercase internally - probability=1.0, # endpoint selection weight within the server + endpoint_name="/api", # normalized to lowercase internally steps=[ - {"kind": "ram", "step_operation": {"necessary_ram": 64}}, + {"kind": "ram", "step_operation": {"necessary_ram": 64}}, {"kind": "cpu_bound_operation", "step_operation": {"cpu_time": 0.004}}, - {"kind": "io_db", "step_operation": {"io_waiting_time": 0.012}}, + {"kind": "io_db", "step_operation": {"io_waiting_time": 0.012}}, ], ) server = Server( - id="srv-1", # type fixed to 'server' + id="srv-1", # type fixed to 'server' server_resources={ "cpu_cores": 2, # int ≥ 1 "ram_mb": 2048, # int ≥ 256 - "db_connection_pool": None, # optional future-use + "db_connection_pool": None, # optional }, endpoints=[endpoint], ) @@ -237,13 +230,14 @@ server = Server( **Runtime semantics (high level)** * RAM is reserved before CPU, then released at the end. -* CPU tokens acquired lazily for consecutive CPU steps; released on I/O. +* CPU tokens are acquired for CPU-bound segments; released when switching to I/O. * I/O waits **do not** hold a CPU core. ### 3.3 Load Balancer (optional) ```python -from asyncflow.schemas.system_topology.full_system_topology import LoadBalancer +from asyncflow.schemas.topology.nodes import LoadBalancer # internal type +# (Use only if you build the graph manually. AsyncFlow builder hides the graph.) lb = LoadBalancer( id="lb-1", @@ -260,16 +254,15 @@ lb = LoadBalancer( ### 3.4 Edges ```python -from asyncflow.schemas.system_topology.full_system_topology import Edge +from asyncflow.components import Edge edge = Edge( id="client-to-srv1", source="client-1", # may be external only for sources target="srv-1", # MUST be a declared node latency={"mean": 0.003, "distribution": "exponential"}, - probability=1.0, # optional [0..1] - edge_type="network_connection", # current default/only - dropout_rate=0.01, # optional [0..1] + # edge_type defaults to "network_connection" + # dropout_rate defaults to 0.01 (0.0 .. 1.0) ) ``` @@ -278,17 +271,14 @@ edge = Edge( * `source`: can be an **external** ID for entry points (e.g., `"rqs-1"`). * `target`: **must** be a declared node (`client`, `server`, `load_balancer`). * `latency`: random variable; **`mean > 0`**, `variance ≥ 0` (if provided). -* `probability`: used when a node has multiple outgoing edges (fan-out). - If your code enforces “no fan-out except LB”, do **not** create multiple - outgoing edges from nodes other than the LB. -* `dropout_rate`: per-request/packet drop probability on this link. +* **Fan-out rule**: the model enforces **“no fan-out except LB”**—i.e., only the load balancer may have multiple outgoing edges. --- ## 4) Global Settings: `SimulationSettings` ```python -from asyncflow.schemas.simulation_settings_input import SimulationSettings +from asyncflow.components import SimulationSettings settings = SimulationSettings( total_simulation_time=600, # seconds, default 3600, min 5 @@ -316,16 +306,16 @@ settings = SimulationSettings( ## 5) Building the Payload with `AsyncFlow` ```python -from asyncflow.pybuilder.input_builder import AsyncFlow -from asyncflow.schemas.full_simulation_input import SimulationPayload +from asyncflow import AsyncFlow +from asyncflow.schemas.payload import SimulationPayload # optional typing flow = ( AsyncFlow() .add_generator(generator) .add_client(client) - .add_servers(server) # varargs; supports multiple - .add_edges(*edges) # varargs; supports multiple - # .add_load_balancer(lb) # optional + .add_servers(server) # varargs + .add_edges(*edges) # varargs + # .add_load_balancer(lb) .add_simulation_settings(settings) ) @@ -337,10 +327,9 @@ payload: SimulationPayload = flow.build_payload() 1. **Presence**: generator, client, ≥1 server, ≥1 edge, settings. 2. **Unique IDs**: servers and edges have unique IDs. 3. **Node types**: fixed enums: `client`, `server`, `load_balancer`. -4. **Edge integrity**: every target is a declared node; external IDs allowed only as sources; no self-loops (`source != target`). -5. **Load balancer sanity**: `server_covered ⊆ declared_servers` and there is an edge from the LB to **each** covered server. -6. **(Optional in your codebase)** “No fan-out except LB” validator: multiple - outgoing edges only allowed for the LB. +4. **Edge integrity**: every target is a declared node; external IDs allowed only as sources; no self-loops. +5. **Load balancer sanity**: `server_covered ⊆ declared_servers` **and** there is an edge from the LB to **each** covered server. +6. **No fan-out except LB**: only the LB may have multiple outgoing edges. If any rule is violated, a **descriptive `ValueError`** pinpoints the problem. @@ -350,7 +339,7 @@ If any rule is violated, a **descriptive `ValueError`** pinpoints the problem. ```python import simpy -from asyncflow.runtime.simulation_runner import SimulationRunner +from asyncflow import SimulationRunner env = simpy.Environment() runner = SimulationRunner(env=env, simulation_input=payload) @@ -372,64 +361,15 @@ results.plot_throughput(axes[0, 1]) results.plot_server_queues(axes[1, 0]) results.plot_ram_usage(axes[1, 1]) fig.tight_layout() -fig.savefig("single_server_pybuilder.png") +fig.savefig("single_server_builder.png") ``` --- -## 7) Builder vs YAML: Field Mapping - -| YAML path | Builder (Python) | -| --------------------------------------------- | ---------------------------------------------------------------- | -| `rqs_input.id` | `RqsGeneratorInput(id=...)` | -| `rqs_input.avg_active_users.*` | `RqsGeneratorInput(avg_active_users={...})` | -| `rqs_input.avg_request_per_minute_per_user.*` | `RqsGeneratorInput(avg_request_per_minute_per_user={...})` | -| `rqs_input.user_sampling_window` | `RqsGeneratorInput(user_sampling_window=...)` | -| `topology_graph.nodes.client.id` | `Client(id=...)` | -| `topology_graph.nodes.servers[*]` | `Server(id=..., server_resources={...}, endpoints=[...])` | -| `endpoint.endpoint_name` | `Endpoint(endpoint_name=...)` | -| `endpoint.steps[*]` | `Endpoint(steps=[{"kind": "...","step_operation": {...}}, ...])` | -| `topology_graph.nodes.load_balancer.*` | `LoadBalancer(id=..., algorithms=..., server_covered={...})` | -| `topology_graph.edges[*]` | `Edge(id=..., source=..., target=..., latency={...}, ...)` | -| `sim_settings.*` | `SimulationSettings(...)` | +## 7) Enums, Units & Conventions (Cheat Sheet) ---- - -## 8) Common Pitfalls & How to Avoid Them - -* **Mismatched step operations** - A CPU step must use `cpu_time`; an I/O step must use `io_waiting_time`; a RAM - step must use `necessary_ram`. Exactly **one** key per step. - -* **Edge target must be a declared node** - `source` can be external (e.g., `"rqs-1"`), but **no external ID** may ever - appear as a `target`. - -* **Load balancer coverage without edges** - If the LB covers `[srv-1, srv-2]`, you **must** add edges `lb→srv-1` and - `lb→srv-2`. - -* **Latency RV rules on edges** - `mean` must be **> 0**; if `variance` is present, it must be **≥ 0**. - -* **Fan-out rules** - If your codebase enforces “no fan-out except LB”, do not create multiple - outgoing edges from non-LB nodes. If you do allow it, set `probability` - weights so outgoing probabilities per source sum to \~1.0 (or ensure a single - edge per source). - -* **Sampling too coarse** - Large `sample_period_s` may miss short spikes. Lower it to capture bursts - (at the cost of larger time series). - ---- - -## 9) Enums, Units & Conventions (Cheat Sheet) - -* **Distributions**: `"poisson"`, `"normal"`, `"log_normal"`, `"exponential"`, - `"uniform"` -* **Node types**: fixed internally to: `generator`, `server`, `client`, - `load_balancer` +* **Distributions**: `"poisson"`, `"normal"`, `"log_normal"`, `"exponential"`, `"uniform"` +* **Node types**: fixed internally to `generator`, `server`, `client`, `load_balancer` * **Edge type**: `network_connection` * **LB algorithms**: `"round_robin"`, `"least_connection"` * **Step kinds** @@ -437,22 +377,14 @@ fig.savefig("single_server_pybuilder.png") RAM: `"ram"` I/O: `"io_task_spawn"`, `"io_llm"`, `"io_wait"`, `"io_db"`, `"io_cache"` * **Step operation keys**: `cpu_time`, `io_waiting_time`, `necessary_ram` -* **Sampled metrics**: `ready_queue_len`, `event_loop_io_sleep`, `ram_in_use`, - `edge_concurrent_connection` +* **Sampled metrics**: `ready_queue_len`, `event_loop_io_sleep`, `ram_in_use`, `edge_concurrent_connection` * **Event metrics**: `rqs_clock` (and `llm_cost` reserved for future use) **Units & ranges** -* **Time**: seconds (`cpu_time`, `io_waiting_time`, edge latency means/variance, - `total_simulation_time`, `sample_period_s`, `user_sampling_window`) +* **Time**: seconds (`cpu_time`, `io_waiting_time`, latencies, `total_simulation_time`, `sample_period_s`, `user_sampling_window`) * **RAM**: megabytes (`ram_mb`, `necessary_ram`) * **Rates**: requests/minute (`avg_request_per_minute_per_user.mean`) -* **Probabilities**: `[0.0, 1.0]` (`probability`, `dropout_rate`) -* **Bounds**: `total_simulation_time ≥ 5`, `sample_period_s ∈ [0.001, 0.1]`, - `cpu_cores ≥ 1`, `ram_mb ≥ 256`, numeric step values > 0 - ---- +* **Probabilities**: `[0.0, 1.0]` (`dropout_rate`) +* **Bounds**: `total_simulation_time ≥ 5`, `sample_period_s ∈ [0.001, 0.1]`, `cpu_cores ≥ 1`, `ram_mb ≥ 256`, numeric step values > 0 -With these patterns, you can build any topology that the YAML supports—**fully -programmatically**, with the same strong validation and clear errors on invalid -configurations. diff --git a/docs/yaml_builder.md b/docs/guides/yaml-builder.md similarity index 85% rename from docs/yaml_builder.md rename to docs/guides/yaml-builder.md index f3d22e3..113b879 100644 --- a/docs/yaml_builder.md +++ b/docs/guides/yaml-builder.md @@ -64,14 +64,12 @@ rqs_input: ### Semantics * **`avg_active_users`**: expected concurrent users (a random variable). - - * Allowed distributions: **Poisson** or **Normal**. + Allowed distributions: **Poisson** or **Normal**. * **`avg_request_per_minute_per_user`**: per-user request rate (RPM). - - * Must be **Poisson**.\* + Must be **Poisson**.\* * **`user_sampling_window`**: every N seconds the generator re-samples the active user count. -\* This reflects current sampler support (Poisson–Poisson and Gaussian–Poisson). +\* Current joint-sampler support covers Poisson–Poisson and Gaussian–Poisson. --- @@ -103,7 +101,6 @@ topology_graph: source: target: # must be a declared node latency: { mean: , distribution: , variance: } - probability: <0..1> # default 1.0 edge_type: network_connection # (enum; current default/only) dropout_rate: <0..1> # default 0.01 ``` @@ -137,7 +134,7 @@ client: * `cpu_cores`: number of worker “core tokens” available for CPU-bound step execution. * `ram_mb`: total available RAM (MB) tracked as a reservoir; steps reserve then release. -* `db_connection_pool`: optional capacity bound for DB-like steps (future-use; declared here for forward compatibility). +* `db_connection_pool`: optional capacity bound for DB-like steps (future-use). #### Load Balancer (optional) @@ -171,11 +168,11 @@ Each step must declare **exactly one** operation (`step_operation`) whose key ma **I/O-bound** (all use `io_waiting_time` as the operation key) -* `io_task_spawn` (spawns a background task, returns immediately) -* `io_llm` (LLM inference call) -* `io_wait` (generic wait, non-blocking) -* `io_db` (DB roundtrip) -* `io_cache` (cache access) +* `io_task_spawn` (spawns a background task, returns immediately) +* `io_llm` (LLM inference call) +* `io_wait` (generic wait, non-blocking) +* `io_db` (DB roundtrip) +* `io_cache` (cache access) #### Operation keys (enum `StepOperation`) @@ -223,7 +220,6 @@ endpoints: mean: 0.003 distribution: exponential # variance optional; if normal/log_normal and missing → set to mean - probability: 1.0 # optional [0..1] edge_type: network_connection dropout_rate: 0.01 # optional [0..1] ``` @@ -233,8 +229,7 @@ endpoints: * **`source`** can be an external entry point (e.g., `rqs-1`) for inbound edges. * **`target`** must always reference a declared node: client, server, or LB. * **`latency`** is a random variable; **`mean > 0`**, **`variance ≥ 0`** (if provided). -* **`probability`** is used when multiple outgoing edges exist from a node. -* **`dropout_rate`** models probabilistic packet/request loss on the link. +* **Fan-out rule**: only the **load balancer** may have multiple outgoing edges. --- @@ -270,21 +265,29 @@ AsyncFlow validates the entire payload. Key checks include: * All server IDs are unique. * Edge IDs are unique. + 2. **Node Types** * `type` fields on nodes are fixed to: `client`, `server`, `load_balancer`. + 3. **Edge referential integrity** * Every **target** is a declared node ID. * **External IDs** are allowed **only** as **sources**. If an ID appears as an external source, it must **never** appear as a target anywhere. + 4. **No self-loops** * `source != target` for every edge. + 5. **Load balancer sanity** * `server_covered` is a subset of declared servers. * There is an **edge from the LB to every covered server**. +6. **No fan-out except LB** + + * Only the load balancer may have multiple outgoing edges in the declared node set. + If any rule is violated, the simulator raises a descriptive error. --- @@ -384,9 +387,9 @@ topology_graph: latency: { mean: 0.002, distribution: exponential } } - { id: lb-srv1, source: lb-1, target: srv-1, - latency: { mean: 0.002, distribution: exponential }, probability: 0.5 } + latency: { mean: 0.002, distribution: exponential } } - { id: lb-srv2, source: lb-1, target: srv-2, - latency: { mean: 0.002, distribution: exponential }, probability: 0.5 } + latency: { mean: 0.002, distribution: exponential } } - { id: srv1-client, source: srv-1, target: client-1, latency: { mean: 0.003, distribution: exponential } } @@ -400,26 +403,7 @@ sim_settings: enabled_event_metrics: [ rqs_clock ] ``` -## 7) Common Pitfalls & How to Avoid Them - -* **Mismatched step operations** - A CPU step must use `cpu_time`; an I/O step must use `io_waiting_time`; a RAM step must use `necessary_ram`. The validator enforces **exactly one** key. - -* **Edge targets must be declared nodes** - `source` can be external (e.g., `rqs-1`), but **no external ID** may ever appear as a **target**. - -* **Load balancer coverage without edges** - If the LB declares `server_covered: [srv-1, srv-2]`, you must also add edges `lb→srv-1` and `lb→srv-2`. - -* **Latency RV rules on edges** - For edge latency, `mean` must be **> 0**; if `variance` is present, it must be **≥ 0**. - -* **Sampling too coarse** - If `sample_period_s` is large, short spikes in queues may be missed. Lower it (e.g., `0.005`) to capture fine-grained bursts—at the cost of larger time-series. - ---- - -## 8) Quick Reference (Enums) +## 7) Quick Reference (Enums) * **Distributions**: `poisson`, `normal`, `log_normal`, `exponential`, `uniform` * **Node types**: `generator`, `server`, `client`, `load_balancer` (fixed by model) @@ -435,14 +419,13 @@ sim_settings: --- -## 9) Units & Conventions +## 8) Units & Conventions * **Time**: seconds (`cpu_time`, `io_waiting_time`, latencies, `total_simulation_time`, `sample_period_s`, `user_sampling_window`) * **RAM**: megabytes (`ram_mb`, `necessary_ram`) * **Rates**: requests/minute (`avg_request_per_minute_per_user.mean`) -* **Probabilities**: `[0.0, 1.0]` (`probability`, `dropout_rate`) +* **Probabilities**: `[0.0, 1.0]` (`dropout_rate`) * **IDs**: strings; must be **unique** per category (servers, edges, LB). --- -If you stick to these rules and examples, your YAML will parse cleanly and the simulation will run with a self-consistent, strongly-validated model. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..651a0ee --- /dev/null +++ b/docs/index.md @@ -0,0 +1,57 @@ +Here’s an updated, list-style `index.md` (in English) **without any Tutorials section** and with a clear pointer to the math details in the workload samplers. + +--- + +# AsyncFlow Documentation + +AsyncFlow is a discrete-event simulator for Python async backends (FastAPI/Uvicorn–style). It builds a **digital twin** of your service—traffic, topology, and resources—so you can measure latency, throughput, queueing, RAM, and more **before** you deploy. + +> ⚠️ The package README with `pip install` & a Quickstart will land after the first PyPI release. + +--- + + +## Public API (stable surface) + +* **[High-Level API](api/high-level.md)** — The two entry points you’ll use most: `AsyncFlow` (builder) and `SimulationRunner` (orchestrator). +* **[Components](api/components.md)** — Public Pydantic models for topology: `Client`, `Server`, `Endpoint`, `Edge`, `LoadBalancer`, `ServerResources`. +* **[Workload](api/workload.md)** — Traffic inputs: `RqsGenerator` and `RVConfig` (random variables). +* **[Settings](api/settings.md)** — Global controls: `SimulationSettings` (duration, sampling cadence, metrics). +* **[Enums](api/enums.md)** — Optional importable enums: distributions, step kinds/ops, metric names, node/edge types, LB algorithms. + +--- + +## How-to Guides + +* **[Builder Guide](guides/builder.md)** — Programmatically assemble a `SimulationPayload` in Python with validation and examples. +* **[YAML Input Guide](guides/yaml-builder.md)** — Author scenarios in YAML: exact schema, units, constraints, runnable samples. + +--- + +## Internals (design & rationale) + +> Prefer formal underpinnings? The **Workload Samplers** section includes mathematical details (compound Poisson–Poisson and Normal–Poisson processes, inverse-CDF gaps, truncated Gaussians). + +* **[Simulation Input (contract)](internals/simulation-input.md)** — The complete `SimulationPayload` schema and all validation guarantees (graph integrity, step coherence, etc.). +* **[Simulation Runner](internals/simulation-runner.md)** — Orchestrator design; build → wire → start → run flow; sequence diagrams; extensibility hooks. +* **[Runtime & Resources](internals/runtime-and-resources.md)** — How CPU/RAM/DB are modeled with SimPy containers; decoupling of runtime logic and resources. +* **Metrics** + + * **[Time-Series Architecture](internals/metrics/time-series-architecture.md)** — Registry → runtime state → collector pipeline; why the `if key in …` guard keeps extensibility with zero hot-path cost. +* **[Workload Samplers (math)](internals/workload-samplers.md)** — Formalization of traffic generators: windowed user resampling, rate construction $\Lambda = U \cdot \text{RPM}/60$, exponential inter-arrival via inverse-CDF, latency RV constraints. + +--- + +## Useful mental model + +Every run boils down to this validated input: + +```python +SimulationPayload( + rqs_input=RqsGenerator(...), # workload + topology_graph=TopologyGraph(...), # nodes & edges + sim_settings=SimulationSettings(...), +) +``` + +Build it (via **Builder** or **YAML**) and hand it to `SimulationRunner` to execute and analyze. diff --git a/docs/fastsim-docs/metrics_to_measure.md b/docs/internals/metrics/overview.md similarity index 100% rename from docs/fastsim-docs/metrics_to_measure.md rename to docs/internals/metrics/overview.md diff --git a/docs/fastsim-docs/time_series_metric_architecture.md b/docs/internals/metrics/time-series-architecture.md similarity index 100% rename from docs/fastsim-docs/time_series_metric_architecture.md rename to docs/internals/metrics/time-series-architecture.md diff --git a/docs/fastsim-docs/runtime_and_resources.md b/docs/internals/runtime-and-resources.md similarity index 100% rename from docs/fastsim-docs/runtime_and_resources.md rename to docs/internals/runtime-and-resources.md diff --git a/docs/fastsim-docs/simulation_input.md b/docs/internals/simulation-input.md similarity index 98% rename from docs/fastsim-docs/simulation_input.md rename to docs/internals/simulation-input.md index d5d860c..f6bbbcd 100644 --- a/docs/fastsim-docs/simulation_input.md +++ b/docs/internals/simulation-input.md @@ -7,7 +7,7 @@ The entry point is: ```python class SimulationPayload(BaseModel): """Full input structure to perform a simulation""" - rqs_input: RqsGeneratorInput + rqs_input: RqsGenerator topology_graph: TopologyGraph sim_settings: SimulationSettings ``` @@ -43,12 +43,12 @@ Everything the engine needs is captured by these three components: --- -## 1) Workload: `RqsGeneratorInput` +## 1) Workload: `RqsGenerator` **Purpose:** Defines the stochastic traffic generator that produces request arrivals. ```python -class RqsGeneratorInput(BaseModel): +class RqsGenerator(BaseModel): id: str type: SystemNodes = SystemNodes.GENERATOR avg_active_users: RVConfig @@ -279,7 +279,7 @@ class SimulationSettings(BaseModel): ## 5) Validation Checklist (What is guaranteed if the payload parses) -### Workload (`RqsGeneratorInput`, `RVConfig`) +### Workload (`RqsGenerator`, `RVConfig`) * `mean` is numeric (`int|float`) and coerced to `float`. * If `distribution ∈ {NORMAL, LOG_NORMAL}` and `variance is None` → `variance := mean`. diff --git a/docs/fastsim-docs/simulation_runner.md b/docs/internals/simulation-runner.md similarity index 100% rename from docs/fastsim-docs/simulation_runner.md rename to docs/internals/simulation-runner.md diff --git a/docs/fastsim-docs/requests_generator.md b/docs/internals/workload-samplers.md similarity index 99% rename from docs/fastsim-docs/requests_generator.md rename to docs/internals/workload-samplers.md index eab8080..eb40121 100644 --- a/docs/fastsim-docs/requests_generator.md +++ b/docs/internals/workload-samplers.md @@ -104,7 +104,7 @@ class RVConfig(BaseModel): return model -class RqsGeneratorInput(BaseModel): +class RqsGenerator(BaseModel): """Define the expected variables for the simulation""" id: str diff --git a/docs/why-asyncflow.md b/docs/why-asyncflow.md new file mode 100644 index 0000000..d50d401 --- /dev/null +++ b/docs/why-asyncflow.md @@ -0,0 +1,93 @@ +# Why AsyncFlow + +> **TL;DR**: AsyncFlow is a *digital twin* of your FastAPI/Uvicorn service. It simulates traffic, async steps, and resource limits in seconds—so you can size CPU/pools/replicas and hit your latency SLOs **before** touching the cloud. + +## What it is + +* **Event-loop faithful**: Replays FastAPI-style async behavior in SimPy (parsing, CPU-bound work, I/O waits, LLM calls). +* **Resource-aware**: Models CPU cores (tokens), RAM, DB pools, and routing so you see queueing, contention, and scheduling delays. +* **Prod-style metrics**: Emits p50/p95/p99 latency, throughput, ready-queue lag, concurrency per edge/server—even estimated LLM cost. + +## What you get + +* **Numbers you can plan with**: p95, max concurrency, queue lengths, RAM usage, RPS over time. +* **Rapid “what-if” loops**: Double traffic, change cores/pools, add a replica—see the impact immediately. +* **Cheap, offline iteration**: Results in seconds, no clusters, no load-test bills. + +## 10-second example + +**Minimal scenario (YAML)** + +```yaml +# examples/data/minimal.yml +rqs_input: + id: rqs-1 + avg_active_users: { mean: 50 } # Poisson by default + avg_request_per_minute_per_user: { mean: 20 } # must be Poisson + user_sampling_window: 60 + +topology_graph: + nodes: + client: { id: client-1 } + servers: + - id: srv-1 + server_resources: { cpu_cores: 2, ram_mb: 2048 } + endpoints: + - endpoint_name: /predict + steps: + - kind: initial_parsing + step_operation: { cpu_time: 0.002 } + - kind: io_wait + step_operation: { io_waiting_time: 0.010 } + edges: + - { id: gen-client, source: rqs-1, target: client-1, + latency: { mean: 0.003, distribution: exponential } } + - { id: client-srv, source: client-1, target: srv-1, + latency: { mean: 0.003, distribution: exponential } } + - { id: srv-client, source: srv-1, target: client-1, + latency: { mean: 0.003, distribution: exponential } } + +sim_settings: + total_simulation_time: 300 + sample_period_s: 0.01 + enabled_sample_metrics: [ ready_queue_len, ram_in_use, edge_concurrent_connection ] + enabled_event_metrics: [ rqs_clock ] +``` + +**Run it (Python)** + +```python +from pathlib import Path +import simpy +from asyncflow.runtime.simulation_runner import SimulationRunner + +env = simpy.Environment() +runner = SimulationRunner.from_yaml(env=env, yaml_path=Path("examples/data/minimal.yml")) +results = runner.run() + +print(results.get_latency_stats()) # p50/p95/p99, etc. +print(results.get_throughput_series()) # (timestamps, rps) +``` + +## The mental model + +```mermaid +flowchart LR + RQS[generator] --> C[client] + C --> S[srv-1] + S --> C +``` + +* Each arrow is a **network edge** with its own latency RV. +* Server endpoints are **linear step chains**: CPU → RAM → I/O, etc. +* CPU/DB/RAM are **capacity-limited resources** → queues form under load. + +## Non-goals (by design) + +* Not a replacement for **production** load tests or packet-level network simulators. +* Not a micro-profiler; it models service times and queues, not byte-level protocol details. +* Not an auto-tuner—**you** iterate quickly with data to choose the best configuration. + +--- + +**Bottom line:** AsyncFlow turns your architecture diagram into hard numbers—p95, concurrency, queue lengths—so you can plan capacity, de-risk launches, and explain trade-offs with evidence, not guesswork. diff --git a/examples/single_server_builder.png b/examples/single_server_builder.png new file mode 100644 index 0000000..2a4d21e Binary files /dev/null and b/examples/single_server_builder.png differ diff --git a/examples/single_server_pybuilder.png b/examples/single_server_pybuilder.png index 575c50b..20166b0 100644 Binary files a/examples/single_server_pybuilder.png and b/examples/single_server_pybuilder.png differ diff --git a/examples/single_server_pybuilder.py b/examples/single_server_pybuilder.py index 223766f..cd92121 100644 --- a/examples/single_server_pybuilder.py +++ b/examples/single_server_pybuilder.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Didactic example: build and run a AsyncFlow scenario **without** YAML, -using the 'pybuilder' (AsyncFlow) to assemble the SimulationPayload. +using the 'builder' (AsyncFlow) to assemble the SimulationPayload. Scenario reproduced (same as the previous YAML): generator ──edge──> client ──edge──> server ──edge──> client @@ -24,7 +24,7 @@ 5) (Optional) Visualize the topology with Matplotlib. Run: - python run_with_pybuilder.py + python run_with_builder.py """ from __future__ import annotations @@ -36,18 +36,18 @@ import simpy # ── AsyncFlow domain imports ─────────────────────────────────────────────────── -from asyncflow.pybuilder.input_builder import AsyncFlow +from asyncflow.builder.asyncflow_builder import AsyncFlow from asyncflow.runtime.simulation_runner import SimulationRunner from asyncflow.metrics.analyzer import ResultsAnalyzer -from asyncflow.schemas.full_simulation_input import SimulationPayload -from asyncflow.schemas.rqs_generator_input import RqsGeneratorInput -from asyncflow.schemas.simulation_settings_input import SimulationSettings -from asyncflow.schemas.system_topology.endpoint import Endpoint -from asyncflow.schemas.system_topology.full_system_topology import ( +from asyncflow.schemas.payload import SimulationPayload +from asyncflow.schemas.workload.rqs_generator import RqsGenerator +from asyncflow.schemas.settings.simulation import SimulationSettings +from asyncflow.schemas.topology.endpoint import Endpoint +from asyncflow.schemas.topology.nodes import ( Client, - Edge, Server, ) +from asyncflow.schemas.topology.edges import Edge from asyncflow.config.constants import LatencyKey, SampledMetricName @@ -160,9 +160,9 @@ def run_sanity_checks( # ───────────────────────────────────────────────────────────── -# Build the same scenario via AsyncFlow (pybuilder) +# Build the same scenario via AsyncFlow (builder) # ───────────────────────────────────────────────────────────── -def build_payload_with_pybuilder() -> SimulationPayload: +def build_payload_with_builder() -> SimulationPayload: """ Construct the SimulationPayload programmatically using the builder. @@ -174,7 +174,7 @@ def build_payload_with_pybuilder() -> SimulationPayload: - Simulation settings: 500s total, sample period 50ms """ # 1) Request generator - generator = RqsGeneratorInput( + generator = RqsGenerator( id="rqs-1", avg_active_users={"mean": 100}, avg_request_per_minute_per_user={"mean": 20}, @@ -255,11 +255,11 @@ def build_payload_with_pybuilder() -> SimulationPayload: def main() -> None: """ Build → wire → run the simulation, then print diagnostics. - Mirrors run_from_yaml.py but uses the pybuilder to construct the input. + Mirrors run_from_yaml.py but uses the builder to construct the input. Also saves a 2x2 plot figure (latency, throughput, server queues, RAM). """ env = simpy.Environment() - payload = build_payload_with_pybuilder() + payload = build_payload_with_builder() runner = SimulationRunner(env=env, simulation_input=payload) results: ResultsAnalyzer = runner.run() @@ -283,7 +283,7 @@ def main() -> None: results.plot_ram_usage(axes[1, 1]) fig.tight_layout() - out_path = Path(__file__).parent / "single_server_pybuilder.png" + out_path = Path(__file__).parent / "single_server_builder.png" fig.savefig(out_path) print(f"\n🖼️ Plots saved to: {out_path}") except Exception as exc: # Matplotlib not installed or plotting failed diff --git a/examples/single_server_yml.png b/examples/single_server_yml.png index 560a4b3..76c2115 100644 Binary files a/examples/single_server_yml.png and b/examples/single_server_yml.png differ diff --git a/src/asyncflow/__init__.py b/src/asyncflow/__init__.py index 0f69098..5bdbdea 100644 --- a/src/asyncflow/__init__.py +++ b/src/asyncflow/__init__.py @@ -1 +1,7 @@ -"""Main application package for the project backend.""" +"""Public facade for high-level API.""" +from __future__ import annotations + +from asyncflow.builder.asyncflow_builder import AsyncFlow +from asyncflow.runtime.simulation_runner import SimulationRunner + +__all__ = ["AsyncFlow", "SimulationRunner"] diff --git a/src/asyncflow/pybuilder/input_builder.py b/src/asyncflow/builder/asyncflow_builder.py similarity index 87% rename from src/asyncflow/pybuilder/input_builder.py rename to src/asyncflow/builder/asyncflow_builder.py index 7d3c26f..f6d2cea 100644 --- a/src/asyncflow/pybuilder/input_builder.py +++ b/src/asyncflow/builder/asyncflow_builder.py @@ -4,17 +4,17 @@ from typing import Self -from asyncflow.schemas.full_simulation_input import SimulationPayload -from asyncflow.schemas.rqs_generator_input import RqsGeneratorInput -from asyncflow.schemas.simulation_settings_input import SimulationSettings -from asyncflow.schemas.system_topology.full_system_topology import ( +from asyncflow.schemas.payload import SimulationPayload +from asyncflow.schemas.settings.simulation import SimulationSettings +from asyncflow.schemas.topology.edges import Edge +from asyncflow.schemas.topology.graph import TopologyGraph +from asyncflow.schemas.topology.nodes import ( Client, - Edge, LoadBalancer, Server, - TopologyGraph, TopologyNodes, ) +from asyncflow.schemas.workload.rqs_generator import RqsGenerator class AsyncFlow: @@ -22,17 +22,17 @@ class AsyncFlow: def __init__(self) -> None: """Instance attributes necessary to define the simulation payload""" - self._generator: RqsGeneratorInput | None = None + self._generator: RqsGenerator | None = None self._client: Client | None = None self._servers: list[Server] | None = None self._edges: list[Edge] | None = None self._sim_settings: SimulationSettings | None = None self._load_balancer: LoadBalancer | None = None - def add_generator(self, rqs_generator: RqsGeneratorInput) -> Self: + def add_generator(self, rqs_generator: RqsGenerator) -> Self: """Method to instantiate the generator""" - if not isinstance(rqs_generator, RqsGeneratorInput): - msg = "You must add a RqsGeneratorInput instance" + if not isinstance(rqs_generator, RqsGenerator): + msg = "You must add a RqsGenerator instance" raise TypeError(msg) self._generator = rqs_generator return self diff --git a/src/asyncflow/components/__init__.py b/src/asyncflow/components/__init__.py new file mode 100644 index 0000000..774a77f --- /dev/null +++ b/src/asyncflow/components/__init__.py @@ -0,0 +1,15 @@ +"""Public components: re-exports Pydantic leaf schemas (topology).""" +from __future__ import annotations + +from asyncflow.schemas.topology.edges import Edge +from asyncflow.schemas.topology.endpoint import Endpoint +from asyncflow.schemas.topology.nodes import ( + Client, + LoadBalancer, + Server, + ServerResources, +) + +__all__ = ["Client", "Edge", "Endpoint", "LoadBalancer", "Server", "ServerResources"] + + diff --git a/src/asyncflow/config/__init__.py b/src/asyncflow/config/__init__.py deleted file mode 100644 index 255bf0d..0000000 --- a/src/asyncflow/config/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Configuration modules and settings.""" diff --git a/src/asyncflow/enums/__init__.py b/src/asyncflow/enums/__init__.py new file mode 100644 index 0000000..a07a18f --- /dev/null +++ b/src/asyncflow/enums/__init__.py @@ -0,0 +1,23 @@ +"""Public enums used in scenario definitions.""" + +from asyncflow.config.constants import ( + Distribution, + EndpointStepCPU, + EndpointStepIO, + EndpointStepRAM, + EventMetricName, + LbAlgorithmsName, + SampledMetricName, + StepOperation, +) + +__all__ = [ + "Distribution", + "EndpointStepCPU", + "EndpointStepIO", + "EndpointStepRAM", + "EventMetricName", + "LbAlgorithmsName", + "SampledMetricName", + "StepOperation", +] diff --git a/src/asyncflow/metrics/analyzer.py b/src/asyncflow/metrics/analyzer.py index 4db2ee1..35b43bb 100644 --- a/src/asyncflow/metrics/analyzer.py +++ b/src/asyncflow/metrics/analyzer.py @@ -26,7 +26,7 @@ from asyncflow.runtime.actors.client import ClientRuntime from asyncflow.runtime.actors.edge import EdgeRuntime from asyncflow.runtime.actors.server import ServerRuntime - from asyncflow.schemas.simulation_settings_input import SimulationSettings + from asyncflow.schemas.settings.simulation import SimulationSettings class ResultsAnalyzer: diff --git a/src/asyncflow/metrics/collector.py b/src/asyncflow/metrics/collector.py index 97421b7..38c2f0d 100644 --- a/src/asyncflow/metrics/collector.py +++ b/src/asyncflow/metrics/collector.py @@ -7,7 +7,7 @@ from asyncflow.config.constants import SampledMetricName from asyncflow.runtime.actors.edge import EdgeRuntime from asyncflow.runtime.actors.server import ServerRuntime -from asyncflow.schemas.simulation_settings_input import SimulationSettings +from asyncflow.schemas.settings.simulation import SimulationSettings # The idea for this class is to gather list of runtime objects that # are defined in the central class to build the simulation, in this diff --git a/src/asyncflow/resources/__init__.py b/src/asyncflow/resources/__init__.py deleted file mode 100644 index 69884c1..0000000 --- a/src/asyncflow/resources/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""python package for resource registry""" diff --git a/src/asyncflow/resources/registry.py b/src/asyncflow/resources/registry.py index 6e1e3be..26576ba 100644 --- a/src/asyncflow/resources/registry.py +++ b/src/asyncflow/resources/registry.py @@ -10,7 +10,7 @@ import simpy from asyncflow.resources.server_containers import ServerContainers, build_containers -from asyncflow.schemas.system_topology.full_system_topology import TopologyGraph +from asyncflow.schemas.topology.graph import TopologyGraph class ResourcesRuntime: diff --git a/src/asyncflow/resources/server_containers.py b/src/asyncflow/resources/server_containers.py index ca054c2..1401247 100644 --- a/src/asyncflow/resources/server_containers.py +++ b/src/asyncflow/resources/server_containers.py @@ -12,9 +12,7 @@ import simpy from asyncflow.config.constants import ServerResourceName -from asyncflow.schemas.system_topology.full_system_topology import ( - ServerResources, -) +from asyncflow.schemas.topology.nodes import ServerResources # ============================================================== # DICT FOR THE REGISTRY TO INITIALIZE RESOURCES FOR EACH SERVER diff --git a/src/asyncflow/runtime/__init__.py b/src/asyncflow/runtime/__init__.py deleted file mode 100644 index fdc562a..0000000 --- a/src/asyncflow/runtime/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""module for the runtime folder""" diff --git a/src/asyncflow/runtime/actors/client.py b/src/asyncflow/runtime/actors/client.py index 58ba7e9..6c752f1 100644 --- a/src/asyncflow/runtime/actors/client.py +++ b/src/asyncflow/runtime/actors/client.py @@ -8,7 +8,7 @@ from asyncflow.config.constants import SystemNodes from asyncflow.metrics.client import RqsClock from asyncflow.runtime.actors.edge import EdgeRuntime -from asyncflow.schemas.system_topology.full_system_topology import Client +from asyncflow.schemas.topology.nodes import Client if TYPE_CHECKING: from asyncflow.runtime.rqs_state import RequestState diff --git a/src/asyncflow/runtime/actors/edge.py b/src/asyncflow/runtime/actors/edge.py index c2c5328..ee2131d 100644 --- a/src/asyncflow/runtime/actors/edge.py +++ b/src/asyncflow/runtime/actors/edge.py @@ -16,11 +16,11 @@ from asyncflow.metrics.edge import build_edge_metrics from asyncflow.runtime.rqs_state import RequestState from asyncflow.samplers.common_helpers import general_sampler -from asyncflow.schemas.simulation_settings_input import SimulationSettings -from asyncflow.schemas.system_topology.full_system_topology import Edge +from asyncflow.schemas.settings.simulation import SimulationSettings +from asyncflow.schemas.topology.edges import Edge if TYPE_CHECKING: - from asyncflow.schemas.random_variables_config import RVConfig + from asyncflow.schemas.common.random_variables import RVConfig diff --git a/src/asyncflow/runtime/actors/load_balancer.py b/src/asyncflow/runtime/actors/load_balancer.py index fac3f66..498fb18 100644 --- a/src/asyncflow/runtime/actors/load_balancer.py +++ b/src/asyncflow/runtime/actors/load_balancer.py @@ -7,11 +7,11 @@ from asyncflow.config.constants import LbAlgorithmsName, SystemNodes from asyncflow.runtime.actors.edge import EdgeRuntime -from asyncflow.runtime.actors.helpers.lb_algorithms import ( +from asyncflow.runtime.actors.routing.lb_algorithms import ( least_connections, round_robin, ) -from asyncflow.schemas.system_topology.full_system_topology import LoadBalancer +from asyncflow.schemas.topology.nodes import LoadBalancer if TYPE_CHECKING: from asyncflow.runtime.rqs_state import RequestState diff --git a/src/asyncflow/runtime/actors/helpers/lb_algorithms.py b/src/asyncflow/runtime/actors/routing/lb_algorithms.py similarity index 100% rename from src/asyncflow/runtime/actors/helpers/lb_algorithms.py rename to src/asyncflow/runtime/actors/routing/lb_algorithms.py diff --git a/src/asyncflow/runtime/actors/rqs_generator.py b/src/asyncflow/runtime/actors/rqs_generator.py index 6c983df..1b67213 100644 --- a/src/asyncflow/runtime/actors/rqs_generator.py +++ b/src/asyncflow/runtime/actors/rqs_generator.py @@ -21,8 +21,8 @@ import simpy from asyncflow.runtime.actors.edge import EdgeRuntime - from asyncflow.schemas.rqs_generator_input import RqsGeneratorInput - from asyncflow.schemas.simulation_settings_input import SimulationSettings + from asyncflow.schemas.settings.simulation import SimulationSettings + from asyncflow.schemas.workload.rqs_generator import RqsGenerator class RqsGeneratorRuntime: @@ -36,7 +36,7 @@ def __init__( *, env: simpy.Environment, out_edge: EdgeRuntime | None, - rqs_generator_data: RqsGeneratorInput, + rqs_generator_data: RqsGenerator, sim_settings: SimulationSettings, rng: np.random.Generator | None = None, ) -> None: @@ -46,7 +46,7 @@ def __init__( Args: env (simpy.Environment): environment for the simulation out_edge (EdgeRuntime): edge connecting this node with the next one - rqs_generator_data (RqsGeneratorInput): data do define the sampler + rqs_generator_data (RqsGenerator): data do define the sampler sim_settings (SimulationSettings): settings to start the simulation rng (np.random.Generator | None, optional): random variable generator. diff --git a/src/asyncflow/runtime/actors/server.py b/src/asyncflow/runtime/actors/server.py index 7d72de1..0572956 100644 --- a/src/asyncflow/runtime/actors/server.py +++ b/src/asyncflow/runtime/actors/server.py @@ -22,8 +22,8 @@ from asyncflow.resources.server_containers import ServerContainers from asyncflow.runtime.actors.edge import EdgeRuntime from asyncflow.runtime.rqs_state import RequestState -from asyncflow.schemas.simulation_settings_input import SimulationSettings -from asyncflow.schemas.system_topology.full_system_topology import Server +from asyncflow.schemas.settings.simulation import SimulationSettings +from asyncflow.schemas.topology.nodes import Server class ServerRuntime: diff --git a/src/asyncflow/runtime/simulation_runner.py b/src/asyncflow/runtime/simulation_runner.py index f82fb72..5d112ae 100644 --- a/src/asyncflow/runtime/simulation_runner.py +++ b/src/asyncflow/runtime/simulation_runner.py @@ -16,18 +16,18 @@ from asyncflow.runtime.actors.load_balancer import LoadBalancerRuntime from asyncflow.runtime.actors.rqs_generator import RqsGeneratorRuntime from asyncflow.runtime.actors.server import ServerRuntime -from asyncflow.schemas.full_simulation_input import SimulationPayload +from asyncflow.schemas.payload import SimulationPayload if TYPE_CHECKING: from collections.abc import Iterable - from asyncflow.schemas.rqs_generator_input import RqsGeneratorInput - from asyncflow.schemas.system_topology.full_system_topology import ( + from asyncflow.schemas.topology.edges import Edge + from asyncflow.schemas.topology.nodes import ( Client, - Edge, LoadBalancer, Server, ) + from asyncflow.schemas.workload.rqs_generator import RqsGenerator # --- PROTOCOL DEFINITION --- # This is the contract that all runtime actors must follow. @@ -63,7 +63,7 @@ def __init__( # instantiation of object needed to build nodes for the runtime phase self.servers: list[Server] = simulation_input.topology_graph.nodes.servers self.client: Client = simulation_input.topology_graph.nodes.client - self.rqs_generator: RqsGeneratorInput = simulation_input.rqs_input + self.rqs_generator: RqsGenerator = simulation_input.rqs_input self.lb: LoadBalancer | None = None self.simulation_settings = simulation_input.sim_settings self.edges: list[Edge] = simulation_input.topology_graph.edges diff --git a/src/asyncflow/samplers/common_helpers.py b/src/asyncflow/samplers/common_helpers.py index 123ae4a..4f2f675 100644 --- a/src/asyncflow/samplers/common_helpers.py +++ b/src/asyncflow/samplers/common_helpers.py @@ -4,7 +4,7 @@ import numpy as np from asyncflow.config.constants import Distribution -from asyncflow.schemas.random_variables_config import RVConfig +from asyncflow.schemas.common.random_variables import RVConfig def uniform_variable_generator(rng: np.random.Generator) -> float: diff --git a/src/asyncflow/samplers/gaussian_poisson.py b/src/asyncflow/samplers/gaussian_poisson.py index 5caa9ed..b96eca5 100644 --- a/src/asyncflow/samplers/gaussian_poisson.py +++ b/src/asyncflow/samplers/gaussian_poisson.py @@ -16,12 +16,12 @@ truncated_gaussian_generator, uniform_variable_generator, ) -from asyncflow.schemas.rqs_generator_input import RqsGeneratorInput -from asyncflow.schemas.simulation_settings_input import SimulationSettings +from asyncflow.schemas.settings.simulation import SimulationSettings +from asyncflow.schemas.workload.rqs_generator import RqsGenerator def gaussian_poisson_sampling( - input_data: RqsGeneratorInput, + input_data: RqsGenerator, sim_settings: SimulationSettings, *, rng: np.random.Generator, diff --git a/src/asyncflow/samplers/poisson_poisson.py b/src/asyncflow/samplers/poisson_poisson.py index 5e1b4cc..ea7a4fb 100644 --- a/src/asyncflow/samplers/poisson_poisson.py +++ b/src/asyncflow/samplers/poisson_poisson.py @@ -13,12 +13,12 @@ poisson_variable_generator, uniform_variable_generator, ) -from asyncflow.schemas.rqs_generator_input import RqsGeneratorInput -from asyncflow.schemas.simulation_settings_input import SimulationSettings +from asyncflow.schemas.settings.simulation import SimulationSettings +from asyncflow.schemas.workload.rqs_generator import RqsGenerator def poisson_poisson_sampling( - input_data: RqsGeneratorInput, + input_data: RqsGenerator, sim_settings: SimulationSettings, *, rng: np.random.Generator, diff --git a/src/asyncflow/schemas/common/__init__.py b/src/asyncflow/schemas/common/__init__.py new file mode 100644 index 0000000..206bfb9 --- /dev/null +++ b/src/asyncflow/schemas/common/__init__.py @@ -0,0 +1 @@ +"""Shared, reusable primitives for schema modules (e.g., RVConfig).""" diff --git a/src/asyncflow/schemas/random_variables_config.py b/src/asyncflow/schemas/common/random_variables.py similarity index 100% rename from src/asyncflow/schemas/random_variables_config.py rename to src/asyncflow/schemas/common/random_variables.py diff --git a/src/asyncflow/schemas/full_simulation_input.py b/src/asyncflow/schemas/full_simulation_input.py deleted file mode 100644 index 504396a..0000000 --- a/src/asyncflow/schemas/full_simulation_input.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Definition of the full input for the simulation""" - -from pydantic import BaseModel - -from asyncflow.schemas.rqs_generator_input import RqsGeneratorInput -from asyncflow.schemas.simulation_settings_input import SimulationSettings -from asyncflow.schemas.system_topology.full_system_topology import TopologyGraph - - -class SimulationPayload(BaseModel): - """Full input structure to perform a simulation""" - - rqs_input: RqsGeneratorInput - topology_graph: TopologyGraph - sim_settings: SimulationSettings diff --git a/src/asyncflow/schemas/payload.py b/src/asyncflow/schemas/payload.py new file mode 100644 index 0000000..3c889e4 --- /dev/null +++ b/src/asyncflow/schemas/payload.py @@ -0,0 +1,15 @@ +"""Definition of the full input for the simulation""" + +from pydantic import BaseModel + +from asyncflow.schemas.settings.simulation import SimulationSettings +from asyncflow.schemas.topology.graph import TopologyGraph +from asyncflow.schemas.workload.rqs_generator import RqsGenerator + + +class SimulationPayload(BaseModel): + """Full input structure to perform a simulation""" + + rqs_input: RqsGenerator + topology_graph: TopologyGraph + sim_settings: SimulationSettings diff --git a/src/asyncflow/schemas/simulation_settings_input.py b/src/asyncflow/schemas/settings/simulation.py similarity index 100% rename from src/asyncflow/schemas/simulation_settings_input.py rename to src/asyncflow/schemas/settings/simulation.py diff --git a/src/asyncflow/schemas/system_topology/full_system_topology.py b/src/asyncflow/schemas/system_topology/full_system_topology.py deleted file mode 100644 index 7ab91bc..0000000 --- a/src/asyncflow/schemas/system_topology/full_system_topology.py +++ /dev/null @@ -1,391 +0,0 @@ -""" -Define the topology of the system as a directed graph -where nodes represents macro structure (server, client ecc ecc) -and edges how these strcutures are connected and the network -latency necessary for the requests generated to move from -one structure to another -""" - -from collections import Counter - -from pydantic import ( - BaseModel, - ConfigDict, - Field, - PositiveInt, - field_validator, - model_validator, -) -from pydantic_core.core_schema import ValidationInfo - -from asyncflow.config.constants import ( - LbAlgorithmsName, - NetworkParameters, - ServerResourcesDefaults, - SystemEdges, - SystemNodes, -) -from asyncflow.schemas.random_variables_config import RVConfig -from asyncflow.schemas.system_topology.endpoint import Endpoint - -#------------------------------------------------------------- -# Definition of the nodes structure for the graph representing -# the topoogy of the system defined for the simulation -#------------------------------------------------------------- - -# ------------------------------------------------------------- -# CLIENT -# ------------------------------------------------------------- - -class Client(BaseModel): - """Definition of the client class""" - - id: str - type: SystemNodes = SystemNodes.CLIENT - - @field_validator("type", mode="after") - def ensure_type_is_standard(cls, v: SystemNodes) -> SystemNodes: # noqa: N805 - """Ensure the type of the client is standard""" - if v != SystemNodes.CLIENT: - msg = f"The type should have a standard value: {SystemNodes.CLIENT}" - raise ValueError(msg) - return v - -# ------------------------------------------------------------- -# SERVER RESOURCES -# ------------------------------------------------------------- - -class ServerResources(BaseModel): - """ - Defines the quantifiable resources available on a server node. - Each attribute maps directly to a SimPy resource primitive. - """ - - cpu_cores: PositiveInt = Field( - ServerResourcesDefaults.CPU_CORES, - ge = ServerResourcesDefaults.MINIMUM_CPU_CORES, - description="Number of CPU cores available for processing.", - ) - db_connection_pool: PositiveInt | None = Field( - ServerResourcesDefaults.DB_CONNECTION_POOL, - description="Size of the database connection pool, if applicable.", - ) - - # Risorse modellate come simpy.Container (livello) - ram_mb: PositiveInt = Field( - ServerResourcesDefaults.RAM_MB, - ge = ServerResourcesDefaults.MINIMUM_RAM_MB, - description="Total available RAM in Megabytes.") - - # for the future - # disk_iops_limit: PositiveInt | None = None - # network_throughput_mbps: PositiveInt | None = None - -# ------------------------------------------------------------- -# SERVER -# ------------------------------------------------------------- - -class Server(BaseModel): - """ - definition of the server class: - - id: is the server identifier - - type: is the type of node in the structure - - server resources: is a dictionary to define the resources - of the machine where the server is living - - endpoints: is the list of all endpoints in a server - """ - - id: str - type: SystemNodes = SystemNodes.SERVER - #Later define a valide structure for the keys of server resources - server_resources : ServerResources - endpoints : list[Endpoint] - - @field_validator("type", mode="after") - def ensure_type_is_standard(cls, v: SystemNodes) -> SystemNodes: # noqa: N805 - """Ensure the type of the server is standard""" - if v != SystemNodes.SERVER: - msg = f"The type should have a standard value: {SystemNodes.SERVER}" - raise ValueError(msg) - return v - -class LoadBalancer(BaseModel): - """ - basemodel for the load balancer - - id: unique name associated to the lb - - type: type of the node in the structure - - server_covered: list of server id connected to the lb - """ - - id: str - type: SystemNodes = SystemNodes.LOAD_BALANCER - algorithms: LbAlgorithmsName = LbAlgorithmsName.ROUND_ROBIN - server_covered: set[str] = Field(default_factory=set) - - - - @field_validator("type", mode="after") - def ensure_type_is_standard(cls, v: SystemNodes) -> SystemNodes: # noqa: N805 - """Ensure the type of the server is standard""" - if v != SystemNodes.LOAD_BALANCER: - msg = f"The type should have a standard value: {SystemNodes.LOAD_BALANCER}" - raise ValueError(msg) - return v - -# ------------------------------------------------------------- -# NODES CLASS WITH ALL POSSIBLE OBJECTS REPRESENTED BY A NODE -# ------------------------------------------------------------- - -class TopologyNodes(BaseModel): - """ - Definition of the nodes class: - - server: represent all servers implemented in the system - - client: is a simple object with just a name representing - the origin of the graph - """ - - servers: list[Server] - client: Client - load_balancer: LoadBalancer | None = None - - @model_validator(mode="after") # type: ignore[arg-type] - def unique_ids( - cls, # noqa: N805 - model: "TopologyNodes", - ) -> "TopologyNodes": - """Check that all id are unique""" - ids = [server.id for server in model.servers] + [model.client.id] - - if model.load_balancer is not None: - ids.append(model.load_balancer.id) - - counter = Counter(ids) - duplicate = [node_id for node_id, value in counter.items() if value > 1] - if duplicate: - msg = f"The following node ids are duplicate {duplicate}" - raise ValueError(msg) - return model - - model_config = ConfigDict(extra="forbid") - -#------------------------------------------------------------- -# Definition of the edges structure for the graph representing -# the topoogy of the system defined for the simulation -#------------------------------------------------------------- - -class Edge(BaseModel): - """ - A directed connection in the topology graph. - - Attributes - ---------- - source : str - Identifier of the source node (where the request comes from). - target : str - Identifier of the destination node (where the request goes to). - latency : RVConfig - Random-variable configuration for network latency on this link. - probability : float - Probability of taking this edge when there are multiple outgoing links. - Must be in [0.0, 1.0]. Defaults to 1.0 (always taken). - edge_type : SystemEdges - Category of the link (e.g. network, queue, stream). - - """ - - id: str - source: str - target: str - latency: RVConfig - edge_type: SystemEdges = SystemEdges.NETWORK_CONNECTION - dropout_rate: float = Field( - NetworkParameters.DROPOUT_RATE, - ge = NetworkParameters.MIN_DROPOUT_RATE, - le = NetworkParameters.MAX_DROPOUT_RATE, - description=( - "for each nodes representing a network we define" - "a probability to drop the request" - ), - ) - - # The idea to put here the control about variance and mean about the edges - # latencies and not in RVConfig is to provide a better error handling - # providing a direct indication of the edge with the error - # The idea to put here the control about variance and mean about the edges - # latencies and not in RVConfig is to provide a better error handling - # providing a direct indication of the edge with the error - @field_validator("latency", mode="after") - def ensure_latency_is_non_negative( - cls, # noqa: N805 - v: RVConfig, - info: ValidationInfo, - ) -> RVConfig: - """Ensures that the latency's mean and variance are positive.""" - mean = v.mean - variance = v.variance - - # We can get the edge ID from the validation context for a better error message - edge_id = info.data.get("id", "unknown") - - if mean <= 0: - msg = f"The mean latency of the edge '{edge_id}' must be positive" - raise ValueError(msg) - if variance is not None and variance < 0: # Variance can be zero - msg = ( - f"The variance of the latency of the edge {edge_id}" - "must be non negative" - ) - raise ValueError(msg) - return v - - - @model_validator(mode="after") # type: ignore[arg-type] - def check_src_trgt_different(cls, model: "Edge") -> "Edge": # noqa: N805 - """Ensure source is different from target""" - if model.source == model.target: - msg = "source and target must be different nodes" - raise ValueError(msg) - return model - - -#------------------------------------------------------------- -# Definition of the Graph structure representing -# the topogy of the system defined for the simulation -#------------------------------------------------------------- - -class TopologyGraph(BaseModel): - """ - data collection for the whole graph representing - the full system - """ - - nodes: TopologyNodes - edges: list[Edge] - - @model_validator(mode="after") # type: ignore[arg-type] - def unique_ids( - cls, # noqa: N805 - model: "TopologyGraph", - ) -> "TopologyGraph": - """Check that all id are unique""" - counter = Counter(edge.id for edge in model.edges) - duplicate = [edge_id for edge_id, value in counter.items() if value > 1] - if duplicate: - msg = f"There are multiple edges with the following ids {duplicate}" - raise ValueError(msg) - return model - - - @model_validator(mode="after") # type: ignore[arg-type] - def edge_refs_valid( - cls, # noqa: N805 - model: "TopologyGraph", - ) -> "TopologyGraph": - """ - Validate that the graph is self-consistent. - - * All targets must be nodes declared in ``m.nodes``. - * External IDs are allowed as sources (entry points, generator) but - they must never appear as a target anywhere else. - """ - # ------------------------------------------------------------------ - # 1. Collect declared node IDs (servers, client, optional LB) - # ------------------------------------------------------------------ - node_ids: set[str] = {srv.id for srv in model.nodes.servers} - node_ids.add(model.nodes.client.id) - if model.nodes.load_balancer is not None: - node_ids.add(model.nodes.load_balancer.id) - - # ------------------------------------------------------------------ - # 2. Scan every edge once - # ------------------------------------------------------------------ - external_sources: set[str] = set() - - for edge in model.edges: - # ── Rule 1: target must be a declared node - if edge.target not in node_ids: - msg = ( - f"Edge {edge.source}->{edge.target} references " - f"unknown target node '{edge.target}'." - ) - raise ValueError(msg) - - # Collect any source that is not a declared node - if edge.source not in node_ids: - external_sources.add(edge.source) - - # ------------------------------------------------------------------ - # 3. Ensure external sources never appear as targets elsewhere - # ------------------------------------------------------------------ - forbidden_targets = external_sources & {e.target for e in model.edges} - if forbidden_targets: - msg = ( - "External IDs cannot be used as targets as well:" - f"{sorted(forbidden_targets)}" - ) - raise ValueError(msg) - - return model - - @model_validator(mode="after") # type: ignore[arg-type] - def valid_load_balancer(cls, model: "TopologyGraph") -> "TopologyGraph": # noqa: N805 - """ - Check the validity of the load balancer: first we check - if is present in the simulation, second we check if the LB list - is a proper subset of the server sets of ids, then we check if - edge from LB to the servers are well defined - """ - lb = model.nodes.load_balancer - if lb is None: - return model - - server_ids = {s.id for s in model.nodes.servers} - - # 1) LB list ⊆ server_ids - missing = lb.server_covered - server_ids - if missing: - - msg = (f"Load balancer '{lb.id}'" - f"references unknown servers: {sorted(missing)}") - raise ValueError(msg) - - # edge are well defined - targets_from_lb = {e.target for e in model.edges if e.source == lb.id} - not_linked = lb.server_covered - targets_from_lb - if not_linked: - msg = ( - f"Servers {sorted(not_linked)} are covered by LB '{lb.id}' " - "but have no outgoing edge from it." - ) - - raise ValueError(msg) - - return model - - - @model_validator(mode="after") # type: ignore[arg-type] - def no_fanout_except_lb(cls, model: "TopologyGraph") -> "TopologyGraph": # noqa: N805 - """Ensure only the LB (declared node) can have multiple outgoing edges.""" - lb_id = model.nodes.load_balancer.id if model.nodes.load_balancer else None - - # let us consider only nodes declared in the topology - node_ids: set[str] = {server.id for server in model.nodes.servers} - node_ids.add(model.nodes.client.id) - if lb_id: - node_ids.add(lb_id) - - counts: dict[str, int] = {} - for edge in model.edges: - if edge.source not in node_ids: - continue - counts[edge.source] = counts.get(edge.source, 0) + 1 - - offenders = [src for src, c in counts.items() if c > 1 and src != lb_id] - if offenders: - msg = ( - "Only the load balancer can have multiple outgoing edges. " - f"Offending sources: {offenders}" - ) - raise ValueError(msg) - - return model diff --git a/src/asyncflow/schemas/topology/edges.py b/src/asyncflow/schemas/topology/edges.py new file mode 100644 index 0000000..6e3d03b --- /dev/null +++ b/src/asyncflow/schemas/topology/edges.py @@ -0,0 +1,99 @@ +""" +Define the property of the edges of the system representing +links between different nodes +""" + +from pydantic import ( + BaseModel, + Field, + field_validator, + model_validator, +) +from pydantic_core.core_schema import ValidationInfo + +from asyncflow.config.constants import ( + NetworkParameters, + SystemEdges, +) +from asyncflow.schemas.common.random_variables import RVConfig + +#------------------------------------------------------------- +# Definition of the edges structure for the graph representing +# the topoogy of the system defined for the simulation +#------------------------------------------------------------- + +class Edge(BaseModel): + """ + A directed connection in the topology graph. + + Attributes + ---------- + source : str + Identifier of the source node (where the request comes from). + target : str + Identifier of the destination node (where the request goes to). + latency : RVConfig + Random-variable configuration for network latency on this link. + probability : float + Probability of taking this edge when there are multiple outgoing links. + Must be in [0.0, 1.0]. Defaults to 1.0 (always taken). + edge_type : SystemEdges + Category of the link (e.g. network, queue, stream). + + """ + + id: str + source: str + target: str + latency: RVConfig + edge_type: SystemEdges = SystemEdges.NETWORK_CONNECTION + dropout_rate: float = Field( + NetworkParameters.DROPOUT_RATE, + ge = NetworkParameters.MIN_DROPOUT_RATE, + le = NetworkParameters.MAX_DROPOUT_RATE, + description=( + "for each nodes representing a network we define" + "a probability to drop the request" + ), + ) + + # The idea to put here the control about variance and mean about the edges + # latencies and not in RVConfig is to provide a better error handling + # providing a direct indication of the edge with the error + # The idea to put here the control about variance and mean about the edges + # latencies and not in RVConfig is to provide a better error handling + # providing a direct indication of the edge with the error + @field_validator("latency", mode="after") + def ensure_latency_is_non_negative( + cls, # noqa: N805 + v: RVConfig, + info: ValidationInfo, + ) -> RVConfig: + """Ensures that the latency's mean and variance are positive.""" + mean = v.mean + variance = v.variance + + # We can get the edge ID from the validation context for a better error message + edge_id = info.data.get("id", "unknown") + + if mean <= 0: + msg = f"The mean latency of the edge '{edge_id}' must be positive" + raise ValueError(msg) + if variance is not None and variance < 0: # Variance can be zero + msg = ( + f"The variance of the latency of the edge {edge_id}" + "must be non negative" + ) + raise ValueError(msg) + return v + + + @model_validator(mode="after") # type: ignore[arg-type] + def check_src_trgt_different(cls, model: "Edge") -> "Edge": # noqa: N805 + """Ensure source is different from target""" + if model.source == model.target: + msg = "source and target must be different nodes" + raise ValueError(msg) + return model + + diff --git a/src/asyncflow/schemas/system_topology/endpoint.py b/src/asyncflow/schemas/topology/endpoint.py similarity index 100% rename from src/asyncflow/schemas/system_topology/endpoint.py rename to src/asyncflow/schemas/topology/endpoint.py diff --git a/src/asyncflow/schemas/topology/graph.py b/src/asyncflow/schemas/topology/graph.py new file mode 100644 index 0000000..91cf857 --- /dev/null +++ b/src/asyncflow/schemas/topology/graph.py @@ -0,0 +1,159 @@ +""" +Define the topology of the system as a directed graph +where nodes represents macro structure (server, client ecc ecc) +and edges how these strcutures are connected and the network +latency necessary for the requests generated to move from +one structure to another +""" + +from collections import Counter + +from pydantic import ( + BaseModel, + model_validator, +) + +from asyncflow.schemas.topology.edges import Edge +from asyncflow.schemas.topology.nodes import TopologyNodes + +#------------------------------------------------------------- +# Definition of the Graph structure representing +# the topogy of the system defined for the simulation +#------------------------------------------------------------- + +class TopologyGraph(BaseModel): + """ + data collection for the whole graph representing + the full system + """ + + nodes: TopologyNodes + edges: list[Edge] + + @model_validator(mode="after") # type: ignore[arg-type] + def unique_ids( + cls, # noqa: N805 + model: "TopologyGraph", + ) -> "TopologyGraph": + """Check that all id are unique""" + counter = Counter(edge.id for edge in model.edges) + duplicate = [edge_id for edge_id, value in counter.items() if value > 1] + if duplicate: + msg = f"There are multiple edges with the following ids {duplicate}" + raise ValueError(msg) + return model + + + @model_validator(mode="after") # type: ignore[arg-type] + def edge_refs_valid( + cls, # noqa: N805 + model: "TopologyGraph", + ) -> "TopologyGraph": + """ + Validate that the graph is self-consistent. + + * All targets must be nodes declared in ``m.nodes``. + * External IDs are allowed as sources (entry points, generator) but + they must never appear as a target anywhere else. + """ + # ------------------------------------------------------------------ + # 1. Collect declared node IDs (servers, client, optional LB) + # ------------------------------------------------------------------ + node_ids: set[str] = {srv.id for srv in model.nodes.servers} + node_ids.add(model.nodes.client.id) + if model.nodes.load_balancer is not None: + node_ids.add(model.nodes.load_balancer.id) + + # ------------------------------------------------------------------ + # 2. Scan every edge once + # ------------------------------------------------------------------ + external_sources: set[str] = set() + + for edge in model.edges: + # ── Rule 1: target must be a declared node + if edge.target not in node_ids: + msg = ( + f"Edge {edge.source}->{edge.target} references " + f"unknown target node '{edge.target}'." + ) + raise ValueError(msg) + + # Collect any source that is not a declared node + if edge.source not in node_ids: + external_sources.add(edge.source) + + # ------------------------------------------------------------------ + # 3. Ensure external sources never appear as targets elsewhere + # ------------------------------------------------------------------ + forbidden_targets = external_sources & {e.target for e in model.edges} + if forbidden_targets: + msg = ( + "External IDs cannot be used as targets as well:" + f"{sorted(forbidden_targets)}" + ) + raise ValueError(msg) + + return model + + @model_validator(mode="after") # type: ignore[arg-type] + def valid_load_balancer(cls, model: "TopologyGraph") -> "TopologyGraph": # noqa: N805 + """ + Check the validity of the load balancer: first we check + if is present in the simulation, second we check if the LB list + is a proper subset of the server sets of ids, then we check if + edge from LB to the servers are well defined + """ + lb = model.nodes.load_balancer + if lb is None: + return model + + server_ids = {s.id for s in model.nodes.servers} + + # 1) LB list ⊆ server_ids + missing = lb.server_covered - server_ids + if missing: + + msg = (f"Load balancer '{lb.id}'" + f"references unknown servers: {sorted(missing)}") + raise ValueError(msg) + + # edge are well defined + targets_from_lb = {e.target for e in model.edges if e.source == lb.id} + not_linked = lb.server_covered - targets_from_lb + if not_linked: + msg = ( + f"Servers {sorted(not_linked)} are covered by LB '{lb.id}' " + "but have no outgoing edge from it." + ) + + raise ValueError(msg) + + return model + + + @model_validator(mode="after") # type: ignore[arg-type] + def no_fanout_except_lb(cls, model: "TopologyGraph") -> "TopologyGraph": # noqa: N805 + """Ensure only the LB (declared node) can have multiple outgoing edges.""" + lb_id = model.nodes.load_balancer.id if model.nodes.load_balancer else None + + # let us consider only nodes declared in the topology + node_ids: set[str] = {server.id for server in model.nodes.servers} + node_ids.add(model.nodes.client.id) + if lb_id: + node_ids.add(lb_id) + + counts: dict[str, int] = {} + for edge in model.edges: + if edge.source not in node_ids: + continue + counts[edge.source] = counts.get(edge.source, 0) + 1 + + offenders = [src for src, c in counts.items() if c > 1 and src != lb_id] + if offenders: + msg = ( + "Only the load balancer can have multiple outgoing edges. " + f"Offending sources: {offenders}" + ) + raise ValueError(msg) + + return model diff --git a/src/asyncflow/schemas/topology/nodes.py b/src/asyncflow/schemas/topology/nodes.py new file mode 100644 index 0000000..d742421 --- /dev/null +++ b/src/asyncflow/schemas/topology/nodes.py @@ -0,0 +1,164 @@ +""" +Define the pydantic schemas of the nodes you are allowed +to define in the topology of the system you would like to +simulate +""" + +from collections import Counter + +from pydantic import ( + BaseModel, + ConfigDict, + Field, + PositiveInt, + field_validator, + model_validator, +) + +from asyncflow.config.constants import ( + LbAlgorithmsName, + ServerResourcesDefaults, + SystemNodes, +) +from asyncflow.schemas.topology.endpoint import Endpoint + +#------------------------------------------------------------- +# Definition of the nodes structure for the graph representing +# the topoogy of the system defined for the simulation +#------------------------------------------------------------- + +# ------------------------------------------------------------- +# CLIENT +# ------------------------------------------------------------- + +class Client(BaseModel): + """Definition of the client class""" + + id: str + type: SystemNodes = SystemNodes.CLIENT + + @field_validator("type", mode="after") + def ensure_type_is_standard(cls, v: SystemNodes) -> SystemNodes: # noqa: N805 + """Ensure the type of the client is standard""" + if v != SystemNodes.CLIENT: + msg = f"The type should have a standard value: {SystemNodes.CLIENT}" + raise ValueError(msg) + return v + +# ------------------------------------------------------------- +# SERVER RESOURCES +# ------------------------------------------------------------- + +class ServerResources(BaseModel): + """ + Defines the quantifiable resources available on a server node. + Each attribute maps directly to a SimPy resource primitive. + """ + + cpu_cores: PositiveInt = Field( + ServerResourcesDefaults.CPU_CORES, + ge = ServerResourcesDefaults.MINIMUM_CPU_CORES, + description="Number of CPU cores available for processing.", + ) + db_connection_pool: PositiveInt | None = Field( + ServerResourcesDefaults.DB_CONNECTION_POOL, + description="Size of the database connection pool, if applicable.", + ) + + # Risorse modellate come simpy.Container (livello) + ram_mb: PositiveInt = Field( + ServerResourcesDefaults.RAM_MB, + ge = ServerResourcesDefaults.MINIMUM_RAM_MB, + description="Total available RAM in Megabytes.") + + # for the future + # disk_iops_limit: PositiveInt | None = None + # network_throughput_mbps: PositiveInt | None = None + +# ------------------------------------------------------------- +# SERVER +# ------------------------------------------------------------- + +class Server(BaseModel): + """ + definition of the server class: + - id: is the server identifier + - type: is the type of node in the structure + - server resources: is a dictionary to define the resources + of the machine where the server is living + - endpoints: is the list of all endpoints in a server + """ + + id: str + type: SystemNodes = SystemNodes.SERVER + #Later define a valide structure for the keys of server resources + server_resources : ServerResources + endpoints : list[Endpoint] + + @field_validator("type", mode="after") + def ensure_type_is_standard(cls, v: SystemNodes) -> SystemNodes: # noqa: N805 + """Ensure the type of the server is standard""" + if v != SystemNodes.SERVER: + msg = f"The type should have a standard value: {SystemNodes.SERVER}" + raise ValueError(msg) + return v + +class LoadBalancer(BaseModel): + """ + basemodel for the load balancer + - id: unique name associated to the lb + - type: type of the node in the structure + - server_covered: list of server id connected to the lb + """ + + id: str + type: SystemNodes = SystemNodes.LOAD_BALANCER + algorithms: LbAlgorithmsName = LbAlgorithmsName.ROUND_ROBIN + server_covered: set[str] = Field(default_factory=set) + + + + @field_validator("type", mode="after") + def ensure_type_is_standard(cls, v: SystemNodes) -> SystemNodes: # noqa: N805 + """Ensure the type of the server is standard""" + if v != SystemNodes.LOAD_BALANCER: + msg = f"The type should have a standard value: {SystemNodes.LOAD_BALANCER}" + raise ValueError(msg) + return v + + +# ------------------------------------------------------------- +# NODES CLASS WITH ALL POSSIBLE OBJECTS REPRESENTED BY A NODE +# ------------------------------------------------------------- + +class TopologyNodes(BaseModel): + """ + Definition of the nodes class: + - server: represent all servers implemented in the system + - client: is a simple object with just a name representing + the origin of the graph + """ + + servers: list[Server] + client: Client + load_balancer: LoadBalancer | None = None + + @model_validator(mode="after") # type: ignore[arg-type] + def unique_ids( + cls, # noqa: N805 + model: "TopologyNodes", + ) -> "TopologyNodes": + """Check that all id are unique""" + ids = [server.id for server in model.servers] + [model.client.id] + + if model.load_balancer is not None: + ids.append(model.load_balancer.id) + + counter = Counter(ids) + duplicate = [node_id for node_id, value in counter.items() if value > 1] + if duplicate: + msg = f"The following node ids are duplicate {duplicate}" + raise ValueError(msg) + return model + + model_config = ConfigDict(extra="forbid") diff --git a/src/asyncflow/schemas/rqs_generator_input.py b/src/asyncflow/schemas/workload/rqs_generator.py similarity index 95% rename from src/asyncflow/schemas/rqs_generator_input.py rename to src/asyncflow/schemas/workload/rqs_generator.py index f0f63a3..a6fbf3b 100644 --- a/src/asyncflow/schemas/rqs_generator_input.py +++ b/src/asyncflow/schemas/workload/rqs_generator.py @@ -4,10 +4,10 @@ from pydantic import BaseModel, Field, field_validator from asyncflow.config.constants import Distribution, SystemNodes, TimeDefaults -from asyncflow.schemas.random_variables_config import RVConfig +from asyncflow.schemas.common.random_variables import RVConfig -class RqsGeneratorInput(BaseModel): +class RqsGenerator(BaseModel): """Define the expected variables for the simulation""" id: str diff --git a/src/asyncflow/settings/__init__.py b/src/asyncflow/settings/__init__.py new file mode 100644 index 0000000..4f4031c --- /dev/null +++ b/src/asyncflow/settings/__init__.py @@ -0,0 +1,6 @@ +"""Public settings API.""" +from __future__ import annotations + +from asyncflow.schemas.settings.simulation import SimulationSettings + +__all__ = ["SimulationSettings"] diff --git a/src/asyncflow/workload/__init__.py b/src/asyncflow/workload/__init__.py new file mode 100644 index 0000000..c4b8735 --- /dev/null +++ b/src/asyncflow/workload/__init__.py @@ -0,0 +1,7 @@ +"""Public workload API.""" +from __future__ import annotations + +from asyncflow.schemas.common.random_variables import RVConfig +from asyncflow.schemas.workload.rqs_generator import RqsGenerator + +__all__ = ["RVConfig", "RqsGenerator"] diff --git a/tests/conftest.py b/tests/conftest.py index 893cb83..80955f0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,16 +12,16 @@ SamplePeriods, TimeDefaults, ) -from asyncflow.schemas.full_simulation_input import SimulationPayload -from asyncflow.schemas.random_variables_config import RVConfig -from asyncflow.schemas.rqs_generator_input import RqsGeneratorInput -from asyncflow.schemas.simulation_settings_input import SimulationSettings -from asyncflow.schemas.system_topology.full_system_topology import ( +from asyncflow.schemas.common.random_variables import RVConfig +from asyncflow.schemas.payload import SimulationPayload +from asyncflow.schemas.settings.simulation import SimulationSettings +from asyncflow.schemas.topology.edges import Edge +from asyncflow.schemas.topology.graph import TopologyGraph +from asyncflow.schemas.topology.nodes import ( Client, - Edge, - TopologyGraph, TopologyNodes, ) +from asyncflow.schemas.workload.rqs_generator import RqsGenerator # ============================================================================ # STANDARD CONFIGURATION FOR INPUT VARIABLES @@ -90,12 +90,12 @@ def sim_settings( @pytest.fixture -def rqs_input() -> RqsGeneratorInput: +def rqs_input() -> RqsGenerator: """ One active user issuing two requests per minute—sufficient to exercise the entire request-generator pipeline with minimal overhead. """ - return RqsGeneratorInput( + return RqsGenerator( id="rqs-1", avg_active_users=RVConfig(mean=1.0), avg_request_per_minute_per_user=RVConfig(mean=2.0), @@ -136,7 +136,7 @@ def topology_minimal() -> TopologyGraph: @pytest.fixture def payload_base( - rqs_input: RqsGeneratorInput, + rqs_input: RqsGenerator, sim_settings: SimulationSettings, topology_minimal: TopologyGraph, ) -> SimulationPayload: diff --git a/tests/integration/minimal/conftest.py b/tests/integration/minimal/conftest.py index 812ba5e..f29bf49 100644 --- a/tests/integration/minimal/conftest.py +++ b/tests/integration/minimal/conftest.py @@ -16,20 +16,20 @@ from asyncflow.config.constants import TimeDefaults from asyncflow.runtime.simulation_runner import SimulationRunner -from asyncflow.schemas.random_variables_config import RVConfig -from asyncflow.schemas.rqs_generator_input import RqsGeneratorInput +from asyncflow.schemas.common.random_variables import RVConfig +from asyncflow.schemas.workload.rqs_generator import RqsGenerator if TYPE_CHECKING: - from asyncflow.schemas.full_simulation_input import SimulationPayload + from asyncflow.schemas.payload import SimulationPayload # ────────────────────────────────────────────────────────────────────────────── # 0-traffic generator (shadows the project-wide fixture) # ────────────────────────────────────────────────────────────────────────────── @pytest.fixture(scope="session") -def rqs_input() -> RqsGeneratorInput: +def rqs_input() -> RqsGenerator: """A generator that never emits any request.""" - return RqsGeneratorInput( + return RqsGenerator( id="rqs-zero", avg_active_users=RVConfig(mean=0.0), avg_request_per_minute_per_user=RVConfig(mean=0.0), diff --git a/tests/integration/minimal/test_minimal.py b/tests/integration/minimal/test_minimal.py index 2a82601..7ae9507 100644 --- a/tests/integration/minimal/test_minimal.py +++ b/tests/integration/minimal/test_minimal.py @@ -22,7 +22,7 @@ from asyncflow.runtime.simulation_runner import SimulationRunner if TYPE_CHECKING: - from asyncflow.schemas.full_simulation_input import SimulationPayload + from asyncflow.schemas.payload import SimulationPayload # --------------------------------------------------------------------------- # diff --git a/tests/integration/payload/test_payload_invalid.py b/tests/integration/payload/test_payload_invalid.py index fb700b4..8cd5226 100644 --- a/tests/integration/payload/test_payload_invalid.py +++ b/tests/integration/payload/test_payload_invalid.py @@ -6,7 +6,7 @@ import yaml from pydantic import ValidationError -from asyncflow.schemas.full_simulation_input import SimulationPayload +from asyncflow.schemas.payload import SimulationPayload DATA_DIR = Path(__file__).parent / "data" / "invalid" YMLS = sorted(DATA_DIR.glob("*.yml")) diff --git a/tests/unit/metrics/test_analyzer.py b/tests/unit/metrics/test_analyzer.py index eaae45d..365cb1c 100644 --- a/tests/unit/metrics/test_analyzer.py +++ b/tests/unit/metrics/test_analyzer.py @@ -14,7 +14,7 @@ from asyncflow.runtime.actors.client import ClientRuntime from asyncflow.runtime.actors.edge import EdgeRuntime from asyncflow.runtime.actors.server import ServerRuntime - from asyncflow.schemas.simulation_settings_input import SimulationSettings + from asyncflow.schemas.settings.simulation import SimulationSettings # ---------------------------------------------------------------------- # diff --git a/tests/unit/pybuilder/test_input_builder.py b/tests/unit/pybuilder/test_input_builder.py index 3ee710c..fa49fda 100644 --- a/tests/unit/pybuilder/test_input_builder.py +++ b/tests/unit/pybuilder/test_input_builder.py @@ -13,20 +13,21 @@ import pytest -from asyncflow.pybuilder.input_builder import AsyncFlow -from asyncflow.schemas.full_simulation_input import SimulationPayload -from asyncflow.schemas.rqs_generator_input import RqsGeneratorInput -from asyncflow.schemas.simulation_settings_input import SimulationSettings -from asyncflow.schemas.system_topology.endpoint import Endpoint -from asyncflow.schemas.system_topology.full_system_topology import Client, Edge, Server +from asyncflow.builder.asyncflow_builder import AsyncFlow +from asyncflow.schemas.payload import SimulationPayload +from asyncflow.schemas.settings.simulation import SimulationSettings +from asyncflow.schemas.topology.edges import Edge +from asyncflow.schemas.topology.endpoint import Endpoint +from asyncflow.schemas.topology.nodes import Client, Server +from asyncflow.schemas.workload.rqs_generator import RqsGenerator # --------------------------------------------------------------------------- # # Helpers: build minimal, valid components # # --------------------------------------------------------------------------- # -def make_generator() -> RqsGeneratorInput: +def make_generator() -> RqsGenerator: """Return a minimal valid request generator.""" - return RqsGeneratorInput( + return RqsGenerator( id="rqs-1", avg_active_users={"mean": 10}, avg_request_per_minute_per_user={"mean": 30}, @@ -244,7 +245,7 @@ def test_build_without_settings_raises() -> None: # Negative cases: type enforcement in add_* methods # # --------------------------------------------------------------------------- # def test_add_generator_rejects_wrong_type() -> None: - """`add_generator` rejects non-RqsGeneratorInput instances.""" + """`add_generator` rejects non-RqsGenerator instances.""" flow = AsyncFlow() with pytest.raises(TypeError): flow.add_generator("not-a-generator") # type: ignore[arg-type] diff --git a/tests/unit/resources/test_registry.py b/tests/unit/resources/test_registry.py index 34154db..6581ae0 100644 --- a/tests/unit/resources/test_registry.py +++ b/tests/unit/resources/test_registry.py @@ -7,12 +7,12 @@ from asyncflow.config.constants import ServerResourceName from asyncflow.resources.registry import ResourcesRuntime -from asyncflow.schemas.system_topology.endpoint import Endpoint -from asyncflow.schemas.system_topology.full_system_topology import ( +from asyncflow.schemas.topology.endpoint import Endpoint +from asyncflow.schemas.topology.graph import TopologyGraph +from asyncflow.schemas.topology.nodes import ( Client, Server, ServerResources, - TopologyGraph, TopologyNodes, ) diff --git a/tests/unit/resources/test_server_containers.py b/tests/unit/resources/test_server_containers.py index 3772528..b7a8243 100644 --- a/tests/unit/resources/test_server_containers.py +++ b/tests/unit/resources/test_server_containers.py @@ -4,7 +4,7 @@ from asyncflow.config.constants import ServerResourceName from asyncflow.resources.server_containers import build_containers -from asyncflow.schemas.system_topology.full_system_topology import ServerResources +from asyncflow.schemas.topology.nodes import ServerResources def test_containers_start_full() -> None: diff --git a/tests/unit/runtime/actors/test_client.py b/tests/unit/runtime/actors/test_client.py index 9188d64..d78c848 100644 --- a/tests/unit/runtime/actors/test_client.py +++ b/tests/unit/runtime/actors/test_client.py @@ -7,9 +7,7 @@ from asyncflow.config.constants import SystemEdges, SystemNodes from asyncflow.runtime.actors.client import ClientRuntime from asyncflow.runtime.rqs_state import RequestState -from asyncflow.schemas.system_topology.full_system_topology import ( - Client, -) +from asyncflow.schemas.topology.nodes import Client # --------------------------------------------------------------------------- # # Dummy edge (no real network) # diff --git a/tests/unit/runtime/actors/test_edge.py b/tests/unit/runtime/actors/test_edge.py index e180bec..1800a12 100644 --- a/tests/unit/runtime/actors/test_edge.py +++ b/tests/unit/runtime/actors/test_edge.py @@ -14,13 +14,13 @@ from asyncflow.config.constants import SampledMetricName, SystemEdges, SystemNodes from asyncflow.runtime.actors.edge import EdgeRuntime from asyncflow.runtime.rqs_state import RequestState -from asyncflow.schemas.random_variables_config import RVConfig -from asyncflow.schemas.system_topology.full_system_topology import Edge +from asyncflow.schemas.common.random_variables import RVConfig +from asyncflow.schemas.topology.edges import Edge if TYPE_CHECKING: import numpy as np - from asyncflow.schemas.simulation_settings_input import SimulationSettings + from asyncflow.schemas.settings.simulation import SimulationSettings # --------------------------------------------------------------------------- # diff --git a/tests/unit/runtime/actors/test_load_balancer.py b/tests/unit/runtime/actors/test_load_balancer.py index 41902e0..1905543 100644 --- a/tests/unit/runtime/actors/test_load_balancer.py +++ b/tests/unit/runtime/actors/test_load_balancer.py @@ -9,7 +9,7 @@ from asyncflow.config.constants import LbAlgorithmsName, SystemNodes from asyncflow.runtime.actors.load_balancer import LoadBalancerRuntime -from asyncflow.schemas.system_topology.full_system_topology import LoadBalancer +from asyncflow.schemas.topology.nodes import LoadBalancer if TYPE_CHECKING: from asyncflow.runtime.actors.edge import EdgeRuntime diff --git a/tests/unit/runtime/actors/test_rqs_generator.py b/tests/unit/runtime/actors/test_rqs_generator.py index 7130306..fef5987 100644 --- a/tests/unit/runtime/actors/test_rqs_generator.py +++ b/tests/unit/runtime/actors/test_rqs_generator.py @@ -17,8 +17,8 @@ from asyncflow.runtime.actors.edge import EdgeRuntime from asyncflow.runtime.rqs_state import RequestState - from asyncflow.schemas.rqs_generator_input import RqsGeneratorInput - from asyncflow.schemas.simulation_settings_input import SimulationSettings + from asyncflow.schemas.settings.simulation import SimulationSettings + from asyncflow.schemas.workload.rqs_generator import RqsGenerator import importlib @@ -42,7 +42,7 @@ def transport(self, state: RequestState) -> None: def _make_runtime( env: simpy.Environment, edge: DummyEdgeRuntime, - rqs_input: RqsGeneratorInput, + rqs_input: RqsGenerator, sim_settings: SimulationSettings, *, seed: int = 0, @@ -67,7 +67,7 @@ def _make_runtime( def test_dispatcher_selects_poisson_poisson( monkeypatch: pytest.MonkeyPatch, - rqs_input: RqsGeneratorInput, + rqs_input: RqsGenerator, sim_settings: SimulationSettings, ) -> None: """Default (Poisson) distribution must invoke *poisson_poisson_sampling*.""" @@ -93,7 +93,7 @@ def _fake_pp(*args: object, **kwargs: object) -> Iterator[float]: def test_dispatcher_selects_gaussian_poisson( monkeypatch: pytest.MonkeyPatch, - rqs_input: RqsGeneratorInput, + rqs_input: RqsGenerator, sim_settings: SimulationSettings, ) -> None: """Normal distribution must invoke *gaussian_poisson_sampling*.""" @@ -124,7 +124,7 @@ def _fake_gp(*args: object, **kwargs: object) -> Iterator[float]: def test_event_arrival_generates_expected_number_of_requests( monkeypatch: pytest.MonkeyPatch, - rqs_input: RqsGeneratorInput, + rqs_input: RqsGenerator, sim_settings: SimulationSettings, ) -> None: """Given a deterministic gap list, exactly that many requests are sent.""" diff --git a/tests/unit/runtime/actors/test_server.py b/tests/unit/runtime/actors/test_server.py index 7085e12..4c915ac 100644 --- a/tests/unit/runtime/actors/test_server.py +++ b/tests/unit/runtime/actors/test_server.py @@ -32,9 +32,9 @@ from asyncflow.resources.server_containers import build_containers from asyncflow.runtime.actors.server import ServerRuntime from asyncflow.runtime.rqs_state import RequestState -from asyncflow.schemas.simulation_settings_input import SimulationSettings -from asyncflow.schemas.system_topology.endpoint import Endpoint, Step -from asyncflow.schemas.system_topology.full_system_topology import ( +from asyncflow.schemas.settings.simulation import SimulationSettings +from asyncflow.schemas.topology.endpoint import Endpoint, Step +from asyncflow.schemas.topology.nodes import ( Server, ServerResources, ) diff --git a/tests/unit/runtime/test_simulation_runner.py b/tests/unit/runtime/test_simulation_runner.py index c0f6b2e..e3b4da7 100644 --- a/tests/unit/runtime/test_simulation_runner.py +++ b/tests/unit/runtime/test_simulation_runner.py @@ -23,7 +23,7 @@ from asyncflow.runtime.actors.client import ClientRuntime from asyncflow.runtime.actors.rqs_generator import RqsGeneratorRuntime - from asyncflow.schemas.full_simulation_input import SimulationPayload + from asyncflow.schemas.payload import SimulationPayload # --------------------------------------------------------------------------- # diff --git a/tests/unit/samplers/test_gaussian_poisson.py b/tests/unit/samplers/test_gaussian_poisson.py index 4b3ed80..657fae9 100644 --- a/tests/unit/samplers/test_gaussian_poisson.py +++ b/tests/unit/samplers/test_gaussian_poisson.py @@ -13,12 +13,12 @@ from asyncflow.samplers.gaussian_poisson import ( gaussian_poisson_sampling, ) -from asyncflow.schemas.random_variables_config import RVConfig -from asyncflow.schemas.rqs_generator_input import RqsGeneratorInput +from asyncflow.schemas.common.random_variables import RVConfig +from asyncflow.schemas.workload.rqs_generator import RqsGenerator if TYPE_CHECKING: - from asyncflow.schemas.simulation_settings_input import SimulationSettings + from asyncflow.schemas.settings.simulation import SimulationSettings # --------------------------------------------------------------------------- # FIXTURES @@ -26,9 +26,9 @@ @pytest.fixture -def rqs_cfg() -> RqsGeneratorInput: - """Minimal, valid RqsGeneratorInput for Gaussian-Poisson tests.""" - return RqsGeneratorInput( +def rqs_cfg() -> RqsGenerator: + """Minimal, valid RqsGenerator for Gaussian-Poisson tests.""" + return RqsGenerator( id= "gen-1", avg_active_users=RVConfig( mean=10.0, @@ -47,7 +47,7 @@ def rqs_cfg() -> RqsGeneratorInput: def test_returns_generator_type( - rqs_cfg: RqsGeneratorInput, + rqs_cfg: RqsGenerator, sim_settings: SimulationSettings, rng: Generator, ) -> None: @@ -57,7 +57,7 @@ def test_returns_generator_type( def test_generates_positive_gaps( - rqs_cfg: RqsGeneratorInput, + rqs_cfg: RqsGenerator, sim_settings: SimulationSettings, ) -> None: """ @@ -83,7 +83,7 @@ def test_generates_positive_gaps( def test_zero_users_produces_no_events( monkeypatch: pytest.MonkeyPatch, - rqs_cfg: RqsGeneratorInput, + rqs_cfg: RqsGenerator, sim_settings: SimulationSettings, ) -> None: """ diff --git a/tests/unit/samplers/test_poisson_poisson.py b/tests/unit/samplers/test_poisson_poisson.py index fde7d04..c5d4a18 100644 --- a/tests/unit/samplers/test_poisson_poisson.py +++ b/tests/unit/samplers/test_poisson_poisson.py @@ -12,18 +12,18 @@ from asyncflow.config.constants import TimeDefaults from asyncflow.samplers.poisson_poisson import poisson_poisson_sampling -from asyncflow.schemas.random_variables_config import RVConfig -from asyncflow.schemas.rqs_generator_input import RqsGeneratorInput +from asyncflow.schemas.common.random_variables import RVConfig +from asyncflow.schemas.workload.rqs_generator import RqsGenerator if TYPE_CHECKING: - from asyncflow.schemas.simulation_settings_input import SimulationSettings + from asyncflow.schemas.settings.simulation import SimulationSettings @pytest.fixture -def rqs_cfg() -> RqsGeneratorInput: - """Return a minimal, valid RqsGeneratorInput for the sampler tests.""" - return RqsGeneratorInput( +def rqs_cfg() -> RqsGenerator: + """Return a minimal, valid RqsGenerator for the sampler tests.""" + return RqsGenerator( id="gen-1", avg_active_users={"mean": 1.0, "distribution": "poisson"}, avg_request_per_minute_per_user={"mean": 60.0, "distribution": "poisson"}, @@ -36,7 +36,7 @@ def rqs_cfg() -> RqsGeneratorInput: def test_sampler_returns_generator( - rqs_cfg: RqsGeneratorInput, + rqs_cfg: RqsGenerator, sim_settings: SimulationSettings, rng: Generator, ) -> None: @@ -46,7 +46,7 @@ def test_sampler_returns_generator( def test_all_gaps_are_positive( - rqs_cfg: RqsGeneratorInput, + rqs_cfg: RqsGenerator, sim_settings: SimulationSettings, ) -> None: """Every yielded gap must be strictly positive.""" @@ -65,7 +65,7 @@ def test_all_gaps_are_positive( def test_sampler_is_reproducible_with_fixed_seed( - rqs_cfg: RqsGeneratorInput, + rqs_cfg: RqsGenerator, sim_settings: SimulationSettings, ) -> None: """Same RNG seed must produce identical first N gaps.""" @@ -96,7 +96,7 @@ def test_zero_users_produces_no_events( sim_settings: SimulationSettings, ) -> None: """If the mean user count is zero the generator must yield no events.""" - cfg_zero = RqsGeneratorInput( + cfg_zero = RqsGenerator( id="gen-1", avg_active_users=RVConfig(mean=0.0, distribution="poisson"), avg_request_per_minute_per_user=RVConfig(mean=60.0, distribution="poisson"), @@ -115,7 +115,7 @@ def test_zero_users_produces_no_events( def test_cumulative_time_never_exceeds_horizon( - rqs_cfg: RqsGeneratorInput, + rqs_cfg: RqsGenerator, sim_settings: SimulationSettings, ) -> None: """Sum of gaps must stay below the simulation horizon.""" diff --git a/tests/unit/samplers/test_sampler_helper.py b/tests/unit/samplers/test_sampler_helper.py index f6b4241..349a5fd 100644 --- a/tests/unit/samplers/test_sampler_helper.py +++ b/tests/unit/samplers/test_sampler_helper.py @@ -17,7 +17,7 @@ truncated_gaussian_generator, uniform_variable_generator, ) -from asyncflow.schemas.random_variables_config import RVConfig +from asyncflow.schemas.common.random_variables import RVConfig # --------------------------------------------------------------------------- # # Dummy RNG # diff --git a/tests/unit/schemas/test_endpoint_input.py b/tests/unit/schemas/test_endpoint.py similarity index 98% rename from tests/unit/schemas/test_endpoint_input.py rename to tests/unit/schemas/test_endpoint.py index 3813dd6..080f55a 100644 --- a/tests/unit/schemas/test_endpoint_input.py +++ b/tests/unit/schemas/test_endpoint.py @@ -11,7 +11,7 @@ EndpointStepRAM, StepOperation, ) -from asyncflow.schemas.system_topology.endpoint import Endpoint, Step +from asyncflow.schemas.topology.endpoint import Endpoint, Step # --------------------------------------------------------------------------- # diff --git a/tests/unit/schemas/test_requests_generator_input.py b/tests/unit/schemas/test_generator.py similarity index 92% rename from tests/unit/schemas/test_requests_generator_input.py rename to tests/unit/schemas/test_generator.py index 66ad037..608adc4 100644 --- a/tests/unit/schemas/test_requests_generator_input.py +++ b/tests/unit/schemas/test_generator.py @@ -1,13 +1,13 @@ -"""Validation tests for RVConfig, RqsGeneratorInput and SimulationSettings.""" +"""Validation tests for RVConfig, RqsGenerator and SimulationSettings.""" from __future__ import annotations import pytest from pydantic import ValidationError from asyncflow.config.constants import Distribution, TimeDefaults -from asyncflow.schemas.random_variables_config import RVConfig -from asyncflow.schemas.rqs_generator_input import RqsGeneratorInput -from asyncflow.schemas.simulation_settings_input import SimulationSettings +from asyncflow.schemas.common.random_variables import RVConfig +from asyncflow.schemas.settings.simulation import SimulationSettings +from asyncflow.schemas.workload.rqs_generator import RqsGenerator # --------------------------------------------------------------------------- # # RVCONFIG # @@ -82,7 +82,7 @@ def test_invalid_distribution_literal_raises() -> None: # --------------------------------------------------------------------------- # -# RQSGENERATORINPUT - USER_SAMPLING_WINDOW & DISTRIBUTION CONSTRAINTS # +# RqsGenerator - USER_SAMPLING_WINDOW & DISTRIBUTION CONSTRAINTS # # --------------------------------------------------------------------------- # @@ -98,7 +98,7 @@ def _valid_normal_cfg(mean: float = 1.0) -> dict[str, float | str]: def test_default_user_sampling_window() -> None: """If user_sampling_window is missing it defaults to the constant.""" - inp = RqsGeneratorInput( + inp = RqsGenerator( id="rqs-1", avg_active_users=_valid_poisson_cfg(), avg_request_per_minute_per_user=_valid_poisson_cfg(), @@ -108,7 +108,7 @@ def test_default_user_sampling_window() -> None: def test_explicit_user_sampling_window_kept() -> None: """An explicit user_sampling_window is preserved.""" - inp = RqsGeneratorInput( + inp = RqsGenerator( id="rqs-1", avg_active_users=_valid_poisson_cfg(), avg_request_per_minute_per_user=_valid_poisson_cfg(), @@ -120,7 +120,7 @@ def test_explicit_user_sampling_window_kept() -> None: def test_user_sampling_window_not_int_raises() -> None: """A non-integer user_sampling_window raises ValidationError.""" with pytest.raises(ValidationError): - RqsGeneratorInput( + RqsGenerator( id="rqs-1", avg_active_users=_valid_poisson_cfg(), avg_request_per_minute_per_user=_valid_poisson_cfg(), @@ -132,7 +132,7 @@ def test_user_sampling_window_above_max_raises() -> None: """user_sampling_window above the max constant raises ValidationError.""" too_large = TimeDefaults.MAX_USER_SAMPLING_WINDOW + 1 with pytest.raises(ValidationError): - RqsGeneratorInput( + RqsGenerator( id="rqs-1", avg_active_users=_valid_poisson_cfg(), avg_request_per_minute_per_user=_valid_poisson_cfg(), @@ -143,7 +143,7 @@ def test_user_sampling_window_above_max_raises() -> None: def test_avg_request_must_be_poisson() -> None: """avg_request_per_minute_per_user must be Poisson; Normal raises.""" with pytest.raises(ValidationError): - RqsGeneratorInput( + RqsGenerator( id="rqs-1", avg_active_users=_valid_poisson_cfg(), avg_request_per_minute_per_user=_valid_normal_cfg(), @@ -154,7 +154,7 @@ def test_avg_active_users_invalid_distribution_raises() -> None: """avg_active_users cannot be Exponential; only Poisson or Normal allowed.""" bad_cfg = {"mean": 1.0, "distribution": Distribution.EXPONENTIAL} with pytest.raises(ValidationError): - RqsGeneratorInput( + RqsGenerator( id="rqs-1", avg_active_users=bad_cfg, avg_request_per_minute_per_user=_valid_poisson_cfg(), @@ -163,7 +163,7 @@ def test_avg_active_users_invalid_distribution_raises() -> None: def test_valid_poisson_poisson_configuration() -> None: """Poisson-Poisson combo is accepted.""" - cfg = RqsGeneratorInput( + cfg = RqsGenerator( id="rqs-1", avg_active_users=_valid_poisson_cfg(), avg_request_per_minute_per_user=_valid_poisson_cfg(), @@ -177,7 +177,7 @@ def test_valid_poisson_poisson_configuration() -> None: def test_valid_normal_poisson_configuration() -> None: """Normal-Poisson combo is accepted.""" - cfg = RqsGeneratorInput( + cfg = RqsGenerator( id="rqs-1", avg_active_users=_valid_normal_cfg(), avg_request_per_minute_per_user=_valid_poisson_cfg(), diff --git a/tests/unit/schemas/test_full_topology_input.py b/tests/unit/schemas/test_topology.py similarity index 97% rename from tests/unit/schemas/test_full_topology_input.py rename to tests/unit/schemas/test_topology.py index 4e27562..0ef53e0 100644 --- a/tests/unit/schemas/test_full_topology_input.py +++ b/tests/unit/schemas/test_topology.py @@ -13,15 +13,15 @@ SystemEdges, SystemNodes, ) -from asyncflow.schemas.random_variables_config import RVConfig -from asyncflow.schemas.system_topology.endpoint import Endpoint, Step -from asyncflow.schemas.system_topology.full_system_topology import ( +from asyncflow.schemas.common.random_variables import RVConfig +from asyncflow.schemas.topology.edges import Edge +from asyncflow.schemas.topology.endpoint import Endpoint, Step +from asyncflow.schemas.topology.graph import TopologyGraph +from asyncflow.schemas.topology.nodes import ( Client, - Edge, LoadBalancer, Server, ServerResources, - TopologyGraph, TopologyNodes, )