Skip to content

feat(server): async request lifecycle, strict request schema, and execution guarantees - #286

Draft
Yunnglin wants to merge 25 commits into
mainfrom
feat/server-execution-guarantees
Draft

Yunnglin wants to merge 25 commits into
mainfrom
feat/server-execution-guarantees

Conversation

@Yunnglin

@Yunnglin Yunnglin commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

Hardens the Twinkle server's entire request-serving path across three sequenced specs, then enforces lint on the client and lands the supporting refactors. Client and server share one request-model contract; every serving path has a strict time bound; failures propagate loudly.

  • Execution guarantees — every serving path has a strict time upper bound; failures propagate as a complete ErrorPayload instead of degrading silently; a shared pydantic contract base.
  • Async submit/retrieve lifecycle (Part 2) — twinkle-native requests submit work and return a TaskEnvelope; results are delivered through a persistent future record + retrieve endpoint, decoupled from the HTTP connection.
  • Request schema (Part 3) — one shared request-model source of truth, a strict control plane, and a data-plane Wire Schema validated at HTTP ingress before anything reaches a GPU.

Key changes

1. Execution guarantees (time bounds + loud failures)

  • Effective time bound on the sync=True dispatch path; blocking calls moved off the event loop via call_backend + a per-replica admission gate; AST guard forbids new direct backend calls.
  • Post-timeout actor liveness probe + health bit; two distinct bounds (record-terminal vs. resource-release) documented.
  • Removed all silent-degradation layers and the TWINKLE_FAIL_FAST switch; nccl_safe reduced to a single nccl_safe_megatron decorator.
  • Sampler streams gain a double timeout (total + per-token); failures surface as ErrorPayload wire frames.

2. Async submit/retrieve lifecycle (Part 2)

  • run_submit / submit_and_peek submit shell: handlers submit and return immediately; the client future layer resolves the envelope to a terminal state.
  • Persistent FutureRecord (with replica_id / absolute_deadline); cleanup reads each record's own deadline; do-not-regress guard extended to terminal->terminal.
  • Idempotent seq_id dedup keyed on (session, adapter, seq_id) so a retried gradient-mutating call applies at most once.

3. Request schema (Part 3)

  • Single shared request models in twinkle_client.types; the client builds them via build_request + one model_dump_json(exclude_none=True) instead of hand-assembling json_data.
  • StrictRequest on all twinkle-native routes (unknown top-level field -> 422); ResponseModel (extra='ignore') so older clients tolerate new response fields; token is rejected in the body.
  • Explicit field roles (control / backend_kwarg / passthrough): each backend field forwarded at most once, control fields never forwarded, passthrough regions flattened and forwarded unjudged (no spelling heuristic).
  • run_submit preflight rejects backend-incompatible fields (422) and unavailable endpoints (501) before the seq claim / enqueue -> a rejected request runs on zero data-parallel ranks.
  • Wire Schema validates inline inputs at HTTP ingress: strict integer leaves (reject bool/float), homogeneous batch, unknown-JSON-field preservation on export, shallowest-first unions; a single shared is_encoded predicate replaces three copies.
  • Unified RequestValidationError -> ErrorPayload handler registered on the shared deployment app builder (Model / Sampler / Processor identical).
  • Naming disambiguation: sampler-domain models are Sampler-prefixed so they no longer collide with model.py; fixes add_adapter_to_sampler silently binding the wrong (model) schema and rejecting a dict config.

4. Lint enforcement & refactors

  • Remove src/twinkle_client/ from the pre-commit exemptions; the client is now linted like the server, and the existing client code is normalized to green.
  • Rename server/utils/validation.py -> auth.py (ends the collision with the server/validation preflight package) and model/utils.py -> data_plane_inputs.py.
  • Rename client http/http_utils.py -> http/client.py and http/utils.py -> http/context.py; update all importers.
  • Split auto/agent/tools.py into tool_schemas.py / server_tools.py / search_tools.py; rework rollout/multi_turn.py into an explicit _RolloutState.

Breaking changes

  • Control-plane strictness and the Wire Schema are breaking wire changes: unknown top-level fields and previously-tolerated bool/float/heterogeneous inline inputs are now rejected. Client and server must be upgraded in lockstep — no version negotiation or lax mode is introduced.

