perf(sessions): stop the refill jumping to its scan ceiling
The unassigned refill's proportional widening degenerated in two shapes, and both spent the forced ceiling query -- 200 conversations read, sidecar-stat()ed and merged into the payload -- to deliver a 20-row window. * A refill query that finds ZERO still-unassigned conversations has no density to extrapolate from, but max(unassigned_seen, 1) evaluated the estimate as target * width anyway (20 * 40 = 800, clamped to the ceiling). Any profile that had moved >= 21 of its newest conversations from the WebUI went straight to a full 200-row read where the 60-row exact-width floor fills the window on the nose. The proportional term now applies only when the window in hand actually saw an unassigned conversation. * When the moved conversations cluster just below the window edge, every re-count returns the same unassigned count, so the estimate advanced ~2 rows a time (21, 23, 25): all four queries spent and the last reading the whole ceiling band. The widening is now also geometric, paced by the queries still left (width // queries_left, so width/3 then width/2) -- bolder as the forced ceiling read gets closer, because a too-narrow last estimate costs that whole read while a too-wide one costs only its own surplus. Measured on a 5000-conversation state.db (warm cache), unassigned query widths / rows the model carries: * newest 40 moved: [40, 200] 200 rows -> [40, 60] 60 rows, 400 -> 160 stat() * 8 moves clustered at the bottom edge of 200: [21, 23, 25, 200] 200 rows / 192 unassigned -> [21, 28] 28 rows / 20 unassigned * every clustered size up to 22 moves is off the ceiling path (42 rows, three queries); 23+ still ends on the ceiling read, as before * no WebUI-side moves: unchanged, no refill query at all Unchanged by construction: the widening is still strictly increasing so the loop cannot stall, the query count is still bounded by UNASSIGNED_CLI_REFILL_MAX_QUERIES, and the last query still reads the full ceiling band -- so the window still fills whenever at least 20 of the newest 200 state.db-unassigned conversations are unassigned once sidecars are read. The one deliberate cost: a profile whose newest ceiling band is ENTIRELY moved now climbs (40, 60, 90, 200) instead of (40, 200), paying two extra narrow SQL queries for the same 200-row payload. The trade-off note is rewritten with it. "A few spare conversations" was the best case (24 rows for 20); the worst case inside the same bound is the whole ceiling band, so the note now states that, plus the two things the old bound omitted: CANDIDATE_WINDOW_MULTIPLIERS = (8, 32) means a ceiling-width query touches 1600 raw rows (6400 re-widened), and every bound here is per profile context, not per sidebar build -- all_profiles=1 runs the loader once each.
This commit is contained in:
1 parent
98d5dfdc53
commit
94c5a4f917
1 file changed
+88
-24
+88
-24
@@ -87,13 +87,17 @@ PROJECT_ASSIGNED_CLI_SCAN_CEILING = 2000
|
||||
# conversations this projection classifies as ASSIGNED. How many is not knowable
|
||||
# before the query runs, so the refill re-classifies its own result and widens
|
||||
# again while the window is still short. These two constants are what keep that
|
||||
# loop finite.
|
||||
# loop finite. Both bound ONE profile context: get_cli_sessions(
|
||||
# all_profiles=1) runs the whole loader once per context, so an N-profile sidebar
|
||||
# pays N times the numbers below.
|
||||
#
|
||||
# At most this many unassigned queries per sidebar build (the first one included).
|
||||
# The widening below is proportional, not one row at a time, so 4 covers every
|
||||
# arrangement whose moved conversations are anywhere near uniformly distributed;
|
||||
# the last of the four abandons estimating and reads the ceiling in one go, which
|
||||
# is the widest read the loop is ever allowed to pay.
|
||||
# At most this many unassigned queries per profile context (the first included).
|
||||
# The widening below is geometric and proportional, not one row at a time, so 4
|
||||
# covers both moves spread through the history and moves clustered just under the
|
||||
# window edge; the last of the four abandons estimating and reads the ceiling in
|
||||
# one go, which is the widest read the loop is ever allowed to pay. It is also
|
||||
# what paces the geometric growth: each widening adds at least width // queries
|
||||
# still left, so the steps get bolder as the forced ceiling read gets closer.
|
||||
UNASSIGNED_CLI_REFILL_MAX_QUERIES = 4
|
||||
# ...and no single refill query reads deeper than this many logical
|
||||
# conversations. It bounds both the I/O and the payload: a build cannot be made
|
||||
@@ -8825,7 +8829,9 @@ def _load_cli_sessions_uncached(
|
||||
# eleven straddling the boundary delivered 15.)
|
||||
#
|
||||
# So the refill re-classifies ITS OWN result and widens again
|
||||
# while the window is short. Bounds, per sidebar build:
|
||||
# while the window is short. Bounds, per PROFILE CONTEXT (not per
|
||||
# sidebar build: all_profiles=1 runs this loader once per context,
|
||||
# so an N-profile view pays N times everything below):
|
||||
# * at most UNASSIGNED_CLI_REFILL_MAX_QUERIES queries;
|
||||
# * none of them reading deeper than the scan ceiling;
|
||||
# * and it stops early the moment the window is full or the
|
||||
@@ -8836,6 +8842,32 @@ def _load_cli_sessions_uncached(
|
||||
# their sidecars are read — the last query reads exactly that
|
||||
# band. Past that the sidebar is honestly short (a shortfall the
|
||||
# database itself imposes) instead of unbounded work.
|
||||
#
|
||||
# What that costs, stated at its WORST rather than its best. The
|
||||
# window this loop must deliver is 20 rows; the best case is a
|
||||
# single query carrying 24 of them, and quoting that as the
|
||||
# trade-off understates it by an order of magnitude:
|
||||
# * PAYLOAD. Every refill row is merged into the projection and
|
||||
# travels on, so the surplus is trimmed by the sidebar cap
|
||||
# only AFTER it has been read, sidecar-stat()ed and (on
|
||||
# /api/sessions/gateway/stream, which snapshots the uncapped
|
||||
# list) serialised. A shape that keeps the window short
|
||||
# through all three estimates reaches the forced ceiling
|
||||
# query, and the model then carries the whole band — up to
|
||||
# scan_ceiling (200) conversations, nearly all of them
|
||||
# unassigned, to hand the sidebar 20. The widening below is
|
||||
# what keeps ordinary shapes off that path; it does not, and
|
||||
# cannot, remove it, because the ceiling read is exactly what
|
||||
# the guarantee above is made of.
|
||||
# * I/O. "No query deeper than 200 conversations" is a LOGICAL
|
||||
# depth. read_importable_agent_session_rows oversamples raw
|
||||
# rows by CANDIDATE_WINDOW_MULTIPLIERS = (8, 32) to survive
|
||||
# compression segments, so a ceiling-width query touches
|
||||
# 200 * 8 = 1600 raw rows, and 200 * 32 = 6400 when the first
|
||||
# window is consumed and re-widened. Per profile context.
|
||||
# Cheaper than any of it: project-filtered server pagination, or
|
||||
# recording WebUI-side moves in state.db so the SQL filter stops
|
||||
# lying to this pass.
|
||||
query_limit = unassigned_target + sidecar_only_assigned
|
||||
# A caller-supplied window wider than the ceiling still gets its
|
||||
# first query at full width — the ceiling bounds the WIDENING,
|
||||
@@ -8880,29 +8912,61 @@ def _load_cli_sessions_uncached(
|
||||
)
|
||||
):
|
||||
break
|
||||
# Widen PROPORTIONALLY, not by one row at a time. Two
|
||||
# candidate widths, take the larger:
|
||||
# Widen GEOMETRICALLY, not by one row at a time. Up to three
|
||||
# candidate widths; the widest of the ones that apply wins:
|
||||
# * query_limit + shortfall — the exact width needed if
|
||||
# every conversation deeper than the current one is
|
||||
# unassigned. It is the FLOOR: always strictly wider, so
|
||||
# the loop cannot stall, and it is exact (no over-read)
|
||||
# when the moved conversations are clustered.
|
||||
# * target * query_limit // unassigned_seen — the width
|
||||
# implied by the moved share observed so far, for moves
|
||||
# spread through the history. Without it, a database
|
||||
# where every other conversation was moved would close
|
||||
# the gap by halves and need ~log2(target) queries.
|
||||
# The estimate can over-read when moves cluster at the top of
|
||||
# the window; the surplus is bounded by the ceiling and
|
||||
# trimmed by the sidebar cap, which is cheaper than another
|
||||
# query.
|
||||
proportional_width = -( # ceil(target * width / unassigned)
|
||||
-unassigned_target * query_limit // max(unassigned_seen, 1)
|
||||
)
|
||||
query_limit = min(
|
||||
scan_ceiling,
|
||||
max(query_limit + shortfall, proportional_width),
|
||||
# * query_limit + query_limit // queries_left — GEOMETRIC
|
||||
# growth, paced by the budget that is left. The floor alone
|
||||
# advances by only the shortfall, so a handful of moves
|
||||
# clustered just below the window edge crawls (21, 23, 25)
|
||||
# until every estimate is spent and the forced ceiling read
|
||||
# does the job anyway: 200 rows fetched to deliver 20. The
|
||||
# step grows as the budget shrinks (width/3, then width/2)
|
||||
# because a too-narrow LAST estimate costs the whole ceiling
|
||||
# read, while a too-wide one costs only its own surplus.
|
||||
# Those eight clustered moves now finish at (21, 28).
|
||||
# * ceil(target * query_limit / unassigned_seen) — the width
|
||||
# implied by the moved SHARE observed so far, for moves
|
||||
# spread through the history. Without it, a database where
|
||||
# every other conversation was moved would close the gap
|
||||
# by halves and need ~log2(target) queries.
|
||||
# That last term applies ONLY when this window found at least
|
||||
# one still-unassigned conversation. With zero, the share is
|
||||
# undefined, and guarding the divisor with max(seen, 1)
|
||||
# evaluated it as target * query_limit (20 * 40 = 800, clamped
|
||||
# to the ceiling): every profile that had moved >= 21 of its
|
||||
# newest conversations jumped straight to a full 200-row read
|
||||
# where the 60-row floor would have filled the window. Zero
|
||||
# unassigned rows is not evidence about density, so the
|
||||
# geometric step — not a degenerate ratio — is what carries it.
|
||||
# The price of escalating smoothly is that a profile whose
|
||||
# newest ceiling band is ENTIRELY moved now spends its whole
|
||||
# budget (40, 60, 90, 200) climbing to the ceiling it was
|
||||
# always going to need, instead of two queries. That is the
|
||||
# deliberate side of the trade: an extra narrow query costs SQL
|
||||
# only, while an early-and-wrong wide query costs payload — and
|
||||
# payload is read, sidecar-stat()ed and serialised. And a
|
||||
# cluster deeper than the last estimate can reach — measured at
|
||||
# 23+ moves sitting just under the window edge, where the three
|
||||
# estimates are 21, 28, 42 — still ends on the ceiling read, as
|
||||
# it did before: that is the guarantee doing its job, not the
|
||||
# estimate failing.
|
||||
next_query_limit = max(
|
||||
query_limit + shortfall,
|
||||
query_limit + query_limit // max(queries_left, 1),
|
||||
)
|
||||
if unassigned_seen > 0:
|
||||
next_query_limit = max(
|
||||
next_query_limit,
|
||||
-( # ceil(target * width / unassigned)
|
||||
-unassigned_target * query_limit // unassigned_seen
|
||||
),
|
||||
)
|
||||
query_limit = min(scan_ceiling, next_query_limit)
|
||||
except Exception:
|
||||
logger.debug("Unassigned CLI refill pass failed", exc_info=True)
|
||||
|
||||
|
||||
Reference in new issue
Block a user