Skip to content

Commit ffd81f9

Browse files
authored
Merge pull request #264 from sheaf-project/fix/front-coalesce-scaling
Fix front-coalesce query blow-up and statement_timeout coverage gaps
2 parents 4dc873f + b6645d5 commit ffd81f9

7 files changed

Lines changed: 340 additions & 88 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ All notable changes to Sheaf are documented here. The format is based on [Keep a
66

77
## [Unreleased]
88

9+
### Fixed
10+
11+
- **"Fronting since" no longer melts the database on a large imported history.** With coalesce-contiguous-fronts enabled, the walk that computes how far back a member's unbroken fronting run extends used a recursive query that, on an imported history whose switch boundaries share timestamps (PluralKit exports round to the second, so thousands do), could enumerate hundreds of millions of intermediate rows and spill tens of gigabytes into database temporary storage - one system's genuine imported history briefly took the whole database down this way. The walk is now a single sorted pass over the member's own front entries, with cost proportional to their history size, and returns the same answers. One deliberate refinement: fronts that *overlap* for the same member now count as one continuous run (previously the run only chained when one entry ended at the exact instant the next began), so a member fronting continuously across overlapping entries gets the earlier, correct "since". As a consequence the walk-back depth cap is gone; `member_since_capped` remains in the API for compatibility but is now always empty.
12+
- **The realtime front-change stream's database queries are now time-capped like every other request.** The stream endpoint builds its snapshots in self-managed database sessions, which silently missed the per-request statement timeout - so a pathological query from the stream path could run unboundedly (this is how the incident above kept filling the disk for 13 minutes after the same query on the normal endpoint had been cancelled at 30 seconds). Those sessions now carry the same timeout, and the cap is re-applied on every transaction, closing a second quiet gap where a mid-request commit dropped the timeout for the remainder of that request.
13+
914
## [1.3.5] - 2026-08-09
1015

1116
### Security

docs/SELFHOSTING.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,18 @@ Sheaf refuses to start in `saas` mode if this is left at the default, and logs a
108108

109109
**If not set**, a key is auto-generated on first startup and saved to `data/encryption.key` inside the Docker volume. **Back this file up.** Prefer setting `SHEAF_ENCRYPTION_KEY` explicitly so the key isn't tied to a single volume.
110110

111+
### Postgres safety limits (recommended)
112+
113+
By default Postgres lets a single query spill unlimited temporary files to disk while it works. One runaway query can therefore fill the volume Postgres lives on and take the whole database down for every user - not just fail itself. Capping it turns that failure mode into "the one query errors out":
114+
115+
```sql
116+
ALTER SYSTEM SET temp_file_limit = '2GB'; -- generous for a small instance
117+
ALTER SYSTEM SET log_temp_files = '64MB'; -- log any query spilling more than this
118+
SELECT pg_reload_conf();
119+
```
120+
121+
Run once via `psql` against your database (for the bundled container: `docker compose exec db psql -U sheaf`). Pick a `temp_file_limit` well below the free space on the database volume. `log_temp_files` gives you a log line naming any unusually hungry query, which is the breadcrumb you want if something ever does hit the cap.
122+
111123
### Compose managers that don't use a `.env` file
112124

113125
Some stack managers keep the environment in a differently-named file: OpenMediaVault's compose plugin writes `sheaf.env`, Portainer keeps stack env in its own database, and so on. Sheaf's `docker-compose.yml` loads the app's config from a file literally named `.env`, so if your manager uses another name the app receives none of its env-file settings and falls back to defaults. The classic symptom on a brand-new stack is `password authentication failed for user "sheaf"`: Postgres was created with your `POSTGRES_PASSWORD`, but the app never saw it.

sheaf/api/v1/front_stream.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from sheaf.auth.dependencies import get_current_user
2626
from sheaf.auth.sessions import get_redis, get_session_user_id
2727
from sheaf.config import settings
28-
from sheaf.database import async_session_factory
28+
from sheaf.database import request_session
2929
from sheaf.models.user import User
3030
from sheaf.observability.metrics import (
3131
realtime_connection_duration_seconds,
@@ -122,7 +122,7 @@ async def _recheck_auth(ctx: dict) -> tuple[bool, str | None]:
122122

123123
from sheaf.models.api_key import ApiKey
124124

125-
async with async_session_factory() as db:
125+
async with request_session() as db:
126126
row = await db.get(ApiKey, ctx["api_key_id"])
127127
if row is None:
128128
return False, "auth_revoked"
@@ -215,7 +215,7 @@ async def _stream(
215215
# Snapshot in a short-lived session CLOSED before we start yielding, so
216216
# no DB connection is held while the stream is open. Building the whole
217217
# snapshot first also means a slow client cannot pin the connection.
218-
async with async_session_factory() as db:
218+
async with request_session() as db:
219219
snapshots = [
220220
build_snapshot_payload(
221221
sid,
@@ -356,7 +356,7 @@ async def stream_fronts(
356356
# after the response finishes, and a stream finishes only when it closes, so
357357
# the pooled Postgres connection would sit idle-in-transaction for the whole
358358
# connection and eventually exhaust the pool, blocking every other request.
359-
async with async_session_factory() as db:
359+
async with request_session() as db:
360360
system_ids = await authorized_front_system_ids(user, db)
361361
account_key = str(user.id)
362362
auth_ctx = _auth_context(request)

sheaf/api/v1/fronts.py

Lines changed: 82 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,11 @@ def _front_to_read(
8787
member — the literal-entry view used by history endpoints and any
8888
caller that doesn't want to pay for the walk-back.
8989
90-
`member_since_capped` lists member ids whose chain hit the
91-
walk-back depth limit; the returned timestamp is a lower bound,
92-
not the true chain start. Frontends should render those with a
93-
"> X ago" prefix to be honest about precision.
90+
`member_since_capped` is retained for API compatibility: the old
91+
recursive walk-back could hit a depth limit and flag members whose
92+
timestamp was only a lower bound. The set-based coalesce query has
93+
no such cap, so this is now always empty; frontends that render a
94+
"> X ago" prefix for flagged members simply never see one.
9495
9596
`has_audit_history` reflects whether at least one FrontAuditEvent
9697
exists for this entry. Computed once per list call via a batch
@@ -141,49 +142,78 @@ async def _front_has_audit(db: AsyncSession, front_id: uuid.UUID) -> bool:
141142
return result.scalar_one_or_none() is not None
142143

143144

144-
# Walk-back depth cap: pathological cycles aside, real chains are
145-
# typically 1-3 entries. 500 is a generous bound that prevents a
146-
# corrupted-data edge case from running unbounded queries while still
147-
# covering anyone who switches every few minutes for many hours.
148-
# When the cap *is* hit, the response flags the affected member so the
149-
# UI can render "> X ago" instead of silently under-reporting. Easy to
150-
# raise later if the flag actually starts surfacing in real usage.
151-
_COALESCE_MAX_DEPTH = 500
152-
153-
154-
# Recursive CTE that walks every (seed_front, member) chain in
155-
# parallel. Replaces what was previously a per-(front, member) loop of
156-
# awaited single-row queries — fine for one open front with two
157-
# members on a brand-new system, ruinous for /current on a busy
158-
# system with coalesce_contiguous_fronts on. The CTE is bounded by
159-
# `_COALESCE_MAX_DEPTH` so a corrupted-data cycle can't run forever,
160-
# and we surface the cap-hit per member the same way the old code did.
145+
# Set-based gaps-and-islands "coalesce contiguous fronting" query.
146+
#
147+
# This replaced a recursive CTE that walked prev.ended_at == started_at
148+
# chains with UNION ALL. That formulation enumerates PATHS, not states:
149+
# on an imported history whose switch boundaries share timestamps
150+
# (PluralKit exports are second-rounded, so thousands collide), every
151+
# recursive step matches multiple predecessors and the intermediate set
152+
# grows multiplicatively per level. A depth cap bounds depth, not width.
153+
# On 2026-08-13 a genuine ~14k-front imported history detonated it into
154+
# ~19 GB of Postgres query temp, filling the data volume (see the
155+
# front-coalesce-query-scaling design doc). Path count is combinatorial;
156+
# table size is irrelevant.
157+
#
158+
# The rewrite computes each member's contiguous runs ("islands") in one
159+
# sorted window pass: a run breaks only where every earlier front's
160+
# reach (running MAX of ended_at, with an open front reaching infinity)
161+
# ends strictly before the next started_at. Cost is one sort of the
162+
# member's own fronts - O(n log n), no recursion, no width explosion.
163+
#
164+
# Contiguity is interval-merge: exact touches (ended_at == started_at)
165+
# chain exactly as before, and OVERLAPPING fronts for the same member -
166+
# the very shape that made the recursion branch - merge into one run.
167+
# Chain-reachability is a strict subset of interval-merge, so results
168+
# are identical wherever fronts don't overlap (all live-written data);
169+
# where they do, the merged run extends "since" through the overlap,
170+
# which is the correct reading of an unbroken fronting stretch.
161171
_COALESCED_SINCE_SQL = text(
162172
"""
163-
WITH RECURSIVE chain(seed_front_id, member_id, started_at, depth) AS (
164-
SELECT f.id, fm.member_id, f.started_at, 0
173+
WITH member_fronts AS (
174+
SELECT fm.member_id,
175+
f.id AS front_id,
176+
f.started_at,
177+
COALESCE(f.ended_at, 'infinity'::timestamptz) AS reach
165178
FROM fronts f
166179
JOIN front_members fm ON fm.front_id = f.id
167-
WHERE f.id IN :seed_ids
168-
169-
UNION ALL
170-
171-
SELECT chain.seed_front_id, chain.member_id, prev.started_at,
172-
chain.depth + 1
173-
FROM chain
174-
JOIN fronts prev
175-
ON prev.system_id = :system_id
176-
AND prev.ended_at = chain.started_at
177-
JOIN front_members fm
178-
ON fm.front_id = prev.id
179-
AND fm.member_id = chain.member_id
180-
WHERE chain.depth < :max_depth
180+
WHERE f.system_id = :system_id
181+
AND fm.member_id IN (
182+
SELECT seed_fm.member_id
183+
FROM front_members seed_fm
184+
WHERE seed_fm.front_id IN :seed_ids
185+
)
186+
),
187+
runs AS (
188+
SELECT member_id, front_id, started_at, reach,
189+
MAX(reach) OVER (
190+
PARTITION BY member_id
191+
ORDER BY started_at, reach, front_id
192+
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
193+
) AS prev_reach
194+
FROM member_fronts
195+
),
196+
islands AS (
197+
SELECT member_id, front_id, started_at,
198+
SUM(CASE WHEN prev_reach IS NULL OR prev_reach < started_at
199+
THEN 1 ELSE 0 END)
200+
OVER (PARTITION BY member_id
201+
ORDER BY started_at, reach, front_id) AS island
202+
FROM runs
203+
),
204+
island_starts AS (
205+
SELECT member_id, island, MIN(started_at) AS run_start
206+
FROM islands
207+
GROUP BY member_id, island
181208
)
182-
SELECT seed_front_id, member_id,
183-
MIN(started_at) AS earliest_started,
184-
MAX(depth) AS deepest
185-
FROM chain
186-
GROUP BY seed_front_id, member_id
209+
SELECT i.front_id AS seed_front_id,
210+
i.member_id,
211+
s.run_start AS earliest_started
212+
FROM islands i
213+
JOIN island_starts s
214+
ON s.member_id = i.member_id
215+
AND s.island = i.island
216+
WHERE i.front_id IN :seed_ids
187217
"""
188218
).bindparams(bindparam("seed_ids", expanding=True))
189219

@@ -194,8 +224,14 @@ async def _build_coalesced_member_since(
194224
"""For each given front, build (since_map, capped_member_ids).
195225
196226
When `system.coalesce_contiguous_fronts` is False, returns the
197-
literal-entry view for each member with no capped members.
198-
Otherwise walks back per member to find the earliest chain start.
227+
literal-entry view for each member with no capped members. Otherwise
228+
resolves each member's contiguous-run start via the gaps-and-islands
229+
query above.
230+
231+
`capped_member_ids` is always empty now: the set-based query has no
232+
depth cap to hit (the old recursive walk-back did). The tuple shape
233+
is kept so the schema field `member_since_capped` stays present for
234+
API compatibility; it simply never flags anyone.
199235
"""
200236
out: dict[uuid.UUID, tuple[dict[str, datetime], list[str]]] = {}
201237
if not fronts:
@@ -220,18 +256,12 @@ async def _build_coalesced_member_since(
220256
{
221257
"seed_ids": [f.id for f in fronts],
222258
"system_id": system.id,
223-
"max_depth": _COALESCE_MAX_DEPTH,
224259
},
225260
)
226261

227-
for seed_id, member_id, earliest, deepest in rows:
228-
per_member, capped = out[seed_id]
262+
for seed_id, member_id, earliest in rows:
263+
per_member, _capped = out[seed_id]
229264
per_member[str(member_id)] = earliest
230-
# depth==max_depth means the recursive step ran the last
231-
# allowed iteration and may not have found the true chain
232-
# start. Same semantics as the prior per-walk cap flag.
233-
if deepest is not None and deepest >= _COALESCE_MAX_DEPTH:
234-
capped.append(str(member_id))
235265

236266
return out
237267

sheaf/database.py

Lines changed: 65 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
from collections.abc import AsyncGenerator
33
from contextlib import asynccontextmanager
44

5-
from sqlalchemy import event, text
5+
from sqlalchemy import event
66
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
7+
from sqlalchemy.orm import Session as _SyncSession
78

89
from sheaf.config import settings
910

@@ -16,9 +17,10 @@
1617
# A connection-level cap would apply to every session drawn from the pool,
1718
# including the long-running background jobs (export builds, retention
1819
# sweeps, analytics) that legitimately outlast any request. Instead the
19-
# SHORT request cap is applied per-transaction inside get_db, and jobs stay
20-
# uncapped by default (opting into db_job_statement_timeout_ms via
21-
# job_session()). See _set_local_statement_timeout below.
20+
# SHORT request cap is applied per-transaction to sessions that opt in via
21+
# session.info (get_db, request_session), and jobs stay uncapped by default
22+
# (opting into db_job_statement_timeout_ms via job_session()). See the
23+
# after_begin listener below.
2224
engine = create_async_engine(
2325
settings.database_url,
2426
echo=False,
@@ -72,28 +74,44 @@ def _after_cursor_execute(conn, cursor, statement, parameters, context, executem
7274
)
7375

7476

75-
async def _set_local_statement_timeout(session: AsyncSession, timeout_ms: int) -> None:
76-
"""Apply a transaction-local Postgres statement_timeout to `session`.
77-
78-
A value <= 0 is a no-op (unlimited). SET LOCAL is scoped to the current
79-
transaction, so the cap neither leaks to the next caller that checks out
80-
this pooled connection nor bleeds past a commit. Postgres SET does not
81-
accept bind parameters, so the value is formatted in directly - it comes
82-
from a pydantic int setting, never user input, and is re-cast to int
83-
here, so there is no injection surface.
77+
# Sessions opt into a Postgres statement_timeout by setting this key in
78+
# session.info; the after_begin listener below re-applies it at the start of
79+
# EVERY transaction the session opens. The previous design issued a single
80+
# SET LOCAL when the session was created, which silently evaporated at the
81+
# first mid-request commit (SET LOCAL is transaction-scoped), leaving the
82+
# rest of the request uncapped. The 2026-08-13 incident's variant of this
83+
# class - a request-tier session created outside get_db with no cap at all -
84+
# let one coalesce query spill 19 GB of query temp; see request_session().
85+
_TIMEOUT_INFO_KEY = "statement_timeout_ms"
86+
87+
88+
@event.listens_for(_SyncSession, "after_begin")
89+
def _apply_statement_timeout(session, transaction, connection) -> None:
90+
"""Apply the session's opted-in statement_timeout to each new transaction.
91+
92+
Registered on the Session class, so it fires for every session drawn from
93+
the shared pool - but it is a no-op unless the session set
94+
`info[_TIMEOUT_INFO_KEY]`, so background jobs that use
95+
async_session_factory() directly stay uncapped as designed. SET LOCAL is
96+
transaction-scoped, so nothing leaks to the next checkout of the pooled
97+
connection. Postgres SET takes no bind parameters; the value is a pydantic
98+
int setting (never user input) re-cast to int here, so there is no
99+
injection surface.
84100
"""
85-
if timeout_ms <= 0:
101+
timeout_ms = session.info.get(_TIMEOUT_INFO_KEY)
102+
if not timeout_ms or timeout_ms <= 0:
86103
return
87-
await session.execute(text(f"SET LOCAL statement_timeout = {int(timeout_ms)}"))
104+
connection.exec_driver_sql(f"SET LOCAL statement_timeout = {int(timeout_ms)}")
88105

89106

90107
async def get_db() -> AsyncGenerator[AsyncSession]:
91108
async with async_session_factory() as session:
92109
# Bound the request path so a pathological O(history) query can't pin
93-
# a pooled connection indefinitely. Applied per-transaction, so it
94-
# never touches the background jobs, which use async_session_factory()
95-
# / job_session() directly and legitimately run longer than a request.
96-
await _set_local_statement_timeout(session, settings.db_statement_timeout_ms)
110+
# a pooled connection indefinitely. The info key makes the after_begin
111+
# listener re-apply the cap on every transaction, so it survives
112+
# mid-request commits. Background jobs use async_session_factory() /
113+
# job_session() directly and are not affected.
114+
session.info[_TIMEOUT_INFO_KEY] = settings.db_statement_timeout_ms
97115
try:
98116
yield session
99117
await session.commit()
@@ -102,6 +120,29 @@ async def get_db() -> AsyncGenerator[AsyncSession]:
102120
raise
103121

104122

123+
@asynccontextmanager
124+
async def request_session() -> AsyncGenerator[AsyncSession]:
125+
"""Session for request-tier work that cannot use Depends(get_db).
126+
127+
Same engine/pool and the same request statement_timeout cap as get_db,
128+
without the DI lifecycle: no auto-commit, caller manages transactions.
129+
For paths that serve a user request but manage their own session - e.g.
130+
the SSE front stream, which builds its snapshot in a short-lived session
131+
so no DB connection is held while the stream is open.
132+
133+
Every session that runs queries on behalf of a user request MUST carry
134+
the request timeout cap. The 2026-08-13 incident: the stream snapshot
135+
used a bare async_session_factory() session, so the coalesce query it
136+
ran had no statement_timeout and kept spilling query temp for ~13
137+
minutes after the request-path twin of the same query had been killed
138+
at 30 s - filling the disk and taking Postgres down for everyone. Use
139+
this instead of async_session_factory() for anything request-shaped.
140+
"""
141+
async with async_session_factory() as session:
142+
session.info[_TIMEOUT_INFO_KEY] = settings.db_statement_timeout_ms
143+
yield session
144+
145+
105146
@asynccontextmanager
106147
async def job_session() -> AsyncGenerator[AsyncSession]:
107148
"""Session for background jobs that want an explicit statement_timeout
@@ -114,15 +155,11 @@ async def job_session() -> AsyncGenerator[AsyncSession]:
114155
ceiling that is still far above the short request timeout. Unlike get_db
115156
this does NOT auto-commit; the job manages its own transactions.
116157
117-
Note: SET LOCAL is per-transaction, so for a job that commits between
118-
units of work the cap re-applies only to the first transaction after
119-
entry. Jobs here follow an execute-then-commit pattern (not
120-
`async with db.begin()`), so the pre-emptive SET joins the same
121-
transaction as the job's first query. Left as an opt-in because the
122-
default ceiling is unlimited.
158+
The after_begin listener re-applies the cap on every transaction, so a
159+
job that commits between units of work keeps its ceiling for each one
160+
(this previously only covered the first transaction after entry). Left
161+
as an opt-in because the default ceiling is unlimited.
123162
"""
124163
async with async_session_factory() as session:
125-
await _set_local_statement_timeout(
126-
session, settings.db_job_statement_timeout_ms
127-
)
164+
session.info[_TIMEOUT_INFO_KEY] = settings.db_job_statement_timeout_ms
128165
yield session

0 commit comments

Comments
 (0)