Skip to content

Commit b6647b1

Browse files
committed
fix(provisioning,server): exit retry loop on Accepted outcome; await runtime in foreground mode
Two live-AWS regressions, both rooted in earlier work on this branch. Provisioning retry loop spawned redundant fleets for async providers. Async providers (EC2Fleet/SpotFleet REQUEST/MAINTAIN) return an Accepted outcome with zero instances on the first call — fulfilment finishes out of band and the status poller reconciles it. The retry loop interpreted "0 fulfilled + not final" as "retry with a new batch", so each accepted request grew a second and third fleet on the side. Polling later saw one healthy fleet and N-1 empty fleets and flipped the request to complete_with_error. Break out of the loop as soon as an Accepted outcome lands so the single accepted fleet drives the rest. No in-tree provider emits RequiresFollowUp today; if one does later, persist_acquiring becomes reachable again. Foreground server start re-entered asyncio.run inside the CLI's own asyncio.run(main()). daemon_mod.start called _spawn_runtime which called asyncio.run, raising RuntimeError "asyncio.run cannot be called from a running event loop". The exception was caught and returned exit_code=1, so the subprocess died silently inside 30s and every rest_api live test hit the "ORB server failed to start" timeout. handle_server_start now drives the pid + token lifecycle directly in foreground mode and awaits the runtime coroutine in the existing event loop. The retry-loop persist_acquiring tests guarded a write that only ran between retry attempts. With Accepted no longer triggering a retry, the persist path is unreachable; the post-call update_request_from_provisioning in request_creation_handlers already stamps IN_PROGRESS (or PARTIAL / FAILED) once the orchestrator returns. Replaced with a test that verifies the Accepted outcome exits the loop after exactly one provider call.
1 parent 4ab16a7 commit b6647b1

4 files changed

Lines changed: 99 additions & 117 deletions

File tree

src/orb/application/services/provisioning_orchestration_service.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,15 @@ async def execute_provisioning(
227227
self._record_provider_failure(selection_result.provider_name)
228228
break
229229

230+
# Async provider accepted the request and is provisioning out of
231+
# band; polling owns the final status from here. Retrying would
232+
# create a second fleet alongside the one already provisioning,
233+
# which then shows up as a phantom failure when it reports empty.
234+
# Break out and let the status-check loop drive the single
235+
# accepted fleet to completion.
236+
if isinstance(last_result.outcome, Accepted):
237+
break
238+
230239
if remaining > 0 and not last_result.is_final:
231240
# Partial fulfillment, retry may help — persist ACQUIRING status
232241
self._logger.info(

src/orb/interface/server_command_handlers.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,41 @@ def _health_url(server_config: Any, ui_config: Any | None) -> str:
142142
@handle_interface_exceptions(context="server_start", interface_type="cli")
143143
async def handle_server_start(args) -> dict[str, Any]:
144144
"""Start the server. Daemonized by default; ``--foreground`` to block."""
145+
import os
146+
145147
from orb.interface import server_daemon as daemon_mod
146148

147149
runtime, server_config, _ui_config = _build_runtime(args)
148150
pid_file, log_file, working_dir = _resolve_lifecycle_paths(server_config)
149151
foreground = getattr(args, "foreground", False)
152+
153+
if foreground:
154+
# Foreground mode runs inside the CLI's own event loop (orb.run.main
155+
# is dispatched via asyncio.run). Routing through daemon_mod.start
156+
# in this path would nest a second asyncio.run, which RuntimeError's
157+
# and surfaces as exit_code=1 with no actual server having started.
158+
# Take the pid + token lock directly here and await the runtime.
159+
logger = get_logger(__name__)
160+
pid_path = daemon_mod._expand(str(pid_file))
161+
log_path = daemon_mod._expand(str(log_file))
162+
wd_path = daemon_mod._expand(str(working_dir))
163+
wd_path.mkdir(parents=True, exist_ok=True)
164+
lock_fd = daemon_mod._acquire_pid_lock(pid_path)
165+
try:
166+
daemon_mod._write_pid(lock_fd, os.getpid())
167+
try:
168+
daemon_mod._write_token_file(pid_path)
169+
except OSError as exc:
170+
logger.warning("loopback handshake file write failed: %s", exc)
171+
log_path.parent.mkdir(parents=True, exist_ok=True)
172+
result = await runtime()
173+
rc = int(result.get("exit_code", 0)) if isinstance(result, dict) else 0
174+
finally:
175+
pid_path.unlink(missing_ok=True)
176+
daemon_mod._cleanup_token_file(pid_path)
177+
os.close(lock_fd)
178+
return {"pid": os.getpid(), "status": "exited", "exit_code": rc}
179+
150180
return daemon_mod.start(
151181
pid_file=pid_file,
152182
log_file=log_file,

tests/unit/application/services/test_provisioning_async_guards.py

Lines changed: 30 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -200,141 +200,62 @@ async def _hang(*_args, **_kwargs):
200200

201201

202202
# ---------------------------------------------------------------------------
203-
# asyncio.to_thread tests
203+
# Accepted-outcome short-circuit
204204
# ---------------------------------------------------------------------------
205205

206206

207-
class TestPersistAcquiringToThread:
208-
"""_persist_acquiring must run in a worker thread via asyncio.to_thread."""
207+
class TestAcceptedOutcomeBreaksRetryLoop:
208+
"""Async providers that return Accepted must not be retried.
209209
210-
@pytest.mark.asyncio
211-
async def test_persist_acquiring_called_via_to_thread(self):
212-
"""asyncio.to_thread is used when calling _persist_acquiring."""
213-
from orb.providers.base.strategy.provider_strategy import ProviderResult
214-
215-
svc = _make_service(dispatch_timeout=10.0)
216-
217-
# First attempt: partial (is_final=False) — triggers persist_acquiring
218-
# Second attempt: fully fulfilled
219-
first_result = ProviderResult.success_result(
220-
data={
221-
"resource_ids": ["i-partial"],
222-
"instances": [{"id": "i-partial"}],
223-
"instance_ids": ["i-partial"],
224-
},
225-
metadata={},
226-
)
227-
second_result = ProviderResult.success_result(
228-
data={
229-
"resource_ids": ["i-rest"],
230-
"instances": [{"id": "i-rest"}],
231-
"instance_ids": ["i-rest"],
232-
},
233-
metadata={},
234-
)
235-
svc._provider_selection_port.execute_operation = AsyncMock(
236-
side_effect=[first_result, second_result]
237-
)
238-
239-
scheduler = MagicMock()
240-
scheduler.format_template_for_provider.return_value = {}
241-
cast(MagicMock, svc._container).get.return_value = scheduler
242-
243-
to_thread_calls: list = []
244-
245-
original_to_thread = asyncio.to_thread
210+
A retry creates a SECOND fleet / batch alongside the one the provider
211+
already accepted; downstream status-check then sees one healthy fleet and
212+
N-1 empty fleets and flips the request to ``complete_with_error``.
246213
247-
async def _spy_to_thread(func, *args, **kwargs):
248-
to_thread_calls.append(func)
249-
return await original_to_thread(func, *args, **kwargs)
250-
251-
# Build a request that needs 2 instances so a second attempt is triggered
252-
request = MagicMock()
253-
request.request_id = "req-persist-test"
254-
request.requested_count = 2
255-
request.metadata = {}
256-
257-
call_counter = [0]
258-
259-
def _update_metadata(d):
260-
call_counter[0] += 1
261-
return request
262-
263-
request.update_metadata = _update_metadata
264-
265-
# _persist_acquiring needs update_status
266-
request.update_status = MagicMock(return_value=request)
267-
268-
# Patch asyncio.to_thread in the module under test
269-
with patch(
270-
"orb.application.services.provisioning_orchestration_service.asyncio.to_thread",
271-
side_effect=_spy_to_thread,
272-
):
273-
# We don't need the full loop to run — just verify to_thread is called
274-
# by exercising the partial-fulfillment path
275-
await svc.execute_provisioning(_make_template(), request, _make_selection_result())
276-
277-
# _persist_acquiring should have been scheduled via to_thread
278-
persist_calls = [f for f in to_thread_calls if f == svc._persist_acquiring]
279-
assert len(persist_calls) >= 1, (
280-
"_persist_acquiring was not dispatched via asyncio.to_thread"
281-
)
214+
This contract replaces the previous retry-loop tests against
215+
``_persist_acquiring``: the persist hook only ran on the retry path,
216+
and the retry path now exits immediately on ``Accepted`` (the only
217+
outcome any in-tree provider emits when more attempts could possibly
218+
help). If a future provider emits ``RequiresFollowUp``, the retry
219+
persist path becomes reachable again and tests should be restored.
220+
"""
282221

283222
@pytest.mark.asyncio
284-
async def test_persist_acquiring_failure_does_not_abort_loop(self):
285-
"""If _persist_acquiring raises, the retry loop continues with in-memory state."""
223+
async def test_accepted_outcome_exits_loop_after_one_attempt(self):
224+
"""A single Accepted attempt must end the loop even with remaining > 0."""
286225
from orb.providers.base.strategy.provider_strategy import ProviderResult
287226

288227
svc = _make_service(dispatch_timeout=10.0)
289228

290-
# Two partial attempts, then a final one
291-
partial_result = ProviderResult.success_result(
292-
data={
293-
"resource_ids": ["i-p"],
294-
"instances": [{"id": "i-p"}],
295-
"instance_ids": ["i-p"],
296-
},
297-
metadata={},
298-
)
299-
final_result = ProviderResult.success_result(
229+
accepted_result = ProviderResult.success_result(
300230
data={
301-
"resource_ids": ["i-f"],
302-
"instances": [{"id": "i-f"}],
303-
"instance_ids": ["i-f"],
231+
"resource_ids": ["fleet-abc"],
232+
"instances": [],
233+
"instance_ids": [],
304234
},
235+
# fulfillment_final omitted (defaults to False) → Accepted outcome.
305236
metadata={},
306237
)
307238
svc._provider_selection_port.execute_operation = AsyncMock(
308-
side_effect=[partial_result, final_result]
239+
side_effect=[accepted_result, accepted_result, accepted_result]
309240
)
310241

311242
scheduler = MagicMock()
312243
scheduler.format_template_for_provider.return_value = {}
313244
cast(MagicMock, svc._container).get.return_value = scheduler
314245

315246
request = MagicMock()
316-
request.request_id = "req-persist-fail"
247+
request.request_id = "req-accepted-once"
317248
request.requested_count = 2
318249
request.metadata = {}
319250
request.update_metadata = lambda d: request
320251
request.update_status = MagicMock(return_value=request)
321252

322-
# Make _persist_acquiring return failure (second return value = False)
323-
def _failing_persist(req):
324-
return req, False
325-
326-
svc._persist_acquiring = _failing_persist # type: ignore[method-assign]
327-
328-
async def _inline_to_thread(func, *args, **kwargs):
329-
return func(*args, **kwargs)
330-
331-
with patch(
332-
"orb.application.services.provisioning_orchestration_service.asyncio.to_thread",
333-
side_effect=_inline_to_thread,
334-
):
335-
await svc.execute_provisioning(_make_template(), request, _make_selection_result())
253+
result = await svc.execute_provisioning(
254+
_make_template(), request, _make_selection_result()
255+
)
336256

337-
# Loop should have continued despite persist failure
338-
cast(MagicMock, svc._logger).warning.assert_called()
339-
warning_msgs = " ".join(str(c) for c in cast(MagicMock, svc._logger).warning.call_args_list)
340-
assert "ACQUIRING persist failed" in warning_msgs or "persist" in warning_msgs.lower()
257+
# Exactly one provider call: the Accepted outcome short-circuits the
258+
# loop before another attempt can be made.
259+
assert svc._provider_selection_port.execute_operation.await_count == 1
260+
# The single accepted fleet is recorded in the returned resource_ids.
261+
assert result.resource_ids == ["fleet-abc"]

tests/unit/interface/test_server_command_handlers.py

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import argparse
66
import json
7+
from pathlib import Path
78
from unittest.mock import AsyncMock, MagicMock, patch
89

910
import pytest
@@ -100,21 +101,42 @@ async def test_start_foreground_false_calls_daemon_start(self, _mock_paths, mock
100101
@pytest.mark.asyncio
101102
@patch(_BUILD_RUNTIME)
102103
@patch(_RESOLVE_PATHS, return_value=("/tmp/srv.pid", "/tmp/srv.log", "/tmp"))
103-
async def test_start_foreground_true_passes_flag(self, _mock_paths, mock_build_runtime):
104+
async def test_start_foreground_awaits_runtime_directly(
105+
self, _mock_paths, mock_build_runtime
106+
):
107+
"""Foreground mode must await the runtime in the caller's event loop.
108+
109+
Routing through daemon_mod.start would nest a second asyncio.run inside
110+
the CLI's own asyncio.run(main()) and the runtime would never reach
111+
``await server.serve()``. The handler now drives the lock/token
112+
lifecycle directly and ``await``s the runtime.
113+
"""
104114
server_cfg = _make_server_config()
105-
runtime = AsyncMock(return_value={"message": "Server stopped"})
115+
runtime = AsyncMock(return_value={"exit_code": 0})
106116
mock_build_runtime.return_value = (runtime, server_cfg, None)
107117

108118
mock_daemon = MagicMock()
109-
mock_daemon.start.return_value = {"pid": 42, "status": "foreground"}
119+
mock_daemon._expand.side_effect = lambda p: Path(p)
120+
mock_daemon._acquire_pid_lock.return_value = 99
110121

111-
with patch("orb.interface.server_daemon", mock_daemon, create=True):
122+
with (
123+
patch("orb.interface.server_daemon", mock_daemon, create=True),
124+
patch("os.close"),
125+
):
112126
from orb.interface.server_command_handlers import handle_server_start
113127

114-
await handle_server_start(_args(foreground=True))
115-
116-
call_kwargs = mock_daemon.start.call_args.kwargs
117-
assert call_kwargs["foreground"] is True
128+
result = await handle_server_start(_args(foreground=True))
129+
130+
# daemon_mod.start MUST NOT be called in foreground mode — that
131+
# would re-enter asyncio.run and crash silently with exit_code=1.
132+
mock_daemon.start.assert_not_called()
133+
# The runtime coroutine must be awaited in the existing loop.
134+
runtime.assert_awaited_once()
135+
# The handler still owns lock + token lifecycle.
136+
mock_daemon._acquire_pid_lock.assert_called_once()
137+
mock_daemon._write_pid.assert_called_once()
138+
assert result["status"] == "exited"
139+
assert result["exit_code"] == 0
118140

119141
@pytest.mark.asyncio
120142
@patch(_BUILD_RUNTIME)

0 commit comments

Comments
 (0)