Testing

  • Schema: preflight (422/501 with no future record and no backend call), request-wire (StrictRequest, token rejection, seq_id acceptance, sampler-binding regression), wire-schema (classification, homogeneity, strict ints, extension round-trip, CORE_INPUT_KEYS/VLM_CONCAT_FIELDS consistency).
  • Execution guarantees / lifecycle: error-wire, stream guarantees, nccl_safe, blocking-boundary + actor-recovery integration, future-lifecycle / error-payload state tests, and the zero-wire-change contract baseline guard.
  • pre-commit run --all-files green across server + client; local unit suites green in the vLLM env.

…es work

T0.1: capture current HEAD client-facing surface so the post-refactor
comparison (T8.1) shows only this spec's changes. No code changes.
…1.6)

- execute_all_sync forwards timeout to ray.get (T1.1)
- resolve effective ray.get timeout before choosing execute_method and flip
  priority to 'decorator wins, instance is fallback' at both dispatch sites;
  fix the 0-treated-as-falsy trap; bound __len__/__next__ bare ray.get (T1.2, T1.5)
- decorator timeout=10 on ping; timeout=3600 on save/add_adapter_to_model/
  resume_from_checkpoint/tinker_load/load_full_weights_from_path (T1.3)
- TaskQueueConfig default execution_timeout 120->1800; effective_execution_timeout
  (0 -> 3600) as the single bound source; startup warning on 0 (T1.4)
- infra unit tests, no GPU/Megatron/server deps (T1.6)
…2.1-T2.4, T6.1)

- twinkle_client/types/base.py: StrictRequest/ResponseModel/DataModel + backend_only()
  helper/reader; naming rulings in module docstring (defined, not applied) (T6.1)
- twinkle_client/types/errors.py: ErrorCategory + ErrorPayload(ResponseModel) (T2.1)
- task_errors.py: task_error_payload builds ErrorPayload dict (request_id/error_code,
  traceback split+tail-trim, User carries no traceback); error_payload_from_stored
  backfills legacy two-field payloads without ValidationError (T2.2)
- worker: single-line error summary + full traceback in traceback field; TimeoutError
  and Ray_Get_Timeout -> 504/Server, others -> 500/Server (T2.3)
- tests for ErrorPayload + updated task_errors test (T2.4)
…e 6, T6.2-T6.3)

- QueueStateLiteral in types/errors.py, values sourced to match server QueueState;
  consistency test asserts equal value sets (T6.2)
- T6.3 realized as a guard test (per user ruling): twinkle_client already shares
  18 public names with tinker.types by design (tinker-compatible client), so the
  literal 'no intersection' cannot hold without renaming twinkle. Guard instead
  asserts no src/twinkle module binds a tinker and a twinkle_client type to the
  same local name (tinker must be aliased when both coexist).
…T3.8)

- call_backend: dedicated ThreadPoolExecutor (no max_workers=1) + per-replica
  opt-in Admission_Gate; gate released from the worker thread's finally so a
  wait_for-cancelled coroutine cannot free it while the call is still in flight;
  fast-fail BackendBusyError when the gate is held by a leaked call (T3.1)
- ModelManagement enables the gate; SamplerManagement does not
- worker maps BackendBusyError -> 503/Server
- mechanical: 27 model/twinkle + 14 model/tinker + 8 sampler/twinkle + 4
  sampler/tinker + 3 model/app direct backend calls -> await call_backend (T3.2-T3.5)
- check_model_health async + admit=False ping; _cleanup_adapter via gate; /healthz
  awaits (T3.5, T3.6)
