Building a GPU SaaS Platform - Reliable Serverless Execution
/ 6 min read
In this chapter, we improve the serverless path we already built.
In the previous chapters, the queue-based flow was able to accept requests, dispatch them to workers, and store results. But that was still mostly a happy path. If any step fails, the current runtime can still lose useful failure context, and in some cases it can even lose the message itself.
That is dangerous for a GPU SaaS platform. When a user reports a failed invocation, we need more than a few scattered logs. We need a durable record that tells us where the request stopped, why it stopped, and whether the runtime should retry it.
So this chapter adds a small reliability layer around the existing serverless flow.
First, retry must be bounded. Similar to TCP, retries should not be an endless tight loop. If a subsystem is already unhealthy, a retry storm can make the failure worse.
Second, execution results need explicit state and failure reason. This makes debugging faster for operators and makes customer-facing support much easier.
Third, the runtime needs backpressure. When one part of the system is overloaded, the platform should protect the core GPU service instead of blindly creating more work. It is better to let some requests wait, retry, or fail than to bring down the whole cluster.
Queue Retry Policy
Every serverless consumer now receives a retry policy:
type RetryPolicy struct {
MaxDeliver int `json:"maxDeliver,omitempty" yaml:"maxDeliver,omitempty"`
Backoff []string `json:"backoff,omitempty" yaml:"backoff,omitempty"`
}
The default policy is:
maxDeliver: 5
backoff:
- "2s"
- "10s"
- "30s"
This is a NATS JetStream consumer policy. Since JetStream already provides these delivery controls, the runtime does not need to build a separate retry loop in the business layer. Most production message queues provide similar consumer-level settings. It controls how many times the queue delivers the same message and how long the runtime waits between failed delivery attempts.
The runtime applies the policy when creating a durable consumer:
jetstream.ConsumerConfig{
Durable: durable,
AckPolicy: jetstream.AckExplicitPolicy,
FilterSubject: filterSubject,
AckWait: opts.AckWait,
MaxDeliver: opts.Retry.MaxDeliver,
BackOff: opts.Retry.BackoffDurations(),
}
Dead-Letter Queue
Retry needs an end state.
If a message keeps failing forever, it should not silently loop in the queue and it should not disappear into logs. The runtime now publishes failed terminal messages into a dead-letter subject family:
runtime.serverless.dlq.<source>.<serverlessRequestID>
The source identifies which queue family failed:
invocation
dispatch
result
metric
For example:
runtime.serverless.dlq.invocation.sd-webui
runtime.serverless.dlq.dispatch.sd-webui
runtime.serverless.dlq.result.sd-webui
runtime.serverless.dlq.metric.sd-webui
A DLQ message stores the original payload and enough queue metadata to debug or replay it later:
type DeadLetterMessage struct {
Source DeadLetterSource
State InvocationState
FailureClass InvocationFailureClass
InvocationID string
ServerlessRequestID string
WorkerName string
OriginalSubject string
Stream string
Consumer string
StreamSequence uint64
ConsumerSequence uint64
DeliveryCount uint64
Error string
Payload json.RawMessage
FailedAt time.Time
}
Malformed messages go to DLQ immediately because the runtime cannot safely decode them. Normal handler failures go to DLQ only after maxDeliver is exhausted.
The DLQ is not a complete replay product yet. It is the durable operational boundary: failed messages become inspectable data instead of invisible consumer failures.
Execution State And Failure Classification
Before this chapter, a result mostly looked like an HTTP response. That is not enough for a serverless runtime.
The runtime now records execution state explicitly:
type InvocationState string
const (
InvocationStateQueued InvocationState = "queued"
InvocationStateDispatching InvocationState = "dispatching"
InvocationStateRunning InvocationState = "running"
InvocationStateSucceeded InvocationState = "succeeded"
InvocationStateFailed InvocationState = "failed"
InvocationStateExpired InvocationState = "expired"
InvocationStateDeadLettered InvocationState = "dead_lettered"
)
These states describe where the invocation is in the runtime:
queued -> runtime API accepted the request and published it to JetStream
dispatching -> activator selected a worker and published a dispatch message
running -> worker sidecar sent the request to the local framework
succeeded -> framework completed successfully
failed -> runtime or framework returned a non-timeout failure
expired -> framework execution timed out
dead_lettered -> queue retry was exhausted or payload was malformed
The runtime also records a failure class:
type InvocationFailureClass string
const (
InvocationFailureMalformedMessage InvocationFailureClass = "malformed_message"
InvocationFailureNoWorkerTemplate InvocationFailureClass = "no_worker_template"
InvocationFailureActivationTimeout InvocationFailureClass = "activation_timeout"
InvocationFailureBackpressure InvocationFailureClass = "backpressure"
InvocationFailureDispatchError InvocationFailureClass = "dispatch_error"
InvocationFailureFrameworkError InvocationFailureClass = "framework_error"
InvocationFailureFrameworkTimeout InvocationFailureClass = "framework_timeout"
InvocationFailureResultStoreError InvocationFailureClass = "result_store_error"
InvocationFailureRetryExhausted InvocationFailureClass = "retry_exhausted"
)
The important point is that HTTP status and runtime failure class are different fields.
An HTTP 504 tells the caller that the request expired. framework_timeout tells the platform that the worker framework did not finish before the configured timeout. That difference matters for alerting, debugging, billing, and customer support.
Result storage now persists both:
state
failure_class
This gives the control plane a durable view of what actually happened, instead of forcing it to infer runtime behavior from status code and log text.
Activator Backpressure
The activator should not convert queue pressure directly into unlimited Pod creation.
The config now includes:
maxPendingWorkersPerRequestID: 1
When a request already has too many pending activator-managed workers, the activator returns a retryable error:
if pending := pendingManagedWorkers(units, requestID); pending >= s.cfg.MaxPendingWorkersPerRequestID {
return Worker{}, &backpressureError{
requestID: requestID,
pending: pending,
limit: s.cfg.MaxPendingWorkersPerRequestID,
}
}
ProcessInvocation does not publish a terminal failure for this case:
worker, err := s.acquireWorker(ctx, invocation.ServerlessRequestID, invocation.InvocationID)
if err != nil {
if isRetryableInvocationError(err) {
return err
}
// non-retryable failure result
}
Returning the error is intentional. The shared NATS consumer sees the error, does not ack the invocation, and schedules redelivery using the configured backoff.
This is not a full capacity scheduler. It is a basic safety rule: if worker creation is already pending for a serverless request, slow down and let the queue absorb the burst.
Worker Framework Timeout
The worker sidecar calls the local framework over UDS.
If the framework returns a normal error, the result is:
state: failed
failureClass: framework_error
If the framework call exceeds the configured timeout, the result is:
state: expired
failureClass: framework_timeout
This distinction is useful because timeout handling is operationally different from an application error. A timeout may indicate overloaded GPU workers, cold model loading, bad package sizing, or a framework bug that never returns.
Result Store Persistence
The result-store consumer also uses the same queue retry policy.
If ScyllaDB is temporarily unavailable, the consumer returns an error. JetStream redelivers the result message using backoff. If all attempts fail, the result message moves to DLQ.
The result table is keyed by invocation_id, so duplicate result delivery remains safe for this tutorial implementation.
What Changed In Code
The code changes follow the same four boundaries:
pkg/serverlessdefines retry policy, consumer options, DLQ message contracts, invocation states, and failure classes.pkg/serverless/nats.gocreates JetStream consumers withMaxDeliverandBackOff, performsNakWithDelay, and publishes DLQ messages after retry exhaustion.pkg/activatorreturns retryable errors for backpressure and infrastructure failures instead of turning every failure into a fake user result.pkg/workersidecarmaps framework timeout toexpired/framework_timeout.pkg/resultstorepersistsstateandfailure_classin ScyllaDB.- local YAML examples expose retry and backpressure settings for activator, worker sidecar, and result-store.
Summary
Part 21 does not try to solve every reliability problem.
It adds the first reliable execution layer:
bounded queue retry
+ dead-letter visibility
+ explicit execution state
+ failure classification
+ safer activator behavior
+ durable result state
The runtime still follows an at-least-once execution model. User handlers must treat invocationID as an idempotency key if duplicate execution would be harmful.
That is the right contract for this stage of the platform: honest, observable, and bounded.
Repository
Code for this chapter:
Comments
Join the discussion with your GitHub account. Powered by giscus .