Skip to content

Commit 1c92cf9

Browse files
authored
Evict banned users from free_session slots each admission tick (#526)
1 parent 6bb2c6c commit 1c92cf9

3 files changed

Lines changed: 63 additions & 1 deletion

File tree

web/src/server/free-session/__tests__/admission.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ function makeAdmissionDeps(overrides: Partial<AdmissionDeps> = {}): AdmissionDep
1515
const deps: AdmissionDeps & { calls: { admit: number } } = {
1616
calls,
1717
sweepExpired: async () => 0,
18+
evictBanned: async () => 0,
1819
queueDepth: async () => 0,
1920
activeCountsByModel: async () => ({}),
2021
getFleetHealth: async () => ({}),
@@ -126,4 +127,33 @@ describe('runAdmissionTick', () => {
126127
await runAdmissionTick(deps)
127128
expect(received).toEqual([12_345])
128129
})
130+
131+
test('evicts banned users every tick and surfaces the count', async () => {
132+
let evictCalls = 0
133+
const deps = makeAdmissionDeps({
134+
evictBanned: async () => {
135+
evictCalls += 1
136+
return 4
137+
},
138+
})
139+
const result = await runAdmissionTick(deps)
140+
expect(evictCalls).toBe(1)
141+
expect(result.evictedBanned).toBe(4)
142+
})
143+
144+
test('still evicts banned users when admission is paused by health', async () => {
145+
let evictCalls = 0
146+
const deps = makeAdmissionDeps({
147+
getFleetHealth: async () => fleet('unhealthy'),
148+
evictBanned: async () => {
149+
evictCalls += 1
150+
return 2
151+
},
152+
})
153+
const result = await runAdmissionTick(deps)
154+
expect(evictCalls).toBe(1)
155+
expect(result.evictedBanned).toBe(2)
156+
expect(result.admitted).toBe(0)
157+
expect(result.skipped).toBe('unhealthy')
158+
})
129159
})

web/src/server/free-session/admission.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { getFleetHealth } from './fireworks-health'
1010
import {
1111
activeCountsByModel,
1212
admitFromQueue,
13+
evictBanned,
1314
queueDepth,
1415
sweepExpired,
1516
} from './store'
@@ -20,6 +21,7 @@ import { logger } from '@/util/logger'
2021

2122
export interface AdmissionDeps {
2223
sweepExpired: (now: Date, graceMs: number) => Promise<number>
24+
evictBanned: () => Promise<number>
2325
queueDepth: (params: { model: string }) => Promise<number>
2426
activeCountsByModel: () => Promise<Record<string, number>>
2527
admitFromQueue: (params: {
@@ -39,6 +41,7 @@ export interface AdmissionDeps {
3941

4042
const defaultDeps: AdmissionDeps = {
4143
sweepExpired,
44+
evictBanned,
4245
queueDepth,
4346
activeCountsByModel,
4447
admitFromQueue,
@@ -60,6 +63,8 @@ const defaultDeps: AdmissionDeps = {
6063

6164
export interface AdmissionTickResult {
6265
expired: number
66+
/** Free_session rows removed because the user is banned. */
67+
evictedBanned: number
6368
admitted: number
6469
/** Per-model queue depth at the end of the tick. */
6570
queueDepthByModel: Record<string, number>
@@ -86,7 +91,12 @@ export async function runAdmissionTick(
8691
deps: AdmissionDeps = defaultDeps,
8792
): Promise<AdmissionTickResult> {
8893
const now = (deps.now ?? (() => new Date()))()
89-
const expired = await deps.sweepExpired(now, deps.graceMs)
94+
// Run eviction before admission so a banned user freed from a slot in this
95+
// tick frees room for a queued user to be admitted in the same tick.
96+
const [expired, evictedBanned] = await Promise.all([
97+
deps.sweepExpired(now, deps.graceMs),
98+
deps.evictBanned(),
99+
])
90100

91101
const models = deps.models ?? FREEBUFF_MODELS.map((m) => m.id)
92102

@@ -122,6 +132,7 @@ export async function runAdmissionTick(
122132

123133
return {
124134
expired,
135+
evictedBanned,
125136
admitted: totalAdmitted,
126137
queueDepthByModel,
127138
activeCountByModel,
@@ -145,6 +156,7 @@ function runTick() {
145156
metric: 'freebuff_waiting_room',
146157
admitted: result.admitted,
147158
expired: result.expired,
159+
evictedBanned: result.evictedBanned,
148160
queueDepthByModel: result.queueDepthByModel,
149161
activeCountByModel: result.activeCountByModel,
150162
skipped: result.skipped,

web/src/server/free-session/store.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,26 @@ export async function sweepExpired(now: Date, graceMs: number): Promise<number>
230230
return deleted.length
231231
}
232232

233+
/**
234+
* Drop any free_session row whose user has been banned. Bans flipped via the
235+
* admin UI / direct SQL / Stripe webhook don't cascade into free_session, so
236+
* without this sweep a banned user keeps holding their admitted slot until
237+
* expires_at. Cheap to call every tick (EXISTS subquery, indexed PK lookup).
238+
*/
239+
export async function evictBanned(): Promise<number> {
240+
const deleted = await db
241+
.delete(schema.freeSession)
242+
.where(
243+
sql`EXISTS (
244+
SELECT 1 FROM ${schema.user}
245+
WHERE ${schema.user.id} = ${schema.freeSession.user_id}
246+
AND ${schema.user.banned} = true
247+
)`,
248+
)
249+
.returning({ user_id: schema.freeSession.user_id })
250+
return deleted.length
251+
}
252+
233253
/**
234254
* Atomically admit one queued user for a specific model, gated by the
235255
* upstream health for that model's deployment and guarded by an advisory

0 commit comments

Comments
 (0)