- AST static check over src/twinkle/server/** + shared exemptions file (T3.7)
- blocking-boundary integration tests (T3.8)
… T4.1-T4.3)

- set _ray_get_timeout = effective execution timeout on model/sampler backends,
  effective for both sync and async dispatch (T4.1)
- ComputeWorker fires an optional on_backend_timeout hook after a timeout;
  ModelManagement probes actor liveness (admit=False ping) and sets a health bit
  that /healthz reflects (503) and a successful probe auto-clears (T4.2)
- worker skips a dequeued task whose record is already terminal (R3#8); document
  record-terminal (queue_timeout+T) vs resource-release (Collect_Width*T) bounds in
  EN+ZH Server docs (T4.3)
…1-T5.6)

- FutureRecord.replica_id set at creation, never overwritten (T5.1)
- ReplicaRegistry last_seen as a separate key (max_loras type unchanged);
  refreshed in _on_request_start; ModelManager.get_alive_replica_ids (T5.2)
- cleanup_expired rewrite: never deletes a non-terminal record; orphans and
  over-absolute-ttl records are written failed; signature adds alive_replica_ids
  + absolute_ttl; ServerState.set_execution_bounds injects queue_timeout/T/
  Collect_Width; age uses the stored-timestamp clock convention (T5.3)
- do-not-regress guard extended to terminal->terminal (warn on different,
  silent drop on same) (T5.4)
- sampler _stream_generator: 60s per-get + total-lifetime bound, ray Queue
  shutdown in finally, empty-actor structured error (T5.5)
- state hygiene tests via Ray-free FileBackend (T5.6)
- delete TWINKLE_FAIL_FAST from the 4 cookbook server configs (T7.1)
- delete Layer 1 (safe_loss/SafeLossWrapper/_zero_loss) + OptimizerGroup.__setattr__
  auto-wrap hook + the test_micro_batch safe_loss import/case (T7.2)
- delete Layer 2 (@nccl_safe decorator, _force_zero_backward, _iter_model_params)
  and its two decoration points + import in transformers_model (T7.3)
- delete Layer 4: the forward_step_func post-processing try/except in megatron.py,
  exceptions now propagate; drop the _is_fail_fast import (T7.4)
- delete Fail_Fast_Switch: _is_fail_fast + env_propagation NCCL_SAFE_ENV_KEYS/
  build_nccl_safe_env_vars and its call (T7.5)
- nccl_safe_megatron rewritten to unconditional rank-attributed log + re-raise,
  no tinker/forward_only params, no degraded return; module docstring records the
  lost coverage window (T7.6, T7.9)
- rewrite both GPU-gated e2e tests to assert failed-terminal + subsequent-success
  and drop degradation symbols (T7.7)
- unified static check asserting the 8 symbols are absent (T7.8)
…e 8, T8.1/T8.2/T8.4)

- test_client_api_contract.py: OpenAPI surface == pre-impl baseline (T0.1),
  schedule_task_and_wait retained, only new client modules base.py/errors.py (T8.1)
- new checks are auto-run by CI 'pytest tests'; lint.yaml runs pre-commit --all-files;
  applied yapf/isort/pyupgrade auto-fixes so all hooks pass on changed src files (T8.2)
- tests/server/README.md documents the mock-backend evidence boundary (T8.4)
…endent

Root cause: _now_iso() wrote naive local time while _parse_timestamp() reads a
naive ISO string as UTC; compared against a time.time()-based cutoff this skewed
every expiry check by the host's UTC offset (premature deletion west of UTC,
over-retention east; invisible on UTC/CI hosts).

Fix (option A): _now_iso() now emits UTC-aware ISO; future_manager reuses it for
record writes and its cleanup 'now' reverts to time.time() (both UTC epoch).
_parse_timestamp is unchanged and still parses legacy naive records as UTC
(same as before). Not a wire-schema change (R8 unaffected).

Adds a timezone-independent regression test asserting a freshly written record
parses to within 1s of time.time(); the suite passes under TZ=America/Los_Angeles.
# Conflicts:
#	src/twinkle/server/model/app.py
…m/gen bounds)

Complete the server-execution-guarantees spec by routing the remaining
sampler paths through the Blocking_Call_Boundary and bounding every
long-lived operation:

- sampler: submit/collect/cancel generation, unload, and streaming now go
  through call_backend; _await_generation is bounded by the effective
  execution timeout and _stream_queue enforces a double timeout (total +
  per-token) instead of blocking indefinitely.
- task queue: thread collect_width through _init_task_queue so state
  hygiene computes the absolute survival TTL without a separate
  set_execution_bounds hop.
- errors: stream/generation failures surface as ErrorPayload wire frames
  rather than silent drops.
- deps: bump tinker to 0.29.0 (python>=3.11) and refresh poetry.lock.
- tests/docs: add error-wire, stream-guarantee and nccl_safe coverage;
  refresh the client API contract baseline (zero wire change).
tinker 0.29.0 turned its tensor request types into dataclasses, which
breaks FastAPI OpenAPI generation. Revert the pin and the code that had
been migrated to the 0.29.0 API:

- pyproject: tinker 0.29.0 -> 0.16.1; restore poetry.lock to match
- sampler/tinker_handlers: use 0.16.1 SampledSequence/SampleResponse fields
- contract harness: fastapi 0.136 ModelField compat; normalize :path route
  converters so the baseline stores client-facing paths
- regenerate client_api_baseline.json against tinker 0.16.1
- test fixtures: add call_backend/_task_queue_config; drop 0.29-only assertion
…ecycle

Implements the server-request-lifecycle spec (Part 2). A single HTTP request's
server-side duration is now decoupled from task execution time: submit enqueues
and returns a TaskEnvelope immediately, and the client's future layer polls a
dedicated retrieve endpoint.

The motivation is narrow and load-bearing: the client's per-request timeout was
600s while most ingress gateways cut idle connections at 60s, so data-plane
endpoints (forward_backward, sample) were already unusable behind a real gateway.
Every single HTTP request is now bounded by the 30s long-poll window regardless of
how long the task runs. This does not improve throughput or training speed -- the
compute queue is still serial and GPU utilisation is unchanged.

Server:
- new twinkle/server/lifecycle/: envelope.py (the one FutureRecord -> TaskEnvelope
  mapping point), poll_config.py (single declaration of the long-poll window and
  interval, shared by both retrieve endpoints), submit.py (run_submit shell plus the
  to_backend_inputs / backend_kwargs / input_metrics seams left for Part 3)
- new POST /twinkle/retrieve_future and POST /twinkle/cancel
- preflight now raises RequestRejectedError subclasses, so a rejected request
  returns a real status code and writes zero future records
- TwinkleServerError handler puts ErrorPayload fields at the response top level
- delete schedule_task_and_wait, run_task (both copies), QueuedTask.completion,
  _complete_result/_complete_error and persist_status; the future record is now the
  only delivery channel for results and failures
- delete TaskStatus.RATE_LIMITED (limiting is now HTTP 429), the get_state_dict
  endpoint and the upload_status endpoint

Client:
- new types/lifecycle.py (TaskEnvelope), _future.py (the only polling loop),
  exceptions.py (TwinkleHTTPError / TaskFailedError / TaskCancelledError /
  TaskWaitTimeoutError / TaskRecordLostError)
- three separate 600s timeout literals collapse into _HTTP_TIMEOUT = 90
- public methods keep their synchronous signatures and return types, so cookbook
  scripts and integration tests are unchanged

Breaking changes: queued endpoints return TaskEnvelope instead of a business model;
task failure raises TaskFailedError (HTTP 200 + payload) instead of requests.HTTPError
(HTTP 500); get_state_dict is removed (use save + read the checkpoint).

Verified on real PPU hardware (Qwen3.5-4B, 8x ZW810): SFT/DPO/GRPO x twinkle/tinker
on the transformers backend and SFT x twinkle/tinker on megatron, 8/8 passing with
losses identical to the pre-refactor run. Unit suite: 360 passed, 0 failed.
…ixins

- move sampler weight resolution to sampler/weights.py and streaming
  bridge to sampler/backends/streaming.py so handlers stay thin
- rename utils/lifecycle to utils/session_resource: the package holds
  session-scoped resource mixins, not the request lifecycle owned by
  twinkle/server/lifecycle
- add static guards for adapter-name mapping and package-root imports
- declare grimp test dependency used by the import-boundary guards
…ing disambiguation

Move HTTP-boundary-decidable request problems out of the async training path:

- Single shared request models (twinkle_client.types) with field roles
  (control / backend_kwarg / passthrough); client builds via build_request +
  model_dump_json instead of hand-assembling json_data.
- StrictRequest on all twinkle-native routes; unified RequestValidationError ->
  ErrorPayload (422/501) registered on the shared deployment app builder.
- Wire schema for inline `inputs` validated at HTTP ingress (strict int leaves,
  homogeneous batch, extension-field preservation); single shared `is_encoded`
  predicate in twinkle.data_format.encoding replaces three copies.
- run_submit preflight (assert_request_supported) rejects backend-incompatible
  fields and unavailable endpoints before seq claim / enqueue -> zero DP ranks.
- Passthrough keys forwarded unjudged (no spelling heuristic).

Naming disambiguation (client/server contract fix):
- Prefix sampler-domain models (SamplerAddAdapterRequest / SetTemplate* /
  CreateResponse) so they no longer collide with model.py; the sampler handler
  now binds sampler_types explicitly. Fixes add_adapter_to_sampler validating
  against model.py's `config: Optional[str]` and rejecting the dict the client
  sends.
- Remove dead model.AddAdapterResponse and server.WeightsInfoResponse.
- training.py response envelopes inherit ResponseModel.
- Regenerate contract route inventory; add regression tests pinning the sampler
  binding and the dict-config acceptance.
Auto-fixes flagged by CI on b928a17 (pre-commit run --all-files):
- sampler/twinkle_handlers.py: isort import order + wrap the 122-char
  create() signature (E501).
- validation/backend_compat.py: pyupgrade Optional[str] -> str | None.
…ing code

Remove src/twinkle_client from the pre-commit exemptions (flake8/isort/yapf/
pyupgrade + the whitespace/EOL/quote fixers) so the client is linted like the
server, and bring the existing (previously-unchecked) client code to a green
`pre-commit run --all-files`:

- isort/yapf/pyupgrade/double-quote normalization across the client package.
- Wrap over-length tool/description/error strings (implicit concatenation,
  content preserved) in auto/agent/tools.py and utils/patch_tinker.py.
- Rename ambiguous loop var `l` -> `line` (E741) in auto/agent/monitor.py.
- `# noqa: E402` on the intentional late import in twinkle_client/__init__.py.
- setup.cfg per-file-ignores: E501 for auto/agent/monitor.py (embedded LLM
  prompt with verbatim long lines).
- types/__init__.py: scoped `# yapf: disable` around the re-export block so the
  45-name `from .model import (...)` stops oscillating between isort's aligned
  wrap and yapf's hanging wrap (isort still owns the ordering).
…ut cleanup

Server:
- Rename utils/validation.py -> utils/auth.py to end the collision with the
  server/validation preflight package (auth/session helpers vs request checks).
- Rename model/utils.py -> model/data_plane_inputs.py to name what it does.

Client:
- Rename http/http_utils.py -> http/client.py and http/utils.py -> http/context.py;
  update all importers.
- Split auto/agent/tools.py into tool_schemas.py (schemas), server_tools.py and
  search_tools.py (ToolExecutor mixins); add test_auto_agent_tools.py.
- Rework rollout/multi_turn.py into an explicit _RolloutState with
  _initialize_state / _process_sequence helpers.

All touched files pass `pre-commit run --all-files` (client now linted).
@Yunnglin Yunnglin changed the title feat(server): server execution guarantees (time bounds + loud failures + contract base) feat(server): async request lifecycle, strict request schema, and execution guarantees Sep 18, 2026
…pe; repair PPU full test suite

- multi_lora: mirror PeftModel.__init__ adapter dtype autocast on each add_adapter slot (drop unconditional float() normalization)
- transformers: make _ensure_lora_dtype a @staticmethod; update call site
- align tests with PEFT 0.18.1 target-parameter shapes and transport API; loosen slow-startup/backend timeouts
- refresh twinkle client cookbooks/docs for client-as-factory usage
…ng R6-R13)

- R6: bounded fail-open session liveness via last_liveness_confirmed_at
- R7: full ABC hooks + cluster-global processor lease quota (429/User)
- R8: ConcurrencyError->StateBackendError(503); close() releases handle only,
  flush_all() for teardown; backend contract docstrings
- R9: drop **kwargs pseudo-polymorphism; ModelManager quota -> 429/User
- R10: Twinkle-native ErrorPayload single exit; EndpointUnavailableError moved
  to server/exceptions.py; no-HTTPException static guard
- R11: FutureFailureRecord domain failure; protocol-boundary wire mapping
- R12: client ErrorPayload parse with details/traceback, lowercase category
- R13: remove unrunnable sampler 'torch' option

Verified: unit/contract regression (361 passed) + full 2x2x3 E2E matrix
(transformers+megatron x twinkle+tinker x sft/dpo/grpo, 12/12 passed incl.
save-LoRA/state + resume).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant