Skip to content

Commit 7d236d3

Browse files
committed
fix: keep fresh stream sessions routable
Soft-pin fresh proxy stream Codex sessions so circuit-filtered pinned assignments can fall back until a strong continuity anchor exists. Also recover stale in-progress Codex turns with no active owner lease.
1 parent 9236153 commit 7d236d3

6 files changed

Lines changed: 409 additions & 21 deletions

File tree

lib/codex_pooler/accounting/request_lifecycle/recovery.ex

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ defmodule CodexPooler.Accounting.RequestLifecycle.Recovery do
55

66
alias CodexPooler.Accounting.{Attempt, LedgerEntry, Request}
77
alias CodexPooler.Accounting.RequestLifecycle
8-
alias CodexPooler.Gateway.Persistence.SessionReadModel
8+
alias CodexPooler.Gateway.Persistence.{CodexTurn, SessionReadModel}
99
alias CodexPooler.Repo
1010

1111
@stale_after_seconds 6 * 60 * 60
@@ -77,25 +77,54 @@ defmodule CodexPooler.Accounting.RequestLifecycle.Recovery do
7777
end
7878

7979
defp release_undispatched_request(%Request{} = request, now) do
80-
RequestLifecycle.finalize_reserved_request_failure(request, %{
81-
request_status: "failed",
82-
response_status_code: 499,
83-
last_error_code: @recovery_code,
84-
usage_status: "not_applicable",
85-
now: now
86-
})
80+
with {:ok, result} <-
81+
RequestLifecycle.finalize_reserved_request_failure(request, %{
82+
request_status: "failed",
83+
response_status_code: 499,
84+
last_error_code: @recovery_code,
85+
usage_status: "not_applicable",
86+
now: now
87+
}) do
88+
recover_stale_turn(request, nil, now)
89+
{:ok, result}
90+
end
8791
end
8892

8993
defp settle_dispatched_request(%Request{} = request, %Attempt{} = attempt, now) do
90-
RequestLifecycle.finalize_request(request, attempt, %{
91-
request_status: "failed",
92-
attempt_status: "failed",
93-
response_status_code: 499,
94-
last_error_code: @recovery_code,
95-
error_message: "stale reservation recovered after request lifecycle was abandoned",
96-
usage: %{status: "usage_unknown", source: @recovery_source},
97-
now: now
98-
})
94+
with {:ok, result} <-
95+
RequestLifecycle.finalize_request(request, attempt, %{
96+
request_status: "failed",
97+
attempt_status: "failed",
98+
response_status_code: 499,
99+
last_error_code: @recovery_code,
100+
error_message: "stale reservation recovered after request lifecycle was abandoned",
101+
usage: %{status: "usage_unknown", source: @recovery_source},
102+
now: now
103+
}) do
104+
recover_stale_turn(request, attempt, now)
105+
{:ok, result}
106+
end
107+
end
108+
109+
defp recover_stale_turn(%Request{id: request_id}, attempt, now) do
110+
final_attempt_id = attempt && attempt.id
111+
112+
CodexTurn
113+
|> where(
114+
[turn],
115+
turn.request_id == ^request_id and turn.status == ^CodexTurn.in_progress_status()
116+
)
117+
|> Repo.update_all(
118+
set: [
119+
status: CodexTurn.interrupted_status(),
120+
error_code: @recovery_code,
121+
final_attempt_id: final_attempt_id,
122+
completed_at: now,
123+
updated_at: now
124+
]
125+
)
126+
127+
:ok
99128
end
100129

101130
defp initial_summary do

lib/codex_pooler/gateway/persistence/session_read_model.ex

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,13 @@ defmodule CodexPooler.Gateway.Persistence.SessionReadModel do
111111
from turn in CodexTurn,
112112
join: session in CodexSession,
113113
on: session.id == turn.codex_session_id,
114+
left_join: lease in BridgeOwnerLease,
115+
on:
116+
lease.codex_session_id == session.id and
117+
lease.status == ^@owner_lease_active and lease.expires_at > ^now,
114118
where:
115119
turn.request_id == ^request_id and turn.status == ^CodexTurn.in_progress_status() and
116-
(is_nil(session.owner_lease_expires_at) or session.owner_lease_expires_at > ^now)
120+
(session.owner_lease_expires_at > ^now or not is_nil(lease.id))
117121
)
118122
end
119123

lib/codex_pooler/gateway/routing/session_continuity.ex

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@ defmodule CodexPooler.Gateway.Routing.SessionContinuity do
44
import Ecto.Query
55

