|
| 1 | +# Architecture Rules: SQL-to-ARC Middleware |
| 2 | + |
| 3 | +This document defines **binding rules** for the `middleware/sql_to_arc` package. |
| 4 | +These constraints exist to preserve correctness, prevent circular imports, and enforce design patterns. |
| 5 | +An AI assistant or developer modifying this codebase MUST follow these rules. |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## 1. Module Dependency Graph |
| 10 | + |
| 11 | +`sql_to_arc` is a single, self-contained component. There is no complex inter-package dependency policy to enforce within it. |
| 12 | + |
| 13 | +The one cross-package rule is: |
| 14 | + |
| 15 | +> `middleware.shared` and `middleware.api_client` are **read-only dependencies** of `sql_to_arc`. |
| 16 | +> They must NEVER import from `middleware.sql_to_arc`. This is naturally enforced since both packages live in a separate repository. |
| 17 | +
|
| 18 | +If intra-package layering rules become necessary in the future (e.g., forbidden imports between specific modules), document them here. |
| 19 | + |
| 20 | +--- |
| 21 | + |
| 22 | +## 2. Extension Points |
| 23 | + |
| 24 | +### 2.1 Adding a New Database Entity |
| 25 | + |
| 26 | +When adding a new entity type (e.g. `SampleRow`), ALL of the following steps are mandatory: |
| 27 | + |
| 28 | +1. **Define the model** in `models.py` by subclassing `BaseRow`: |
| 29 | + |
| 30 | + ```python |
| 31 | + class SampleRow(BaseRow): |
| 32 | + __view_name__: ClassVar[str] = "vSample" |
| 33 | + identifier: str = spec_field(required=True) |
| 34 | + ... |
| 35 | + ``` |
| 36 | + |
| 37 | + - Use `spec_field()` (not `Field()` directly) for all ARC-spec-relevant fields. |
| 38 | + - Set `__view_name__` to the exact database view name. |
| 39 | + |
| 40 | +2. **Add a streaming method** to `Database` in `database.py`: |
| 41 | + |
| 42 | + ```python |
| 43 | + async def stream_samples(self, investigation_ids: list[str]) -> AsyncGenerator[SampleRow, None]: |
| 44 | + async for r in self._stream_by_investigation(SampleRow, investigation_ids, "sample"): |
| 45 | + yield r |
| 46 | + ``` |
| 47 | + |
| 48 | +3. **Register for schema validation** in `Database.validate_schema()`: |
| 49 | + |
| 50 | + ```python |
| 51 | + models = [..., SampleRow] |
| 52 | + ``` |
| 53 | + |
| 54 | +4. **Add a mapper function** in `mapper.py`. |
| 55 | + |
| 56 | +5. **Link into the data bundle** `ArcBuildData` in `context.py` and populate it in `_fetch_and_group_related_data()` in `processor.py` using `group_stream()`. |
| 57 | + |
| 58 | +6. **Call the mapper** inside `builder.py` in `build_single_arc_task()`. |
| 59 | + |
| 60 | +### 2.2 Adding New Mapper Functions |
| 61 | + |
| 62 | +- Mapper functions live exclusively in `mapper.py`. |
| 63 | +- They accept a single `*Row` Pydantic model as input and return an `arctrl` type. |
| 64 | +- They MUST NOT perform I/O, logging, or access the database. |
| 65 | + |
| 66 | +### 2.3 Adding New Configuration Values |
| 67 | + |
| 68 | +- All configuration values MUST be added as typed, annotated fields in the `Config` class in `config.py` or in other config classes that are referenced by `Config`. |
| 69 | +- MUST use `Annotated[..., Field(description="...")]` with a meaningful description. |
| 70 | +- Provide a sensible default whenever possible. |
| 71 | +- **NEVER** access `os.environ` directly in any module. The `Config` object is the single source of truth for all settings. |
| 72 | +- **NEVER** introduce new environment variables outside of `Config`. |
| 73 | + |
| 74 | +--- |
| 75 | + |
| 76 | +## 3. Concurrency & IPC Rules |
| 77 | + |
| 78 | +### 3.1 Process Pool Entry Point |
| 79 | + |
| 80 | +- `build_single_arc_task()` in `builder.py` is the **only function** executed inside worker processes. |
| 81 | +- It MUST be a plain, top-level function (not a method or lambda) because it is pickled for IPC. |
| 82 | +- Its argument MUST be the frozen dataclass `ArcBuildData` (picklable, no locks, no sockets). |
| 83 | +- Its return value MUST be a `str` (JSON-LD string) or `None`. Returning complex objects (e.g., `ARC`) is forbidden — they are not reliably picklable across process boundaries and waste IPC bandwidth. |
| 84 | + |
| 85 | +### 3.2 Memory Management in Workers |
| 86 | + |
| 87 | +- After serializing the ARC to JSON, `del arc` MUST be called, followed by `gc.collect()`. |
| 88 | +- This prevents worker processes from accumulating memory across repeated calls. |
| 89 | + |
| 90 | +### 3.3 Semaphore Usage |
| 91 | + |
| 92 | +- The `asyncio.Semaphore` (from `config.max_concurrent_tasks`) limits the **full lifecycle** of each investigation: data bundling → CPU build → API upload. |
| 93 | +- It is acquired inside `_build_and_upload_single_arc()`. |
| 94 | +- NEVER acquire the semaphore in a different scope (e.g. before spawning a task). |
| 95 | +- Do NOT use `asyncio.Semaphore` as a substitute for the process pool limit. Both controls serve different purposes: the semaphore manages memory/IO, the `ProcessPoolExecutor` manages CPU. |
| 96 | + |
| 97 | +--- |
| 98 | + |
| 99 | +## 4. Error Handling Rules |
| 100 | + |
| 101 | +- A failure for one investigation MUST NOT abort the entire run. |
| 102 | +- Catch expected errors at the point closest to the failure (`_upload_and_update_stats`, `_build_and_upload_single_arc`). |
| 103 | +- On failure: increment `stats.failed_datasets` and append the identifier to `stats.failed_ids`. |
| 104 | +- Re-raise unexpected errors (i.e., programming errors) so they are visible immediately. |
| 105 | +- NEVER use bare `except Exception` as the final catch — only use it in `process_investigations`'s batch loop where it is immediately re-raised after logging. |
| 106 | + |
| 107 | +--- |
| 108 | + |
| 109 | +## 5. Configuration & Secrets |
| 110 | + |
| 111 | +- Configuration is loaded once in `main.py` via `ConfigWrapper` from `middleware.shared`. |
| 112 | +- The resulting `Config` object is passed explicitly to functions that need it (dependency injection). |
| 113 | +- Secrets (e.g. `connection_string`, API keys) use `pydantic.SecretStr`. Never log them with `str()` directly; use `.get_secret_value()` only at the point of use (e.g., engine creation). |
| 114 | + |
| 115 | +--- |
| 116 | + |
| 117 | +## 6. Logging Conventions |
| 118 | + |
| 119 | +- Every module defines: `logger = logging.getLogger(__name__)`. |
| 120 | +- Do NOT use `print()` for any diagnostic output. |
| 121 | +- Log messages that occur inside concurrent tasks MUST include a traceability prefix. Use the pattern `"%s: message", inv_info` (see `inv_info` in `processor.py`) so parallel log lines are distinguishable. |
| 122 | +- Log levels: |
| 123 | + - `DEBUG`: internal state, loop iterations. |
| 124 | + - `INFO`: successful milestones (fetch, build, upload). |
| 125 | + - `WARNING`: recoverable issues (missing optional column, assay without study). |
| 126 | + - `ERROR`: per-item failures (build failed, upload failed). Do not use for fatal errors. |
| 127 | + |
| 128 | +--- |
| 129 | + |
| 130 | +## 7. Database Access Rules |
| 131 | + |
| 132 | +- All database reads go through the `Database` class in `database.py`. No other module is allowed to instantiate `AsyncEngine` or execute SQL directly. |
| 133 | +- Use `stream_results=True` on all large queries to enable server-side cursors and avoid loading full tables into RAM. |
| 134 | +- Use `literal_column("*")` with `select()` rather than ORM field mappings to generate clean `SELECT *` SQL. |
| 135 | +- Related data (studies, assays, etc.) is ALWAYS fetched in bulk per batch using `WHERE investigation_ref IN (...)`. Never fetch related data row-by-row in a loop. |
0 commit comments