diff --git a/backend/druks/api/server.py b/backend/druks/api/server.py index 9b462a04..621ea405 100644 --- a/backend/druks/api/server.py +++ b/backend/druks/api/server.py @@ -32,7 +32,7 @@ create_async_engine_from_url, session_scope, ) -from druks.durable.engine import init_dbos, launch, shutdown +from druks.durable.engine import configure_engine, init_dbos, launch, shutdown from druks.durable.exceptions import AgentCallNotFound from druks.events.routes import router as events_router from druks.exceptions import ObjectNotFound @@ -67,6 +67,8 @@ def configure_state(app: FastAPI, settings: Settings) -> None: # Bind the ambient (``scoped_session``) factory to this engine so # request handlers can use ``db_session()`` without per-call setup. configure_session(app.state.engine) + # Steps run on the serving loop, so they share the request pool. + configure_engine(app.state.engine) @asynccontextmanager diff --git a/backend/druks/database.py b/backend/druks/database.py index 632d52ce..4fd793d0 100644 --- a/backend/druks/database.py +++ b/backend/druks/database.py @@ -114,14 +114,14 @@ def create_async_engine_from_url(database_url: str): # it. The pool serves every concurrent run's steps plus request handling # at once: a modest steady pool, with overflow doing the burst work — # overflow connections open on demand and close on return, so the ceiling - # is high while idle cost is not. Ceiling 50 keeps the appliance (with - # DBOS's two engines at 20 each) inside Postgres's default 100 connections. + # is high while idle cost is not. + settings = load_settings() return create_async_engine( database_url, pool_pre_ping=True, pool_timeout=5, - pool_size=20, - max_overflow=30, + pool_size=settings.database_pool_size, + max_overflow=settings.database_max_overflow, ) diff --git a/backend/druks/durable/engine.py b/backend/druks/durable/engine.py index 2afe3ce5..3a79d997 100644 --- a/backend/druks/durable/engine.py +++ b/backend/druks/durable/engine.py @@ -44,6 +44,7 @@ def init_dbos() -> None: config: DBOSConfig = { "name": "druks", "system_database_url": url, + "sys_db_pool_size": settings.dbos_pool_size, "dbos_system_schema": DBOS_SYSTEM_SCHEMA, "log_level": settings.log_level, # One constant application version. DBOS recovers only the runs whose version diff --git a/backend/druks/settings.py b/backend/druks/settings.py index 5292a5ef..a32c9f5a 100644 --- a/backend/druks/settings.py +++ b/backend/druks/settings.py @@ -178,6 +178,10 @@ class Settings(BaseSettings): default="postgresql+psycopg://druks:druks@localhost:5432/druks", alias="DRUKS_DATABASE_URL", ) + # SQLAlchemy reads a pool size of 0 as unbounded. + database_pool_size: int = Field(default=20, gt=0, alias="DRUKS_DATABASE_POOL_SIZE") + database_max_overflow: int = Field(default=30, ge=0, alias="DRUKS_DATABASE_MAX_OVERFLOW") + dbos_pool_size: int = Field(default=20, gt=0, alias="DRUKS_DBOS_POOL_SIZE") # Transport only — GitHub credentials live on the service-identity row, # not in Settings; this points every client at a compatible API endpoint. diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index 85e6ead7..0a1928e9 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -2,6 +2,8 @@ from pathlib import Path import pytest +from druks import database +from druks.durable import engine as durable_engine from druks.settings import Settings, ensure_data_dirs from druks.testing import make_settings from pydantic import ValidationError @@ -184,3 +186,20 @@ def test_development_example_pins_the_installation_timezone(tmp_path, monkeypatc monkeypatch.setenv("DRUKS_CONFIG", str(config)) monkeypatch.setenv("TIMEZONE", "Asia/Tokyo") assert Settings(secrets={"secrets_key": _SECRETS_KEY}).timezone == "UTC" + + +def test_pool_settings_reach_the_app_engine_and_dbos(tmp_path, monkeypatch): + settings = make_settings( + tmp_path, database_pool_size=3, database_max_overflow=4, dbos_pool_size=5 + ) + monkeypatch.setattr(database, "load_settings", lambda: settings) + monkeypatch.setattr(durable_engine, "load_settings", lambda: settings) + monkeypatch.setattr(durable_engine, "_initialized", False) + configs = [] + monkeypatch.setattr(durable_engine, "DBOS", lambda config: configs.append(config)) + + pool = database.create_async_engine_from_url(settings.database_url).pool + durable_engine.init_dbos() + + assert (pool.size(), pool._max_overflow) == (3, 4) + assert configs[0]["sys_db_pool_size"] == 5 diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 827756e3..fbaa447e 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -98,7 +98,7 @@ services: "--host", "${DRUKS_WEB_BIND_HOST:-127.0.0.1}", "--port", - "8001", + "${DRUKS_WEB_PORT:-8001}", ] drukbox: @@ -206,7 +206,7 @@ services: network_mode: host environment: POSTGRES_USER: ${DRUKS_POSTGRES_USER:-druks} - POSTGRES_PASSWORD: ${DRUKS_POSTGRES_PASSWORD:?set DRUKS_POSTGRES_PASSWORD in .env — druks setup generates one} + POSTGRES_PASSWORD: ${DRUKS_POSTGRES_PASSWORD} POSTGRES_DB: ${DRUKS_POSTGRES_DB:-druks} volumes: - postgres_data:/var/lib/postgresql/data diff --git a/docs/configuration.md b/docs/configuration.md index a41cf572..2c146f98 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -131,6 +131,9 @@ rejects execution settings. The installation API rejects timezone changes. | Variable | Default | Purpose | | --- | --- | --- | | `DRUKS_DATABASE_URL` | local `druks` Postgres | Runtime and DBOS database | +| `DRUKS_DATABASE_POOL_SIZE` | `20` | Connections each process keeps open for requests and workflow steps | +| `DRUKS_DATABASE_MAX_OVERFLOW` | `30` | Extra connections each process opens under load and closes after use | +| `DRUKS_DBOS_POOL_SIZE` | `20` | Connections each process keeps for DBOS | | `DRUKS_TEST_DATABASE_URL` | local `druks_test` Postgres | What the shipped pytest fixtures use — never the runtime's | | `DRUKS_TEST_REDIS_URL` | `redis://127.0.0.1:6379/15` | What the shipped pytest fixtures flush | | `DRUKS_REDIS_URL` | `redis://127.0.0.1:6379/0` | Short-lived coordination and caches | @@ -138,6 +141,11 @@ rejects execution settings. The installation API rejects timezone changes. | `DRUKS_HARNESS_CONFIG_ROOT` | `~/.config/druks/harnesses` | Optional harness configuration copied into sandboxes | | `DRUKS_LOG_LEVEL` | `INFO` | Python and DBOS log level | +Each Druks process can open up to `DRUKS_DATABASE_POOL_SIZE` + +`DRUKS_DATABASE_MAX_OVERFLOW` + `DRUKS_DBOS_POOL_SIZE` + 1 Postgres connections. +The extra connection is the DBOS notification listener. With the defaults, this +is 71 of the 100 connections that Postgres allows by default. + Postgres stores durable state. Redis does not store workflow state. It supports short-lived concerns including webhook delivery claims, OAuth state and token caches, and the sandbox provisioning gate. @@ -181,8 +189,8 @@ order: can access Druks. Account values are case-insensitive. 3. **JWT mode (`jwt`).** This mode uses the assertion channel from `header` mode, but its value is a signed JWT. Druks validates the RS256 signature against - `identity.jwks_url`. It caches keys for five minutes and gets new keys after - rotation. + `identity.jwks_url`. It caches keys for one hour. A token signed with an + unknown key ID makes Druks fetch the keys again at once. The `exp`, `iss`, and `aud` claims must match the configuration. Druks resolves `identity.jwt_identity_claim` in the verified payload and maps