Skip to content

docs: add the OpenAI-compatible batch API proposal - #1812

Open
aeron-gh wants to merge 6 commits into
volcano-sh:mainfrom
aeron-gh:docs/batch-api-proposal
Open

aeron-gh wants to merge 6 commits into
volcano-sh:mainfrom
aeron-gh:docs/batch-api-proposal

Conversation

@aeron-gh

@aeron-gh aeron-gh commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

What type of PR is this?

/kind documentation

What this PR does / why we need it:

Adds the design proposal for the OpenAI-compatible Batch API discussed in #1291, under docs/proposal/.

Neither vLLM nor SGLang serves /v1/files or /v1/batches on its online server (vLLM's batch mode is the offline vllm run-batch, which starts its own engine, and its /v1/chat/completions/batch is a single synchronous request), so Kthena owns the batch lifecycle and sends each input line through the router's own request path.

What the proposal covers:

  • API: OpenAI-compatible /v1/files and /v1/batches, the same object shapes, line format, status flow and error envelope, with every difference listed.
  • State: job records, queue, leases and checkpoints in Redis; file bytes behind a FileStore interface, first backed by a shared volume.
  • Ownership: a renewable lease and a random fence token, so one replica owns a batch and a partitioned owner cannot corrupt the new owner's work.
  • Resumption: per-owner result segments with a valid-byte prefix, plus a done bitmap, so every custom_id is answered exactly once in the committed output across a takeover.
  • Dispatch: each line goes through the router's handler chain in an engine that binds no listener, so route matching, rate limiting, fairness and the KV-cache aware plugins all apply, and the identity cannot be forged from the network.
  • Failures and limits: temporary failures retry instead of failing the line, and per-batch and per-tenant storage limits plus a sweeper keep one batch from filling the volume.
  • Operations: off by default, Helm values, an optional worker pool, readiness that never affects interactive traffic, cleanup, metrics, and a test plan.

The numbers in it come from a working proof of concept: 20k-request batches complete with 0 missing and 0 duplicated custom_id across a forced pod kill, and router memory stays at 15-40 MiB at 50k requests. Testing the proof of concept against this design found gaps, and the proposal lists each one with what the design does instead.

Which issue(s) this PR fixes:

Part of #1291

Bug evidence (required for bug-related PRs):

N/A

Special notes for your reviewer:

Documentation only, no code. Once the design is agreed I'll send the implementation as small serial PRs, in the order under Implementation Plan.

Five questions are open, each with a recommendation, under Decisions needed: the dispatch hook, how batch lines count against rate limits and fairness, the Redis persistence requirement, session stickiness, and one model per input file.

Does this PR introduce a user-facing change?:

NONE

Copilot AI lite review requested due to automatic review settings September 22, 2026 10:43
@volcano-sh-bot volcano-sh-bot added the kind/documentation Improvements or additions to documentation label Sep 22, 2026
@volcano-sh-bot

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign git-malu for approval. For more information see the Kubernetes Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@aeron-gh

Copy link
Copy Markdown
Member Author

@YaoZengzeng @kube-gopher
PTAL

@YaoZengzeng YaoZengzeng left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the full proposal and verified the codebase claims (the /v1/*path catch-all at cmd/kthena-router/app/router.go:296, common.UserIdKey, fairness keying). The overall shape — lease + fence, per-ownership segments with valid-byte prefixes, done bitmap, in-process dispatch — is sound, and the PoC evidence is appreciated. Left a few design questions inline; the main one is how the context-carried identity bridges into the gin middleware chain, since fairness/rate-limiting/access-log all key off gin context state set by middleware.


Two details matter:

- **Identity travels in the context, not a header.** The owning tenant and, under Gateway

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could the proposal specify exactly where in the chain a dispatched line enters? Today /v1 requests flow through AccessLogMiddleware and AuthMiddleware before router.HandlerFunc() (cmd/kthena-router/app/router.go:293-296), and per-user rate limiting / fairness read c.GetString(common.UserIdKey), which is set only by the auth middleware from the verified token (filters/auth/authentication.go:306).

  • If batch dispatch enters at HandlerFunc() directly, UserIdKey is never set, so batch lines run with an empty user — the "same rate limits, the same fairness queue, the same access log" claim wouldn't hold.
  • If it goes through the full gin engine, the auth middleware will reject lines that carry no credentials, so it must be taught to trust the context-carried identity — a security-sensitive change that deserves to be spelled out here (which code checks the context, and how it's kept unreachable from real network requests).

Either answer is fine, but the bridging mechanism is the crux of this design decision and should be explicit in the doc.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doc didn't say, my bad. lines go through a second gin engine, not the network one -- recovery -> batchIdentity -> handler, never bound to a listener. batchIdentity stands in for AuthMiddleware: reads identity off the request context, 403 if missing, sets UserIdKey + gateway keys. real AuthMiddleware untouched. written up under Dispatch now

mountPath: /var/lib/kthena/batch
fsGroup: 65532
worker:
enabled: true # false: serve the API here, run batches elsewhere

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Story 2 says "enable the batch worker on two of six router replicas", but the chart renders a single router Deployment, so worker.enabled applies to all replicas uniformly. How does an operator actually achieve the two-of-six split — a second Deployment / separate router pool with different values? Worth stating, since the isolation story (and the per-replica p99 numbers) leans on this capability.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeahh right, one Deployment so it's uniform. story 2 now says two router pools sharing one redis + volume, and Configuration spells out that the split needs a second chart release.

| --- | --- | --- |
| `job:<id>` | hash | the batch object, plus `holder`, `fence`, and per-status timestamps |
| `file:<id>` | hash | file record: tenant, filename, purpose, status, bytes, expiry |
| `queue` | zset | claimable job IDs, scored by enqueue time |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The queue is FIFO by enqueue time with no per-tenant limits: one tenant queuing many 50k-line batches starves everyone else, and nothing bounds the total number of queued jobs. Consider per-tenant caps (max queued / max active batches) in the limits block — OpenAI enforces per-org batch queue limits for the same reason. Fine as a stated follow-up, but worth a sentence now.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okiee agreed, nothing bounds it today. added maxQueuedBatchesPerTenant/maxActiveBatchesPerTenant as not-built plus a risks row. per-tenant index already exists, so it's really just the admission check on create and skipping a capped tenant in the claim loop.

maxFileBytes: 209715200 # 200 MB, OpenAI's limit
maxRequestsPerBatch: 50000 # OpenAI's limit
maxLineBytes: 1048576
maxResponseBytes: 2097152

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens when a response exceeds maxResponseBytes? batchWriter "collects the body up to a cap" — silent truncation would produce an invalid JSON line in the output file. Presumably the line should be recorded in the error file instead; worth specifying.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's not truncated -- the line is marked failed and written to the error file as response_too_large, counted in request_counts.failed. doc didn't say so though..
added

Copilot AI review requested due to automatic review settings September 28, 2026 11:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@aeron-gh

Copy link
Copy Markdown
Member Author

@YaoZengzeng PTAL

Copilot AI review requested due to automatic review settings September 29, 2026 04:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Signed-off-by: aeron-gh <agab0323@gmail.com>
Signed-off-by: aeron-gh <agab0323@gmail.com>
@aeron-gh

Copy link
Copy Markdown
Member Author

@YaoZengzeng PTAL


The shape of it:

```mermaid

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image

Field not fully displayed

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okk done, labels are single line now and the detail moved into a small table under the diagram.


##### Why an extension hook rather than gin routes

The router's gin engine owns a `/v1/*path` catch-all for model requests. Registering

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not entirely clear on the meaning of this part.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done, rewrote it

| One tenant starves the queue | Not yet addressed: the queue is FIFO with no per-tenant bound. `maxQueuedBatchesPerTenant` and `maxActiveBatchesPerTenant` are proposed in [Configuration](#configuration) |
| Abandoned files fill the volume | Files carry an expiry; a batch holds a reference on its input so a delete keeps the bytes until the batch is done; reap drops finished jobs |

### Design Details

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This Design Details is too abstract.

I would like to see the specific steps the Router takes to handle Batch Requests,

how the various components work together,

what new components are added,

and how they are integrated with existing components.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yepp, it was organised by concept and never said what actually gets built. added three sections at the top of Design Details: -->

  • what is added: file by file, taken off the poc branch rather than invented
  • how it plugs into the existing router: the only existing files touched. the go side is 29 added lines across two of them, no deletions anywhere
  • end to end: sequence diagram plus 8 numbered steps, each naming the component
    that does it

pushed as 06eefbc.

Signed-off-by: aeron-gh <agab0323@gmail.com>
Copilot AI lite review requested due to automatic review settings October 13, 2026 19:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved critical issues affect fencing, error compatibility, durability, duplicate IDs, and JWT identity restoration.

5 open findings

🧠 Review effort: Lite

Copilot AI lite review requested due to automatic review settings October 13, 2026 19:26
Signed-off-by: aeron-gh <agab0323@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Multiple critical and moderate design and operational issues remain unresolved.

7 open findings
4 resolved since last review
Previously missed (1)

In code that hasn't changed since last review

Medium severity Separate router pools do not isolate model backend capacity

docs/​proposal/​kthena-router-batch-api.md:162

Separate router pools do not isolate the model backends. Both deployments still route to the same ModelServers, and this proposal says adaptive back-off is not yet built; batch requests can therefore consume backend capacity and raise interactive latency. The worker switch isolates router-side CPU only, so this story cannot promise an unaffected interactive p99 without backend partitioning or priority/adaptive flow control.

🧠 Review effort: Lite

Copilot AI lite review requested due to automatic review settings October 13, 2026 19:31
@aeron-gh
aeron-gh force-pushed the docs/batch-api-proposal branch from 69cc19d to 9a3d192 Compare October 13, 2026 19:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Signed-off-by: aeron-gh <agab0323@gmail.com>
Copilot AI lite review requested due to automatic review settings October 13, 2026 19:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved API compatibility, storage safety, fencing, lifecycle, and capacity issues remain.

5 open findings
7 resolved since last review
Previously missed (2)

In code that hasn't changed since last review

Medium severity Add a configurable Redis installation prefix

docs/​proposal/​kthena-router-batch-api.md:409

The statement that all Redis keys are prefixed is misleading: the key table includes generic queue, active, and fence keys and unscoped type prefixes. Since Redis is optional today and can be shared by multiple router installations, an operator can cause batch state to collide with another deployment; specify a configurable installation prefix (while keeping the same prefix across the intentional API/worker pools).

Medium severity Require atomic input reference acquisition

docs/​proposal/​kthena-router-batch-api.md:565

This race is acknowledged but left as an optional follow-up even though it violates the stated invariant that a delete keeps input bytes alive until batch creation is complete. A delete can remove the bytes between validation and fileref insertion, so atomic reference acquisition (or the documented recheck-and-release protocol) needs to be a required part of batch creation before implementation proceeds.

🧠 Review effort: Lite

@aeron-gh

Copy link
Copy Markdown
Member Author

@YaoZengzeng
one more from going through this: batchIdentity restores UserIdKey and the gateway keys but not common.JWTTokenKey, so a ModelServer with sessionKeySource JWTClaim resolves to an empty session key on batch lines
(extract.go:77 -> ClaimFromContext). replaying the raw token would put a bearer credential in redis for the life of the batch and it can expire mid-run, so my instinct is to resolve the session key once at create time and replay just that string.
written up under Dispatch as a flagged decision. does that direction look right, or would you rather phase 1 declares JWTClaim affinity unsupported for batch?

Signed-off-by: aeron-gh <agab0323@gmail.com>
Copilot AI lite review requested due to automatic review settings November 6, 2026 17:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines +423 to +424
- has a `method` other than `POST`, or a `url` other than the batch's `endpoint`
- has no `body.model`, or a model different from the first line's (Decision 5)
Comment on lines +807 to +808
- `maxOutputBytes` bounds one batch. A batch that passes it stops dispatching and moves to
`failed` with `output_too_large`, with what it already committed still published.
Comment on lines +798 to +801
- **Orphans.** A slower pass walks the volume and removes files with no record that are
older than `retention.orphanGrace`, 48 hours by default, which is longer than any batch
can run. This catches temporary files from crashed uploads and the bytes left after
losing Redis.
Comment on lines +809 to +810
- `maxStorageBytesPerTenant` bounds one tenant, checked on upload and when claiming a
batch. An upload over the limit gets a 413.
Comment on lines +864 to +872
enabled: true # false: this Deployment serves the API only
maxConcurrentJobs: 2
concurrency: 8
leaseTTL: 30s
lineTimeout: 10m
maxLineAttempts: 5
maxAttempts: 5
workerPool:
enabled: false # a second Deployment that only runs batches
Comment on lines +925 to +929
3. **The volume is the shared one.** A writable volume is not enough: replicas on separate
volumes would accept uploads and then lose them on takeover. The first replica writes a
random `volume-id` file to the volume and sets the same value in Redis if none is
there. Every replica compares the file it sees with the value in Redis, and a mismatch
stays not ready. A ReadWriteOnce claim across nodes shows up separately, as a
@aeron-gh

aeron-gh commented Nov 6, 2026 •

Copy link
Copy Markdown
Member Author

@YaoZengzeng pushed an update, it covers your comments and the open bot findings now. ptal when you get a chance ta!!
copilot left 6 more on the last push, all small edge cases (endpoint check, failed batches, volume-id, tenant quota, worker config). i have fixes ready but holding them so the doc doesn't keep growing, it's already big. happy to push them if you want

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/documentation Improvements or additions to documentation size/XXL

Projects

Status: Accepted

Development

Successfully merging this pull request may close these issues.

4 participants