Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions skyrl/tinker/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1515,8 +1515,13 @@ async def retrieve_future(request: RetrieveFutureRequest, req: Request):
else:
response = raw_json_response(result_data)
# Start the retry-grace clock now that the response is built and about to
# be sent, so a large result is never evicted mid-delivery.
if found_in_memory:
# be sent, so a large result is never evicted mid-delivery -- but only if
# this client is still there to receive it. The SDK abandons a poll after
# 45s and retries the same request_id; if the result lands after that,
# this handler wakes on a dead connection (uvicorn drops the send
# silently) and starting the short clock here would let the sweeper
# evict a result nobody received, turning the retry into a 404.
if found_in_memory and not await req.is_disconnected():
external_future_store.mark_retrieved(request_id)
return response

Expand Down
6 changes: 4 additions & 2 deletions skyrl/tinker/external_future_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@ class ExternalFutureStore:
# retry following a lost HTTP response still finds it. Measured from
# delivery (mark_retrieved), never from the in-store read: a large result
# can spend minutes being serialized and sent, and starting the clock at
# read would evict it mid-delivery.
_RETRIEVED_TTL_SECONDS = 120.0
# read would evict it mid-delivery. The SDK re-polls after a 45s client
# timeout plus up to 30s of backoff, so two consecutive misses span 150s;
# the grace has to outlast that.
_RETRIEVED_TTL_SECONDS = 300.0
# Completed but not yet delivered — governs the read/serialize/send window
# and clients that never come back.
_COMPLETED_TTL_SECONDS = 600.0
Expand Down
8 changes: 8 additions & 0 deletions tests/tinker/test_external_future_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ def _sample_input(seq_id: int) -> types.SampleInput:
)


async def _still_connected() -> bool:
"""Stand-in for ``Request.is_disconnected`` on a client that is still waiting."""
return False


class _CompletingForwarder:
def __init__(self, store: ExternalFutureStore):
self.store = store
Expand Down Expand Up @@ -140,6 +145,7 @@ async def test_sustained_model_path_rollouts_training_futures_and_heartbeats(fut
)
),
headers={},
is_disconnected=_still_connected,
)

async with AsyncSession(engine) as session:
Expand Down Expand Up @@ -294,6 +300,7 @@ def serialize_result_in_thread(request_type, result_data):
)
),
headers={"accept": api.PROTO_CONTENT_TYPE},
is_disconnected=_still_connected,
)

responses = await asyncio.gather(
Expand Down Expand Up @@ -643,6 +650,7 @@ async def test_retrieve_future_serializes_in_memory_result_as_proto(future_store
)
),
headers={"accept": "application/x-protobuf, application/json"},
is_disconnected=_still_connected,
)
response = await api.retrieve_future(api.RetrieveFutureRequest(request_id=str(request_id)), request)

Expand Down
159 changes: 159 additions & 0 deletions tests/tinker/test_retrieve_future_lost_response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""A result whose delivery the client never received must survive for the SDK's retry.

Reproduces the 128x128 failure Chuck hit against PR j316chuck/SkyRL#18 (same
code as main): the SDK polls ``retrieve_future`` with a 45s client timeout and
gives up; the result lands afterwards; the abandoned handler still builds a
response and starts the short *retrieved* TTL clock even though nobody got the
bytes; the sweeper evicts the entry; the SDK's retry of the same request_id
gets ``404 Future not found``, which the SDK treats as fatal.

The server runs under a real uvicorn socket so the abandoned poll is a genuine
TCP disconnect, exactly as with the SDK. TTLs are shortened so the whole chain
takes a few seconds.
"""

import asyncio
import sys
from contextlib import suppress
from types import SimpleNamespace

import aiohttp
import pytest
import pytest_asyncio
import uvicorn
from sqlalchemy.ext.asyncio import create_async_engine
from sqlmodel import SQLModel

from skyrl.tinker import api, types
from skyrl.tinker.config import EngineConfig
from skyrl.tinker.db_models import (
RequestStatus,
enable_sqlite_wal,
get_async_database_url,
)
from skyrl.tinker.external_future_store import ExternalFutureStore

BASE_MODEL = "test-model"
RETRIEVED_TTL_SECONDS = 1.0
SWEEP_INTERVAL_SECONDS = 0.2


class _GatedForwarder:
"""Completes each forwarded sample only once the test releases it."""

def __init__(self, store: ExternalFutureStore):
self.store = store
self.release = asyncio.Event()

async def call_and_store_result(self, request_id, sample_req, model_id, checkpoint_id, *, base_model=None):
await self.release.wait()
result = types.SampleOutput(
sequences=[types.GeneratedSequence(stop_reason="length", tokens=[1, 2, 3], logprobs=[-0.1, -0.2, -0.3])]
)
await self.store.complete(request_id, result, RequestStatus.COMPLETED)


@pytest_asyncio.fixture()
async def served_app(tmp_path, monkeypatch):
"""The real API app on a real uvicorn socket, with app.state wired the way the lifespan does."""
monkeypatch.setattr(ExternalFutureStore, "_RETRIEVED_TTL_SECONDS", RETRIEVED_TTL_SECONDS)
monkeypatch.setattr(ExternalFutureStore, "_SWEEP_INTERVAL_SECONDS", SWEEP_INTERVAL_SECONDS)

engine = create_async_engine(get_async_database_url(f"sqlite:///{tmp_path / 'tinker.db'}"))
enable_sqlite_wal(engine.sync_engine)
async with engine.begin() as connection:
await connection.run_sync(SQLModel.metadata.create_all)

store = ExternalFutureStore()
await store.start()
forwarder = _GatedForwarder(store)

state = api.app.state
state.engine_config = EngineConfig(base_model=BASE_MODEL)
state.db_engine = engine
state.future_waiters = {}
state.future_poller = asyncio.create_task(api.poll_futures(engine, state.future_waiters, poll_interval_sec=0.01))
state.proto_serialization_lock = asyncio.Lock()
state.db_write_lock = asyncio.Lock()
state.sampling_model_cache = {}
state.sampling_model_cache_lock = asyncio.Lock()
state.validated_sampler_checkpoints = set()
state.sampler_checkpoint_validation_lock = asyncio.Lock()
state.external_future_store = store
state.external_inference_client = forwarder

config = uvicorn.Config(api.app, host="127.0.0.1", port=0, log_level="warning", lifespan="off")
server = uvicorn.Server(config)
serve_task = asyncio.create_task(server.serve())
while not server.started:
await asyncio.sleep(0.01)
port = server.servers[0].sockets[0].getsockname()[1]

yield SimpleNamespace(url=f"http://127.0.0.1:{port}/api/v1", store=store, forwarder=forwarder)

server.should_exit = True
await serve_task
state.future_poller.cancel()
with suppress(asyncio.CancelledError):
await state.future_poller
await store.close()
await engine.dispose()


@pytest.mark.asyncio
@pytest.mark.skipif(sys.platform != "linux", reason="relies on uvicorn disconnect handling over a real socket")
async def test_retry_after_client_abandoned_poll_is_served(served_app):
payload = {
"num_samples": 1,
"prompt": {"chunks": [{"type": "encoded_text", "tokens": [1, 2, 3]}]},
"sampling_params": {"max_tokens": 3, "temperature": 1.0, "seed": 0},
"base_model": BASE_MODEL,
}
async with aiohttp.ClientSession() as client:
async with client.post(f"{served_app.url}/asample", json=payload) as resp:
assert resp.status == 200
request_id = (await resp.json())["request_id"]

# The SDK's retrieve_future poll times out client-side (45s in the SDK)
# while the result is still pending, and the connection is closed.
with pytest.raises(asyncio.TimeoutError):
await client.post(
f"{served_app.url}/retrieve_future",
json={"request_id": request_id},
timeout=aiohttp.ClientTimeout(total=0.3),
)
await asyncio.sleep(0.2) # let the server observe the disconnect

# The result arrives after the client gave up. The abandoned handler
# wakes, builds a response nobody will receive, and must NOT start the
# short retrieved-TTL clock.
served_app.forwarder.release.set()
await asyncio.sleep(RETRIEVED_TTL_SECONDS + 3 * SWEEP_INTERVAL_SECONDS)

# The SDK retries the same request_id once its backoff elapses.
async with client.post(f"{served_app.url}/retrieve_future", json={"request_id": request_id}) as resp:
body = await resp.text()
assert resp.status == 200, f"retry of an undelivered result got {resp.status}: {body}"
assert types.SampleOutput.model_validate_json(body).sequences[0].tokens == [1, 2, 3]


@pytest.mark.asyncio
@pytest.mark.skipif(sys.platform != "linux", reason="relies on uvicorn disconnect handling over a real socket")
async def test_delivered_result_still_expires_on_retrieved_ttl(served_app):
"""A result the client actually received is reclaimed on the short clock as before."""
payload = {
"num_samples": 1,
"prompt": {"chunks": [{"type": "encoded_text", "tokens": [1, 2, 3]}]},
"sampling_params": {"max_tokens": 3, "temperature": 1.0, "seed": 0},
"base_model": BASE_MODEL,
}
served_app.forwarder.release.set()
async with aiohttp.ClientSession() as client:
async with client.post(f"{served_app.url}/asample", json=payload) as resp:
request_id = (await resp.json())["request_id"]
async with client.post(f"{served_app.url}/retrieve_future", json={"request_id": request_id}) as resp:
assert resp.status == 200
await resp.read()
assert int(request_id) in served_app.store._entries
await asyncio.sleep(RETRIEVED_TTL_SECONDS + 3 * SWEEP_INTERVAL_SECONDS)
assert int(request_id) not in served_app.store._entries
Loading