mirror of
https://github.com/status-im/status-bot.git
synced 2026-08-31 04:31:15 +00:00
96 lines
5.4 KiB
Markdown
96 lines
5.4 KiB
Markdown
# Database Layer Architecture
|
|
|
|
## Overview
|
|
|
|
The bot persists data through a small **ORM-only** database layer built on SQLAlchemy, backed by Postgres in production and SQLite for tests and demos. The layer exists to give every module a simple, safe, and collision-free way to persist its state without touching core code: modules define SQLAlchemy models that are discovered automatically, and their tables are created in a **single `create_all` pass once every module is loaded**.
|
|
|
|
The layer makes three deliberate trade-offs:
|
|
|
|
- **ORM-first, always.** No dataframes, no raw-dict bulk inserts, no SQL string building in modules. All persistence goes through ORM models and sessions.
|
|
- **One shared metadata.** Every model — core and module-defined alike — lives on the same declarative `Base.metadata`, so one `create_all` covers the whole schema.
|
|
- **`create_all` as the MVP schema strategy.** Tables are created (not migrated); schema evolution is out of scope for now (see *Future work*).
|
|
|
|
## Components
|
|
|
|
| Component | Where | Role |
|
|
|---|---|---|
|
|
| `Database` | `status_bot/database.py` | Wraps the engine and session factory. ORM-only surface: `session()`, `create_tables(tables=None)`, `execute(sql)`, `close()`. Supports `postgres` and `sqlite` URL schemes. |
|
|
| `Base` | `status_bot/models/base.py` | SQLAlchemy `DeclarativeBase`. The single registry every model registers on. |
|
|
| `namespace(prefix)` | `status_bot/models/base.py` | Decorator that renames a model's table to `{prefix}_{name}` so module tables can't collide with core tables. Rejects empty prefixes, idempotent. |
|
|
| `model_by_table(name)` | `status_bot/models/base.py` | Scans `Base.registry.mappers` at call time and returns the mapped class for a table name, or `None`. Dynamic, so it sees late-registered and namespaced models. |
|
|
| Core models | `status_bot/models/` | `ReceivedMessage`, `ReceivedChat`, `Community`, `Channel`. |
|
|
| `ModuleContext.db` | `status_bot/modules/base.py` | How a module receives the shared `Database` instance — or `None` when no database is configured. |
|
|
|
|
All of `Base`, `namespace`, `model_by_table`, and `Database` are re-exported from the top-level `status_bot` package for a single import point.
|
|
|
|
## Startup flow (two-phase table initialization)
|
|
|
|
Tables must be created *after* module models exist. The flow in `main.py` is therefore:
|
|
|
|
```mermaid
|
|
flowchart TD
|
|
A[Load config / config.yaml + env] --> B[create_bot]
|
|
B --> C[init_database: build Database, no tables yet]
|
|
C --> D[discover_modules]
|
|
D --> E[load_modules]
|
|
E --> F[db.create_tables - one create_all for all models]
|
|
F --> G[start_prometheus]
|
|
G --> H[start_all: one daemon thread per module]
|
|
F --> I[(Postgres / SQLite)]
|
|
```
|
|
|
|
```text
|
|
1. init_database() # engine + session factory, tables NOT created yet
|
|
2. discover_modules()# importing module files registers their models on Base.metadata
|
|
3. load_modules() # instantiate modules (each receives ModuleContext.db)
|
|
4. db.create_tables()# create_all now covers core + module tables
|
|
5. start_all() # modules run as daemon threads
|
|
```
|
|
|
|
This is what makes the design work: a model defined at module level in any module file is simply present on `Base.metadata` by the time `create_tables()` runs. No core edits are needed to add a table.
|
|
|
|
## The dynamic model registry
|
|
|
|
Model lookups are not cached in a frozen import-time map (a fixed dict built at package import would never see models registered later). Instead, `model_by_table()` walks `Base.registry.mappers` on each call and matches on `mapper.local_table.name`. Consequences:
|
|
|
|
- Late-registered models are always discoverable.
|
|
- Namespaced names (`mymodule_visit`) resolve correctly.
|
|
- Unknown names resolve to `None`, which callers treat as "no model for this table".
|
|
|
|
## Namespacing module tables
|
|
|
|
Modules avoid table collisions by prefixing their model:
|
|
|
|
```python
|
|
from status_bot import Base, namespace
|
|
|
|
@namespace("mymodule")
|
|
class Visit(Base):
|
|
__tablename__ = "visit" # physically "mymodule_visit"
|
|
```
|
|
|
|
The decorator renames `__tablename__` and `__table__.name` after class creation; the model stays on the shared `Base.metadata`, so the single `create_all` still covers it. Because a module file is imported during `discover_modules()`, module-level model definitions are registered automatically.
|
|
|
|
## Writing and reading data
|
|
|
|
`Database` never exchanges dataframes — modules build ORM objects and commit through short-lived sessions:
|
|
|
|
```python
|
|
with self.ctx.db.session() as session:
|
|
session.add_all(rows)
|
|
session.commit()
|
|
|
|
with self.ctx.db.session() as session:
|
|
rows = session.execute(select(Visit)).scalars().all()
|
|
```
|
|
|
|
Built-in modules follow the same contract: `receiver` converts raw event dicts directly into `ReceivedMessage`/`ReceivedChat` instances and writes them in one batch; `communities_monitoring` uses `session.merge()` for primary-key upserts. Since `self.ctx.db` may be `None`, modules always guard before touching the database.
|
|
|
|
## Thread model
|
|
|
|
Each module runs in its own daemon thread via the `ModuleManager`. The layer provides a session factory but no shared session, which is deliberate: SQLAlchemy sessions are not thread-safe, so modules are expected to open a fresh session per operation, and never share one across threads. The single shared object is the engine, which is thread-safe.
|
|
|
|
## Future work
|
|
|
|
- **Versioned migrations (Alembic).** `create_all` handles green-field schema only. Once the schema is established in production, adding/altering columns needs real migrations.
|