9.5 KiB
RunInstances capacity routing does not retry other nodes
Problem
When RunInstances is called, the gateway publishes to NATS topic ec2.RunInstances with queue group hive-workers. NATS round-robins the request to one daemon. If that daemon has insufficient capacity, it returns InsufficientInstanceCapacity and the gateway passes the error to the client. No other node is tried.
This means a cluster with available capacity can reject instance launches if the randomly-selected node happens to be full.
Observed behavior (3-node cluster)
Node 3 already runs a t3.medium (2 vCPUs, 4 GB) and has no remaining capacity:
# First attempt — NATS routes to node3 (full) → error
$ aws ec2 run-instances --image-id $HIVE_AMI --instance-type t3.medium ...
An error occurred (InsufficientInstanceCapacity) when calling the RunInstances operation: None
# Second attempt — NATS routes to node1 or node2 (free) → success
$ aws ec2 run-instances --image-id $HIVE_AMI --instance-type t3.medium ...
{ "Instances": [{ "InstanceId": "i-6c97c523f1ead0eef", "State": { "Name": "pending" } }] }
The user must retry manually until NATS happens to pick a node with capacity.
Why this happens
The request flow is fire-and-forget from the gateway's perspective:
AWS SDK → Gateway → NATS "ec2.RunInstances" [queue: hive-workers] → ONE daemon
↓
canAllocate() fails
↓
InsufficientInstanceCapacity
↓
Gateway returns error to client
There is no retry, no fallback, and no capacity-aware routing. The gateway (service_nats.go) does a single NATSRequest and returns whatever the daemon responds with.
Current code path
- Gateway
ec2.go:54→ callsRunInstances(input, gw.NATSConn) - Gateway
RunInstances.go:66→ createsNATSInstanceService, callsservice.RunInstances(input) - Service
service_nats.go:22→NATSRequest(conn, "ec2.RunInstances", input, 5*time.Minute)— single NATS request-reply - NATS routes to one daemon in queue group
hive-workers - Daemon
daemon_handlers.go:686→canAllocate(instanceType, maxCount)→ returns 0 - Daemon
daemon_handlers.go:691→ responds withInsufficientInstanceCapacity - Gateway receives error, returns HTTP 503 to client
No retry logic exists anywhere in this chain.
The per-instance-type unsubscribe idea (and why it doesn't work cleanly)
One approach: when a node is full for t3.medium, it unsubscribes from ec2.RunInstances so NATS won't route requests to it. But capacity is shared across instance types — a node with 2 free vCPUs and 2 GB free RAM can run a t3.small (2 vCPU, 2 GB) but not a t3.medium (2 vCPU, 4 GB). You can't unsubscribe from "RunInstances" globally without also blocking smaller instance types that would still fit.
Splitting into per-type topics (e.g., ec2.RunInstances.t3.medium) would fix this: unsubscribe from t3.medium when you can't fit one, while staying subscribed to t3.small. But this creates problems:
- With 14 instance types per node, each daemon manages 14 dynamic subscriptions
- Every
allocateanddeallocatemust recalculate which types still fit and update subscriptions - Race conditions: between the capacity check and NATS delivering the message, another request could consume the resources
- Gateway must know the instance type before publishing (it does — it's in the parsed request) but the topic routing becomes tightly coupled to the type catalog
- Adding new instance types requires matching subscription changes in daemon startup
This approach has merit for a future optimization (proactive capacity filtering) but doesn't solve the core problem: what happens when a node receives a request it can't serve?
Proposed approach: daemon-side NATS retry
Instead of the daemon returning an error when it lacks capacity, have it re-publish the request so another node can try. This keeps the logic in the daemon layer (no gateway changes) and uses NATS naturally.
How it works
- Daemon receives
RunInstancesvia queue group canAllocate()returns 0 — not enough capacity- Instead of responding with error, daemon publishes a
NAK(negative acknowledgment) back through NATS using a retry mechanism - NATS delivers to a different queue group member
- If all nodes are full, the last node returns
InsufficientInstanceCapacity
Implementation: NATS request-retry pattern
The daemon doesn't respond to the original message. Instead, it re-publishes to the same topic with a retry header. NATS queue group semantics mean a different member will receive it (NATS avoids delivering to the same consumer consecutively when others are available).
Gateway → "ec2.RunInstances" [queue: hive-workers]
→ Node3 receives (full) → doesn't respond, publishes retry with node3 in "tried" list
→ Node1 receives (has capacity) → processes request, responds to gateway
The retry message includes:
- Original request payload
- Reply-to address from the original NATS message (so the response goes back to the gateway)
- List of nodes that already tried and failed (to detect exhaustion)
When all nodes are in the "tried" list, the last node responds with InsufficientInstanceCapacity.
Key design details
Retry metadata: Attach a Hive-Tried-Nodes header to the NATS message. Each daemon that can't serve the request appends its node name before re-publishing. When len(triedNodes) >= expectedNodes, no capacity exists anywhere — return the error.
Reply routing: The re-published message must carry the original msg.Reply inbox address so the eventual response reaches the gateway. Use natsConn.PublishMsg() with the original reply subject.
Timeout safety: The gateway already sets a 5-minute timeout on the NATS request. If all nodes are slow or the retry loop takes too long, the gateway times out naturally. No additional timeout needed in the daemon.
No infinite loops: The tried-nodes list grows monotonically. Once it contains all nodes, the loop terminates. Maximum retries = number of nodes - 1.
Pseudocode
func (d *Daemon) handleEC2RunInstances(msg *nats.Msg) {
// ... parse input, validate ...
allocatableCount := d.resourceMgr.canAllocate(instanceType, maxCount)
if allocatableCount < minCount {
// Check if we can retry on another node
triedNodes := getTriedNodes(msg) // from Hive-Tried-Nodes header
triedNodes = append(triedNodes, d.node)
if len(triedNodes) >= d.expectedNodes {
// All nodes tried — no capacity anywhere
msg.Respond(InsufficientInstanceCapacity)
return
}
// Re-publish for another node to try
retryMsg := &nats.Msg{
Subject: "ec2.RunInstances",
Data: msg.Data,
Reply: msg.Reply, // preserve original reply-to
Header: nats.Header{},
}
retryMsg.Header.Set("Hive-Tried-Nodes", strings.Join(triedNodes, ","))
d.natsConn.PublishMsg(retryMsg)
return
}
// ... proceed with allocation and launch ...
}
Why this approach
| Consideration | Daemon-side retry | Gateway retry | Per-type topics |
|---|---|---|---|
| Gateway changes | None | Significant | Moderate |
| Daemon changes | Small (retry logic) | None | Large (dynamic subs) |
| Latency (happy path) | Zero overhead | Zero overhead | Zero overhead |
| Latency (retry) | ~1ms per hop | ~1ms per retry + gateway logic | N/A (preemptive) |
| Race conditions | Same as current | Same as current | Subscription timing |
| Exhaustion detection | Tried-nodes header | Gateway tracks failures | Implicit (no subscribers) |
| NATS compatibility | Standard pub/sub | Requires node-specific topics | Standard but many topics |
Daemon-side retry is the simplest change: one function in the daemon, no gateway changes, no new NATS topic patterns. It also composes well — if per-type topic filtering is added later as an optimization, the retry logic still works as a safety net.
Future optimization: proactive capacity filtering
Once the retry mechanism works, per-type subscriptions can be layered on top as a performance optimization to reduce unnecessary retries:
- After each
allocate/deallocate, recalculate which instance types can fit - Unsubscribe from types that no longer fit
- Re-subscribe when capacity is freed
This is purely an optimization — it reduces the number of retry hops but isn't required for correctness. The retry mechanism handles the edge cases that subscription timing can't (e.g., two requests arriving simultaneously when only one fits).
Files that would need changes
| File | Change |
|---|---|
hive/daemon/daemon_handlers.go |
Add retry logic to handleEC2RunInstances |
hive/daemon/daemon.go |
Add expectedNodes field to Daemon (from config) |
hive/daemon/daemon_test.go |
Test retry behavior: full node forwards, exhaustion returns error |
Verification
- 3-node cluster, fill one node to capacity
- Run 10
RunInstancesrequests — all should succeed (routed to free nodes), none should returnInsufficientInstanceCapacity - Fill all nodes — request should return
InsufficientInstanceCapacity(not hang) - Stop an instance, retry — should succeed on the node that freed capacity