66
alias CodexPooler.Accounting.Request
7+
alias CodexPooler.Catalog.Model
78
alias CodexPooler.Files
89
alias CodexPooler.Gateway.Contracts
910
alias CodexPooler.Gateway.Payloads.ContinuityPayload
1011
alias CodexPooler.Gateway.Payloads.RequestOptions
11-
alias CodexPooler.Gateway.Persistence.{BridgeSessionAlias, CodexSession}
12+
alias CodexPooler.Gateway.Persistence.{BridgeSessionAlias, CodexSession, CodexTurn}
1213
alias CodexPooler.Gateway.Persistence.SessionContinuity, as: ContinuityStore
1314
alias CodexPooler.Gateway.Routing.BridgeRing
1415
alias CodexPooler.Repo
16+
alias CodexPooler.RouteClass
1517
alias CodexPooler.Upstreams.Schemas.{PoolUpstreamAssignment, UpstreamIdentity}
1618

1719
@type auth :: CodexPooler.Access.auth_context()
@@ -154,6 +156,26 @@ defmodule CodexPooler.Gateway.Routing.SessionContinuity do
154156

155157
def filter_file_affinity(candidates, %RequestOptions{}), do: {:ok, candidates}
156158

159+
@spec filter_codex_session_assignment([BridgeRing.candidate()], RequestOptions.t(), Model.t()) ::
160+
{:ok, [BridgeRing.candidate()]} | {:error, gateway_error()}
161+
def filter_codex_session_assignment(
162+
candidates,
163+
%RequestOptions{
164+
continuity: %{codex_session: %CodexSession{pool_upstream_assignment_id: assignment_id}}
165+
} = request_options,
166+
%Model{} = model
167+
)
168+
when is_binary(assignment_id) do
169+
if hard_pin_codex_session_assignment?(request_options, model) do
170+
filter_codex_session_assignment(candidates, request_options)
171+
else
172+
{:ok, prefer_codex_session_assignment(candidates, assignment_id)}
173+
end
174+
end
175+
176+
def filter_codex_session_assignment(candidates, %RequestOptions{} = request_options, %Model{}),
177+
do: filter_codex_session_assignment(candidates, request_options)
178+
157179
@spec filter_codex_session_assignment([BridgeRing.candidate()], RequestOptions.t()) ::
158180
{:ok, [BridgeRing.candidate()]} | {:error, gateway_error()}
159181
def filter_codex_session_assignment(
@@ -175,6 +197,72 @@ defmodule CodexPooler.Gateway.Routing.SessionContinuity do
175197

176198
def filter_codex_session_assignment(candidates, %RequestOptions{}), do: {:ok, candidates}
177199

200+
@spec hard_pin_codex_session_assignment?(RequestOptions.t(), Model.t()) :: boolean()
201+
defp hard_pin_codex_session_assignment?(%RequestOptions{} = request_options, %Model{} = model) do
202+
RequestOptions.route_class(request_options) != RouteClass.proxy_stream() or
203+
continuity_anchor?(request_options) or
204+
file_affinity?(request_options) or
205+
same_model_successful_turn?(request_options, model)
206+
end
207+
208+
@spec continuity_anchor?(RequestOptions.t()) :: boolean()
209+
defp continuity_anchor?(%RequestOptions{continuity: continuity}) do
210+
[
211+
continuity.accepted_turn_state,
212+
continuity.previous_response_id,
213+
continuity.session_header
214+
]
215+
|> Enum.any?(&is_binary(clean_string(&1)))
216+
end
217+
218+
@spec file_affinity?(RequestOptions.t()) :: boolean()
219+
defp file_affinity?(%RequestOptions{routing: %{file_affinity_assignment_id: assignment_id}}),
220+
do: is_binary(clean_string(assignment_id))
221+
222+
@spec same_model_successful_turn?(RequestOptions.t(), Model.t()) :: boolean()
223+
defp same_model_successful_turn?(
224+
%RequestOptions{continuity: %{codex_session: %CodexSession{id: session_id}}} =
225+
request_options,
226+
%Model{} = model
227+
)
228+
when is_binary(session_id) do
229+
case requested_model_identifier(request_options, model) do
230+
requested_model when is_binary(requested_model) ->
231+
Repo.exists?(
232+
from turn in CodexTurn,
233+
join: request in Request,
234+
on: request.id == turn.request_id,
235+
where:
236+
turn.codex_session_id == ^session_id and
237+
turn.status == ^CodexTurn.succeeded_status() and
238+
request.status == "succeeded" and
239+
request.requested_model == ^requested_model
240+
)
241+
242+
nil ->
243+
false
244+
end
245+
end
246+
247+
defp same_model_successful_turn?(%RequestOptions{}, %Model{}), do: false
248+
249+
@spec requested_model_identifier(RequestOptions.t(), Model.t()) :: String.t() | nil
250+
defp requested_model_identifier(
251+
%RequestOptions{routing: %{requested_model: requested_model}},
252+
%Model{exposed_model_id: exposed_model_id}
253+
) do
254+
clean_string(requested_model) || clean_string(exposed_model_id)
255+
end
256+
257+
@spec prefer_codex_session_assignment([BridgeRing.candidate()], Ecto.UUID.t() | String.t()) ::
258+
[BridgeRing.candidate()]
259+
defp prefer_codex_session_assignment(candidates, assignment_id) do
260+
{pinned, fallback} =
261+
Enum.split_with(candidates, fn {assignment, _identity} -> assignment.id == assignment_id end)
262+
263+
pinned ++ fallback
264+
end
265+
178266
@spec pinned_session_assignment_unavailable_error(Ecto.UUID.t() | String.t()) :: gateway_error()
179267
defp pinned_session_assignment_unavailable_error(assignment_id) do
180268
case persisted_pinned_reauth_assignment(assignment_id) do

lib/codex_pooler/gateway/runtime/dispatch/pre_dispatch.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ defmodule CodexPooler.Gateway.Runtime.Dispatch.PreDispatch do
101101
{:ok, request_options} <-
102102
SessionContinuity.attach_codex_session(auth, payload, request_options),
103103
{:ok, candidates} <-
104-
SessionContinuity.filter_codex_session_assignment(candidates, request_options) do
104+
SessionContinuity.filter_codex_session_assignment(candidates, request_options, model) do
105105
route_state =
106106
route_state
107107
|> RouteState.put_candidates(candidates)

test/codex_pooler/accounting_test.exs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,71 @@ defmodule CodexPooler.AccountingTest do
430430
|> Enum.map(& &1.entry_kind) == ["reservation"]
431431
end
432432

433+
test "recovers stale in-progress codex turns when no owner lease is active" do
434+
setup = accounting_setup()
435+
now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
436+
stale_admitted_at = DateTime.add(now, -7, :hour)
437+
438+
assert {:ok, reserved} =
439+
Accounting.reserve(
440+
setup.auth,
441+
setup.model,
442+
%{
443+
"model" => setup.model.exposed_model_id,
444+
"stream" => true,
445+
"max_output_tokens" => 10
446+
},
447+
%{correlation_id: "corr-stale-turn-no-owner", now: stale_admitted_at}
448+
)
449+
450+
assert {:ok, attempt} =
451+
Accounting.create_attempt(reserved.request, setup.assignment, %{
452+
now: stale_admitted_at
453+
})
454+
455+
session =
456+
%CodexSession{
457+
pool_id: setup.pool.id,
458+
api_key_id: setup.api_key.id,
459+
session_key: "session-#{System.unique_integer([:positive])}",
460+
pool_upstream_assignment_id: setup.assignment.id,
461+
status: "active",
462+
created_at: stale_admitted_at,
463+
updated_at: stale_admitted_at
464+
}
465+
|> Repo.insert!()
466+
467+
turn =
468+
%CodexTurn{
469+
codex_session_id: session.id,
470+
request_id: reserved.request.id,
471+
turn_sequence: 1,
472+
transport_kind: "http_sse",
473+
status: "in_progress",
474+
final_attempt_id: attempt.id,
475+
started_at: stale_admitted_at,
476+
created_at: stale_admitted_at,
477+
updated_at: stale_admitted_at
478+
}
479+
|> Repo.insert!()
480+
481+
assert {:ok, %{stale_reservations_released: 0, stale_reservations_settled: 1}} =
482+
Accounting.recover_stale_reservations(now)
483+
484+
assert Repo.get!(CodexPooler.Accounting.Request, reserved.request.id).last_error_code ==
485+
"stale_reservation_recovered"
486+
487+
assert Repo.reload!(attempt).status == "failed"
488+
489+
assert %CodexTurn{
490+
status: "interrupted",
491+
error_code: "stale_reservation_recovered",
492+
final_attempt_id: attempt_id
493+
} = Repo.reload!(turn)
494+
495+
assert attempt_id == attempt.id
496+
end
497+
433498
test "skips already finalized reservations during stale recovery" do
434499
setup = accounting_setup()
435500
now = DateTime.utc_now() |> DateTime.truncate(:microsecond)

0 commit comments

Comments
 (0)