Files
fengqun/swarm_minimal/azure_store.py
T
gongzhiyong 111be3e435 Promote the minimal swarm prototype to the repository root
The standalone prototype should be the root-level project shape for fengqun while preserving the existing planning documents already at the root. This keeps README, examples, tests, and the Python package directly discoverable without deleting the prior docs.

Constraint: User clarified that swarm-minimal is the repository root, but other existing root files must remain.
Rejected: Deleting existing root docs | They are part of the fengqun repository context and were explicitly protected.
Confidence: high
Scope-risk: narrow
Directive: Keep secrets in ignored .env only; do not commit live credentials.
Tested: python3 -B -m unittest discover -s tests; git diff --check; secret-pattern scan showed only placeholders/test values/task-id false positives.
Not-tested: Remote web UI rendering after push.
2026-05-16 13:32:11 +08:00

408 lines
15 KiB
Python

"""Optional Azure-backed store for the minimal swarm.
The default prototype uses ``InMemorySwarmStore``. This module keeps the same
coordinator API but persists the four shared resources to PostgreSQL, Redis, and
Blob Storage when Azure credentials are supplied through environment variables.
"""
from __future__ import annotations
from dataclasses import asdict
import json
from typing import Any, Callable, TypeVar
from .config import SwarmConfig
from .core import Agent, InMemorySwarmStore, SwarmResult, Task
T = TypeVar("T")
class OptionalDependencyError(RuntimeError):
pass
def _import_optional_dependencies() -> tuple[Any, Any, Any]:
try:
import psycopg
import redis
from azure.storage.blob import BlobServiceClient
except ImportError as exc:
raise OptionalDependencyError(
"Azure store dependencies are missing. Install with: "
"pip install -r requirements-azure.txt"
) from exc
return psycopg, redis, BlobServiceClient
class PostgresRedisBlobSwarmStore(InMemorySwarmStore):
"""Persist the minimal swarm resources to Azure-backed services."""
def __init__(self, config: SwarmConfig) -> None:
super().__init__()
psycopg, redis_module, blob_service_client = _import_optional_dependencies()
self._psycopg = psycopg
self._redis_module = redis_module
self.config = config
self._pg_connect_kwargs = {
"host": config.postgres.host,
"port": config.postgres.port,
"dbname": config.postgres.database,
"user": config.postgres.user,
"password": config.postgres.password,
"sslmode": "require",
"connect_timeout": 10,
"autocommit": True,
}
self._redis_connect_kwargs = {
"host": config.redis.host,
"port": config.redis.port,
"password": config.redis.password,
"ssl": config.redis.ssl,
"decode_responses": True,
"socket_timeout": 10,
"socket_connect_timeout": 10,
}
self.pg = self._connect_pg()
self.redis = self._connect_redis()
self.blob_service = blob_service_client.from_connection_string(config.blob.connection_string)
self.container = self.blob_service.get_container_client(config.blob.container)
def _connect_pg(self):
return self._psycopg.connect(
**self._pg_connect_kwargs,
)
def _reconnect_pg(self) -> None:
try:
self.pg.close()
except Exception:
pass
self.pg = self._connect_pg()
def _is_recoverable_pg_error(self, exc: Exception) -> bool:
recoverable = tuple(
error_type
for error_type in (
getattr(self._psycopg, "OperationalError", None),
getattr(self._psycopg, "InterfaceError", None),
)
if error_type is not None
)
return bool(recoverable and isinstance(exc, recoverable))
def _run_pg(self, operation: Callable[[Any], T]) -> T:
last_error: Exception | None = None
for attempt in range(2):
try:
with self.pg.cursor() as cur:
return operation(cur)
except Exception as exc:
last_error = exc
if attempt == 0 and self._is_recoverable_pg_error(exc):
self._reconnect_pg()
continue
raise
if last_error is not None:
raise last_error
raise RuntimeError("PostgreSQL operation did not execute")
def _connect_redis(self):
return self._redis_module.Redis(**self._redis_connect_kwargs)
def _reconnect_redis(self) -> None:
try:
self.redis.close()
except Exception:
pass
self.redis = self._connect_redis()
def _is_recoverable_redis_error(self, exc: Exception) -> bool:
recoverable = tuple(
error_type
for error_type in (
getattr(self._redis_module.exceptions, "ConnectionError", None),
getattr(self._redis_module.exceptions, "TimeoutError", None),
)
if error_type is not None
)
return bool(recoverable and isinstance(exc, recoverable))
def _run_redis(self, operation: Callable[[Any], T]) -> T:
last_error: Exception | None = None
for attempt in range(2):
try:
return operation(self.redis)
except Exception as exc:
last_error = exc
if attempt == 0 and self._is_recoverable_redis_error(exc):
self._reconnect_redis()
continue
raise
if last_error is not None:
raise last_error
raise RuntimeError("Redis operation did not execute")
@classmethod
def from_env(cls) -> "PostgresRedisBlobSwarmStore":
return cls(SwarmConfig.from_env())
def close(self) -> None:
try:
self.pg.close()
finally:
self.redis.close()
def ensure_schema(self) -> None:
statements = [
"""
create table if not exists swarm_tasks (
id text primary key,
kind text not null,
input text not null,
status text not null,
claimed_by text,
output text,
score double precision not null default 0,
error text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
)
""",
"""
create table if not exists swarm_pheromones (
task_id text primary key references swarm_tasks(id) on delete cascade,
score double precision not null default 0,
updated_at timestamptz not null default now()
)
""",
"""
create table if not exists swarm_shared_state (
key text primary key,
value text not null,
updated_at timestamptz not null default now()
)
""",
"""
create table if not exists swarm_observations (
id bigserial primary key,
task_id text not null,
agent_id text not null,
signal text not null,
score_delta double precision not null,
created_at timestamptz not null default now()
)
""",
"""
create table if not exists swarm_convergence (
run_id text primary key,
goal text not null,
accepted_output text not null,
accepted_task_id text not null,
accepted_score double precision not null,
completed_tasks integer not null,
observations jsonb not null,
artifact_path text,
created_at timestamptz not null default now()
)
""",
"""
create table if not exists swarm_outbox (
id bigserial primary key,
event_type text not null,
payload jsonb not null,
published boolean not null default false,
created_at timestamptz not null default now()
)
""",
]
def operation(cur) -> None:
for statement in statements:
cur.execute(statement)
self._run_pg(operation)
try:
self.container.create_container()
except Exception as exc:
if exc.__class__.__name__ != "ResourceExistsError":
raise
def add_task(self, task: Task) -> None:
super().add_task(task)
def operation(cur) -> None:
cur.execute(
"""
insert into swarm_tasks (id, kind, input, status, score)
values (%s, %s, %s, %s, %s)
on conflict (id) do update set
kind = excluded.kind,
input = excluded.input,
status = excluded.status,
score = excluded.score,
updated_at = now()
""",
(task.id, task.kind, task.input, task.status.value, task.score),
)
cur.execute(
"""
insert into swarm_pheromones (task_id, score)
values (%s, %s)
on conflict (task_id) do update set score = excluded.score, updated_at = now()
""",
(task.id, 0.0),
)
self._run_pg(operation)
self._emit_event("task.created", {"task_id": task.id, "kind": task.kind})
def claim_next(self, agent: Agent) -> Task | None:
task = super().claim_next(agent)
if task is None:
return None
def operation(cur) -> None:
cur.execute(
"""
update swarm_tasks
set status = %s, claimed_by = %s, updated_at = now()
where id = %s
""",
(task.status.value, agent.id, task.id),
)
self._run_pg(operation)
self._run_redis(lambda redis: redis.set(f"swarm:agent:{agent.id}:heartbeat", "alive", ex=60))
self._emit_event("task.claimed", {"task_id": task.id, "agent_id": agent.id})
self._persist_shared_state()
return task
def complete_task(self, task: Task, agent: Agent, output: str, score: float) -> None:
super().complete_task(task, agent, output, score)
def operation(cur) -> None:
cur.execute(
"""
update swarm_tasks
set status = %s, output = %s, score = %s, updated_at = now()
where id = %s
""",
(task.status.value, output, score, task.id),
)
cur.execute(
"""
insert into swarm_pheromones (task_id, score)
values (%s, %s)
on conflict (task_id) do update set score = excluded.score, updated_at = now()
""",
(task.id, self.pheromones[task.id]),
)
observation = self.observations[-1]
cur.execute(
"""
insert into swarm_observations (task_id, agent_id, signal, score_delta)
values (%s, %s, %s, %s)
""",
(observation.task_id, observation.agent_id, observation.signal, observation.score_delta),
)
self._run_pg(operation)
self._run_redis(lambda redis: redis.zadd("swarm:pheromones", {task.id: self.pheromones[task.id]}))
self._emit_event("task.done", {"task_id": task.id, "agent_id": agent.id})
self._persist_shared_state()
def fail_task(self, task: Task, agent: Agent, error: str) -> None:
super().fail_task(task, agent, error)
def operation(cur) -> None:
cur.execute(
"""
update swarm_tasks
set status = %s, error = %s, updated_at = now()
where id = %s
""",
(task.status.value, error, task.id),
)
observation = self.observations[-1]
cur.execute(
"""
insert into swarm_observations (task_id, agent_id, signal, score_delta)
values (%s, %s, %s, %s)
""",
(observation.task_id, observation.agent_id, observation.signal, observation.score_delta),
)
self._run_pg(operation)
self._emit_event("task.failed", {"task_id": task.id, "agent_id": agent.id})
self._persist_shared_state()
def converge(self, run_id: str, goal: str) -> SwarmResult:
result = super().converge(run_id, goal)
artifact_path = f"swarm-runs/{run_id}/result.json"
payload = {
"run_id": result.run_id,
"goal": result.goal,
"accepted_output": result.accepted_output,
"accepted_task_id": result.accepted_task_id,
"accepted_score": result.accepted_score,
"completed_tasks": result.completed_tasks,
"observations": [asdict(observation) for observation in result.observations],
}
self.container.upload_blob(
name=artifact_path,
data=json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8"),
overwrite=True,
)
def operation(cur) -> None:
cur.execute(
"""
insert into swarm_convergence (
run_id, goal, accepted_output, accepted_task_id, accepted_score,
completed_tasks, observations, artifact_path
)
values (%s, %s, %s, %s, %s, %s, %s, %s)
on conflict (run_id) do update set
accepted_output = excluded.accepted_output,
accepted_task_id = excluded.accepted_task_id,
accepted_score = excluded.accepted_score,
completed_tasks = excluded.completed_tasks,
observations = excluded.observations,
artifact_path = excluded.artifact_path
""",
(
result.run_id,
result.goal,
result.accepted_output,
result.accepted_task_id,
result.accepted_score,
result.completed_tasks,
json.dumps(payload["observations"]),
artifact_path,
),
)
self._run_pg(operation)
self._emit_event("run.converged", {"run_id": run_id})
self._persist_shared_state()
return result
def _persist_shared_state(self) -> None:
def operation(cur) -> None:
for key, value in self.shared_state.items():
cur.execute(
"""
insert into swarm_shared_state (key, value)
values (%s, %s)
on conflict (key) do update set value = excluded.value, updated_at = now()
""",
(key, value),
)
self._run_pg(operation)
def _emit_event(self, event_type: str, payload: dict[str, object]) -> None:
def operation(cur) -> None:
cur.execute(
"insert into swarm_outbox (event_type, payload) values (%s, %s)",
(event_type, json.dumps(payload)),
)
self._run_pg(operation)
event_payload = {"type": event_type, **{key: str(value) for key, value in payload.items()}}
self._run_redis(lambda redis: redis.xadd("swarm:events", event_payload))