Split databases and Redis — prepare infrastructure for per-domain isolation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m20s

Redis: per-app DB index (0-5) with shared auth DB 15 for SSO keys;
flushdb replaces flushall so deploys don't wipe cross-app auth state.

Postgres: drop 13 cross-domain FK constraints (migration v2t0p8q9r0),
remove dead ORM relationships, add explicit joins for 4 live ones.
Multi-engine sessions (account + federation) ready for per-domain DBs
via DATABASE_URL_ACCOUNT / DATABASE_URL_FEDERATION env vars.

All URLs initially point to the same appdb — zero behaviour change
until split-databases.sh is run to migrate data to per-domain DBs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-25 02:20:34 +00:00
parent 57d2a6a6e3
commit 580f551700
25 changed files with 459 additions and 102 deletions

View File

@@ -0,0 +1,59 @@
"""Drop cross-domain foreign key constraints.
Columns and indexes remain — only the FK constraints are removed.
This prepares for per-domain databases where cross-DB FKs can't exist.
Revision ID: v2t0p8q9r0
Revises: u1s9o5p7q8
"""
from alembic import op
revision = "v2t0p8q9r0"
down_revision = "u1s9o5p7q8"
def upgrade() -> None:
# blog → account
op.drop_constraint("posts_user_id_fkey", "posts", type_="foreignkey")
op.drop_constraint("post_likes_user_id_fkey", "post_likes", type_="foreignkey")
# market → account
op.drop_constraint("product_likes_user_id_fkey", "product_likes", type_="foreignkey")
# cart → account
op.drop_constraint("cart_items_user_id_fkey", "cart_items", type_="foreignkey")
op.drop_constraint("orders_user_id_fkey", "orders", type_="foreignkey")
# cart → market
op.drop_constraint("cart_items_product_id_fkey", "cart_items", type_="foreignkey")
op.drop_constraint("cart_items_market_place_id_fkey", "cart_items", type_="foreignkey")
op.drop_constraint("order_items_product_id_fkey", "order_items", type_="foreignkey")
# cart → events
op.drop_constraint("orders_page_config_id_fkey", "orders", type_="foreignkey")
# events → account
op.drop_constraint("calendar_entries_user_id_fkey", "calendar_entries", type_="foreignkey")
op.drop_constraint("tickets_user_id_fkey", "tickets", type_="foreignkey")
# federation → account
op.drop_constraint("ap_actor_profiles_user_id_fkey", "ap_actor_profiles", type_="foreignkey")
# shared (blog-internal but cross-concern)
op.drop_constraint("menu_items_post_id_fkey", "menu_items", type_="foreignkey")
def downgrade() -> None:
op.create_foreign_key("posts_user_id_fkey", "posts", "users", ["user_id"], ["id"], ondelete="SET NULL")
op.create_foreign_key("post_likes_user_id_fkey", "post_likes", "users", ["user_id"], ["id"], ondelete="CASCADE")
op.create_foreign_key("product_likes_user_id_fkey", "product_likes", "users", ["user_id"], ["id"], ondelete="CASCADE")
op.create_foreign_key("cart_items_user_id_fkey", "cart_items", "users", ["user_id"], ["id"], ondelete="CASCADE")
op.create_foreign_key("cart_items_product_id_fkey", "cart_items", "products", ["product_id"], ["id"], ondelete="CASCADE")
op.create_foreign_key("cart_items_market_place_id_fkey", "cart_items", "market_places", ["market_place_id"], ["id"], ondelete="SET NULL")
op.create_foreign_key("orders_user_id_fkey", "orders", "users", ["user_id"], ["id"])
op.create_foreign_key("orders_page_config_id_fkey", "orders", "page_configs", ["page_config_id"], ["id"], ondelete="SET NULL")
op.create_foreign_key("order_items_product_id_fkey", "order_items", "products", ["product_id"], ["id"])
op.create_foreign_key("calendar_entries_user_id_fkey", "calendar_entries", "users", ["user_id"], ["id"])
op.create_foreign_key("tickets_user_id_fkey", "tickets", "users", ["user_id"], ["id"])
op.create_foreign_key("ap_actor_profiles_user_id_fkey", "ap_actor_profiles", "users", ["user_id"], ["id"], ondelete="CASCADE")
op.create_foreign_key("menu_items_post_id_fkey", "menu_items", "posts", ["post_id"], ["id"], ondelete="CASCADE")

View File

@@ -35,6 +35,72 @@ async def get_session():
await sess.close()
# ---------------------------------------------------------------------------
# Cross-domain sessions — account + federation
#
# Initially DATABASE_URL_ACCOUNT / DATABASE_URL_FEDERATION point to the same
# DB as DATABASE_URL (zero behaviour change). When per-domain DBs are ready,
# switch the env vars to the new connection strings.
# ---------------------------------------------------------------------------
DATABASE_URL_ACCOUNT = (
os.getenv("DATABASE_URL_ACCOUNT") or DATABASE_URL
)
DATABASE_URL_FEDERATION = (
os.getenv("DATABASE_URL_FEDERATION") or DATABASE_URL
)
# Engines are created lazily — only allocate a pool if the URL differs
_account_engine = (
_engine if DATABASE_URL_ACCOUNT == DATABASE_URL
else create_async_engine(
DATABASE_URL_ACCOUNT,
future=True, echo=False, pool_pre_ping=True,
pool_size=3, max_overflow=5,
)
)
_AccountSession = async_sessionmaker(
bind=_account_engine,
class_=AsyncSession,
expire_on_commit=False,
)
_federation_engine = (
_engine if DATABASE_URL_FEDERATION == DATABASE_URL
else create_async_engine(
DATABASE_URL_FEDERATION,
future=True, echo=False, pool_pre_ping=True,
pool_size=3, max_overflow=5,
)
)
_FederationSession = async_sessionmaker(
bind=_federation_engine,
class_=AsyncSession,
expire_on_commit=False,
)
@asynccontextmanager
async def get_account_session():
"""Session targeting the account database (users, grants, oauth codes)."""
sess = _AccountSession()
try:
yield sess
finally:
await sess.close()
@asynccontextmanager
async def get_federation_session():
"""Session targeting the federation database (ap_activities, etc.)."""
sess = _FederationSession()
try:
yield sess
finally:
await sess.close()
def register_db(app: Quart):
@app.before_request

View File

@@ -4,6 +4,11 @@ Unified activity bus.
emit_activity() writes an APActivity row with process_state='pending' within
the caller's existing DB transaction — atomic with the domain change.
When the federation database is separate (DATABASE_URL_FEDERATION differs from
DATABASE_URL), emit_activity() opens its own federation session and commits
independently. Atomicity is traded for domain isolation; handlers are
idempotent, so at-least-once delivery is safe.
register_activity_handler() registers async handler functions that the
EventProcessor dispatches when processing pending activities.
"""
@@ -73,6 +78,12 @@ def get_activity_handlers(
return handlers
def _needs_federation_session() -> bool:
"""True when the federation DB differs from the app's default DB."""
from shared.db.session import DATABASE_URL, DATABASE_URL_FEDERATION
return DATABASE_URL_FEDERATION != DATABASE_URL
# ---------------------------------------------------------------------------
# emit_activity — the primary way to emit events
# ---------------------------------------------------------------------------
@@ -92,8 +103,10 @@ async def emit_activity(
"""
Write an AP-shaped activity to ap_activities with process_state='pending'.
Called inside a service function using the same session that performs the
domain change. The activity and the change commit together.
When all apps share one database the activity is written in the caller's
transaction (atomic with the domain change). When the federation DB is
separate, a dedicated federation session is used and committed
independently.
"""
if not origin_app:
try:
@@ -118,9 +131,17 @@ async def emit_activity(
process_state="pending",
origin_app=origin_app,
)
session.add(activity)
await session.flush()
# Wake any listening EventProcessor as soon as this transaction commits.
# NOTIFY is transactional — delivered only after commit.
await session.execute(text("NOTIFY ap_activity_pending"))
if _needs_federation_session():
from shared.db.session import get_federation_session
async with get_federation_session() as fed_s:
async with fed_s.begin():
fed_s.add(activity)
await fed_s.flush()
await fed_s.execute(text("NOTIFY ap_activity_pending"))
else:
session.add(activity)
await session.flush()
await session.execute(text("NOTIFY ap_activity_pending"))
return activity

View File

@@ -9,6 +9,9 @@ A dedicated asyncpg LISTEN connection wakes the poll loop immediately when
emit_activity() fires NOTIFY ap_activity_pending, so latency drops from
~2 seconds (poll interval) to sub-100 ms. The fixed-interval poll remains
as a safety-net fallback.
The LISTEN connection and poll queries target the federation database
(DATABASE_URL_FEDERATION) since ap_activities lives there.
"""
from __future__ import annotations
@@ -21,7 +24,7 @@ import asyncpg
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from shared.db.session import get_session, DATABASE_URL
from shared.db.session import get_federation_session, DATABASE_URL_FEDERATION
from shared.models.federation import APActivity
from .bus import get_activity_handlers
@@ -89,7 +92,7 @@ class EventProcessor:
async def _listen_for_notify(self) -> None:
"""Maintain a LISTEN connection and wake the poll loop on NOTIFY."""
dsn = DATABASE_URL.replace("+asyncpg", "")
dsn = DATABASE_URL_FEDERATION.replace("+asyncpg", "")
while self._running:
try:
self._listen_conn = await asyncpg.connect(dsn)
@@ -154,7 +157,7 @@ class EventProcessor:
"""
cutoff = datetime.now(timezone.utc) - timedelta(seconds=self._stuck_timeout)
try:
async with get_session() as session:
async with get_federation_session() as session:
filters = [
APActivity.process_state == "processing",
APActivity.created_at < cutoff,
@@ -180,7 +183,7 @@ class EventProcessor:
async def _process_batch(self) -> int:
"""Fetch and process a batch of pending activities. Returns count processed."""
processed = 0
async with get_session() as session:
async with get_federation_session() as session:
filters = [
APActivity.process_state == "pending",
APActivity.process_attempts < APActivity.process_max_attempts,

View File

@@ -0,0 +1,37 @@
"""Shared auth Redis connection (DB 15).
All cross-app auth keys live here so that per-app FLUSHDB on deploy
doesn't wipe SSO state:
- did_auth:{device_id} — login signal timestamp
- grant:{grant_token} — grant validity cache (ok/revoked)
- prompt:{app}:{device_id} — prompt=none cooldown
"""
from __future__ import annotations
import os
from redis import asyncio as aioredis
_AUTH_REDIS_URL = os.getenv("REDIS_AUTH_URL", "redis://redis:6379/15")
_auth_redis: aioredis.Redis | None = None
async def get_auth_redis() -> aioredis.Redis:
"""Return the shared auth Redis connection (lazy init)."""
global _auth_redis
if _auth_redis is None:
_auth_redis = aioredis.Redis.from_url(
_AUTH_REDIS_URL,
encoding="utf-8",
decode_responses=False,
)
return _auth_redis
async def close_auth_redis() -> None:
"""Close the auth Redis connection (call on app shutdown)."""
global _auth_redis
if _auth_redis is not None:
await _auth_redis.close()
_auth_redis = None

View File

@@ -162,17 +162,20 @@ def create_base_app(
uid = qs.get("uid")
grant_token = qs.get("grant_token")
from shared.browser.app.redis_cacher import get_redis
redis = get_redis()
from shared.infrastructure.auth_redis import get_auth_redis
try:
auth_redis = await get_auth_redis()
except Exception:
auth_redis = None
# Case 1: logged in — verify grant still valid (direct DB, cached)
if uid and grant_token:
cache_key = f"grant:{grant_token}"
if redis:
if auth_redis:
# Quick check: if did_auth was cleared (logout), skip cache
device_id = g.device_id
did_auth_present = await redis.get(f"did_auth:{device_id}") if device_id else True
cached = await redis.get(cache_key)
did_auth_present = await auth_redis.get(f"did_auth:{device_id}") if device_id else True
cached = await auth_redis.get(cache_key)
if cached == b"ok" and did_auth_present:
return
if cached == b"revoked":
@@ -183,10 +186,10 @@ def create_base_app(
return
from sqlalchemy import select
from shared.db.session import get_session
from shared.db.session import get_account_session
from shared.models.oauth_grant import OAuthGrant
try:
async with get_session() as s:
async with get_account_session() as s:
grant = await s.scalar(
select(OAuthGrant).where(OAuthGrant.token == grant_token)
)
@@ -194,8 +197,8 @@ def create_base_app(
except Exception:
return # DB error — don't log user out
if redis:
await redis.set(cache_key, b"ok" if valid else b"revoked", ex=60)
if auth_redis:
await auth_redis.set(cache_key, b"ok" if valid else b"revoked", ex=60)
if not valid:
qs.pop("uid", None)
qs.pop("grant_token", None)
@@ -214,8 +217,8 @@ def create_base_app(
# Check if account signalled a login after we cached "not logged in"
# (blog_did == account_did — same value set during OAuth callback)
if device_id and redis and pnone_at:
auth_ts = await redis.get(f"did_auth:{device_id}")
if device_id and auth_redis and pnone_at:
auth_ts = await auth_redis.get(f"did_auth:{device_id}")
if auth_ts:
try:
if float(auth_ts) > pnone_at:
@@ -226,8 +229,8 @@ def create_base_app(
if pnone_at and (now - pnone_at) < 300:
return
if device_id and redis:
cached = await redis.get(f"prompt:{name}:{device_id}")
if device_id and auth_redis:
cached = await auth_redis.get(f"prompt:{name}:{device_id}")
if cached == b"none":
return
return redirect(f"/auth/login?prompt=none&next={_quote(request.url, safe='')}")
@@ -279,6 +282,8 @@ def create_base_app(
@app.after_serving
async def _stop_event_processor():
await _event_processor.stop()
from shared.infrastructure.auth_redis import close_auth_redis
await close_auth_redis()
# --- favicon ---
@app.get("/favicon.ico")

View File

@@ -78,12 +78,14 @@ def create_oauth_blueprint(app_name: str) -> Blueprint:
qsession["_pnone_at"] = _time.time()
device_id = g.device_id
if device_id:
from shared.browser.app.redis_cacher import get_redis
_redis = get_redis()
if _redis:
await _redis.set(
from shared.infrastructure.auth_redis import get_auth_redis
try:
_auth_r = await get_auth_redis()
await _auth_r.set(
f"prompt:{app_name}:{device_id}", b"none", ex=300
)
except Exception:
pass
return redirect(next_url)
code = request.args.get("code")

View File

@@ -31,5 +31,17 @@ async def load_current_user():
g.rights = {"admin": False}
return
g.user = await load_user_by_id(g.s, uid)
# User table lives in the account DB — use account session when
# the per-request session (g.s) targets a different database.
from shared.db.session import DATABASE_URL, DATABASE_URL_ACCOUNT
if DATABASE_URL_ACCOUNT != DATABASE_URL:
from shared.db.session import get_account_session
async with get_account_session() as s:
g.user = await load_user_by_id(s, uid)
# Expunge so the object is usable outside this session
if g.user:
s.expunge(g.user)
else:
g.user = await load_user_by_id(g.s, uid)
g.rights = {l.name: True for l in g.user.labels} if g.user else {}

View File

@@ -77,8 +77,8 @@ class CalendarEntry(Base):
index=True,
)
# NEW: ownership + order link
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
# Ownership (cross-domain — no FK constraint to users table)
user_id = Column(Integer, nullable=True, index=True)
session_id = Column(String(64), nullable=True, index=True)
order_id = Column(Integer, nullable=True, index=True)
@@ -246,7 +246,7 @@ class Ticket(Base):
nullable=True,
index=True,
)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
user_id = Column(Integer, nullable=True, index=True)
session_id = Column(String(64), nullable=True, index=True)
order_id = Column(Integer, nullable=True, index=True)

View File

@@ -23,8 +23,7 @@ class ActorProfile(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"),
unique=True, nullable=False,
Integer, unique=True, nullable=False,
)
preferred_username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
@@ -36,7 +35,6 @@ class ActorProfile(Base):
)
# Relationships
user = relationship("User", backref="actor_profile", uselist=False, lazy="selectin")
activities = relationship("APActivity", back_populates="actor_profile", lazy="dynamic")
followers = relationship("APFollower", back_populates="actor_profile", lazy="dynamic")

View File

@@ -94,7 +94,7 @@ class Post(Base):
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
user_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), index=True
Integer, index=True
)
publish_requested: Mapped[bool] = mapped_column(Boolean(), default=False, server_default="false", nullable=False)
@@ -111,9 +111,6 @@ class Post(Base):
primary_tag: Mapped[Optional[Tag]] = relationship(
"Tag", foreign_keys=[primary_tag_id]
)
user: Mapped[Optional["User"]] = relationship(
"User", foreign_keys=[user_id]
)
# AUTHORS RELATIONSHIP (many-to-many via post_authors)
authors: Mapped[List["Author"]] = relationship(
@@ -205,7 +202,7 @@ class PostLike(Base):
__tablename__ = "post_likes"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, nullable=False)
post_id: Mapped[int] = mapped_column(ForeignKey("posts.id", ondelete="CASCADE"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
@@ -213,4 +210,3 @@ class PostLike(Base):
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
post: Mapped["Post"] = relationship("Post", back_populates="likes", foreign_keys=[post_id])
user = relationship("User", back_populates="liked_posts")

View File

@@ -112,18 +112,8 @@ class Product(Base):
back_populates="product",
cascade="all, delete-orphan",
)
cart_items: Mapped[List["CartItem"]] = relationship(
"CartItem",
back_populates="product",
cascade="all, delete-orphan",
)
# NEW: all order items that reference this product
order_items: Mapped[List["OrderItem"]] = relationship(
"OrderItem",
back_populates="product",
cascade="all, delete-orphan",
)
# cart_items and order_items live in a separate domain (cart DB)
# — cross-domain relationships removed
from sqlalchemy import Column
@@ -131,7 +121,7 @@ class ProductLike(Base):
__tablename__ = "product_likes"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, nullable=False)
product_slug: Mapped[str] = mapped_column(ForeignKey("products.slug", ondelete="CASCADE"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
@@ -139,8 +129,6 @@ class ProductLike(Base):
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
product: Mapped["Product"] = relationship("Product", back_populates="likes", foreign_keys=[product_slug])
user = relationship("User", back_populates="liked_products") # optional, if you want reverse access
class ProductImage(Base):
__tablename__ = "product_images"
@@ -381,7 +369,7 @@ class CartItem(Base):
# Either a logged-in user OR an anonymous session
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"),
Integer,
nullable=True,
)
session_id: Mapped[str | None] = mapped_column(
@@ -389,9 +377,8 @@ class CartItem(Base):
nullable=True,
)
# IMPORTANT: link to product *id*, not slug
product_id: Mapped[int] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"),
Integer,
nullable=False,
)
@@ -413,7 +400,7 @@ class CartItem(Base):
server_default=func.now(),
)
market_place_id: Mapped[int | None] = mapped_column(
ForeignKey("market_places.id", ondelete="SET NULL"),
Integer,
nullable=True,
index=True,
)
@@ -423,17 +410,19 @@ class CartItem(Base):
nullable=True,
)
# Relationships
# Cross-domain relationships — explicit join, viewonly (no FK constraint)
market_place: Mapped["MarketPlace | None"] = relationship(
"MarketPlace",
foreign_keys=[market_place_id],
primaryjoin="CartItem.market_place_id == MarketPlace.id",
foreign_keys="[CartItem.market_place_id]",
viewonly=True,
)
product: Mapped["Product"] = relationship(
"Product",
back_populates="cart_items",
primaryjoin="CartItem.product_id == Product.id",
foreign_keys="[CartItem.product_id]",
viewonly=True,
)
user: Mapped["User | None"] = relationship("User", back_populates="cart_items")
__table_args__ = (
Index("ix_cart_items_user_product", "user_id", "product_id"),

View File

@@ -13,7 +13,6 @@ class MenuItem(Base):
post_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("posts.id", ondelete="CASCADE"),
nullable=False,
index=True
)

View File

@@ -14,11 +14,11 @@ class Order(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True)
user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
session_id: Mapped[Optional[str]] = mapped_column(String(64), index=True, nullable=True)
page_config_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("page_configs.id", ondelete="SET NULL"),
Integer,
nullable=True,
index=True,
)
@@ -69,9 +69,12 @@ class Order(Base):
cascade="all, delete-orphan",
lazy="selectin",
)
# Cross-domain relationship — explicit join, viewonly (no FK constraint)
page_config: Mapped[Optional["PageConfig"]] = relationship(
"PageConfig",
foreign_keys=[page_config_id],
primaryjoin="Order.page_config_id == PageConfig.id",
foreign_keys="[Order.page_config_id]",
viewonly=True,
lazy="selectin",
)
@@ -86,7 +89,7 @@ class OrderItem(Base):
)
product_id: Mapped[int] = mapped_column(
ForeignKey("products.id"),
Integer,
nullable=False,
)
product_title: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
@@ -106,9 +109,11 @@ class OrderItem(Base):
back_populates="items",
)
# NEW: link each order item to its product
# Cross-domain relationship — explicit join, viewonly (no FK constraint)
product: Mapped["Product"] = relationship(
"Product",
back_populates="order_items",
primaryjoin="OrderItem.product_id == Product.id",
foreign_keys="[OrderItem.product_id]",
viewonly=True,
lazy="selectin",
)

View File

@@ -30,13 +30,8 @@ class User(Base):
labels = relationship("GhostLabel", secondary="user_labels", back_populates="users", lazy="selectin")
subscriptions = relationship("GhostSubscription", back_populates="user", cascade="all, delete-orphan", lazy="selectin")
liked_products = relationship("ProductLike", back_populates="user", cascade="all, delete-orphan")
liked_posts = relationship("PostLike", back_populates="user", cascade="all, delete-orphan")
cart_items = relationship(
"CartItem",
back_populates="user",
cascade="all, delete-orphan",
)
# Cross-domain reverse relationships removed (liked_products, liked_posts,
# cart_items) — those tables live in different domain DBs
__table_args__ = (
Index("ix_user_email", "email", unique=True),