Files
spinifex/docs/development/bugs/capacity.md
T

254 lines
10 KiB
Markdown

# RunInstances capacity routing via per-instance-type NATS subscriptions
**Status:** Implemented (local tests pass, pending remote verification)
## 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
```
AWS SDK → Gateway → NATS "ec2.RunInstances" [queue: hive-workers] → ONE daemon (random)
↓
canAllocate() fails
↓
InsufficientInstanceCapacity
↓
Gateway returns error to client
```
There is no capacity-aware routing. NATS blindly round-robins to any daemon, regardless of whether it can serve the requested instance type.
## Solution: per-instance-type NATS topics with dynamic subscription
Leverage NATS subscription semantics directly: each daemon subscribes to `ec2.RunInstances.{instanceType}` only for types it has capacity to serve. When a node fills up for a given type, it unsubscribes. When capacity is freed, it re-subscribes.
This means NATS itself becomes the capacity-aware router — requests are only delivered to nodes that can serve them. No retry logic, no discovery, no hacks.
### Fixed NATS topic flow
```
Gateway → "ec2.RunInstances.t3.medium" [queue: hive-workers] → only daemons with capacity
→ Daemon receives (guaranteed to have capacity) → allocates → launches VM
→ Daemon recalculates: t3.medium no longer fits → unsubscribes from ec2.RunInstances.t3.medium
→ Other daemons still subscribed → next request routed to them
All nodes full for t3.medium:
Gateway → "ec2.RunInstances.t3.medium" → no subscribers → NATS returns ErrNoResponders
→ Gateway maps ErrNoResponders to InsufficientInstanceCapacity → returns to client
```
### How dynamic subscription works
Each daemon maintains a set of per-instance-type NATS subscriptions. After every resource change (allocate or deallocate), it recalculates which types can fit at least 1 instance:
```
Node starts (8 vCPUs, 32 GB free):
→ subscribes to: ec2.RunInstances.t3.nano, .micro, .small, .medium, .large, .xlarge, .2xlarge
ec2.RunInstances.m8a.nano, .micro, .small, .medium, .large, .xlarge, .2xlarge
After launching t3.2xlarge (8 vCPU, 32 GB → 0 free):
→ unsubscribes from ALL ec2.RunInstances.* types (nothing fits)
After terminating t3.2xlarge (resources freed → 8 vCPU, 32 GB free again):
→ re-subscribes to all 14 instance types
```
### Race condition handling
There's a small window between a subscription existing and resources being allocated where two requests for the last available slot could arrive at the same node. This is handled naturally:
1. Request A arrives → daemon allocates → success → recalculates subscriptions
2. Request B arrives before unsubscribe completes → daemon tries to allocate → `canAllocate()` returns 0
3. Daemon responds with `InsufficientInstanceCapacity` (same as today)
This is an edge case that only occurs when a node is at exact capacity boundary with concurrent requests. The existing `canAllocate()` check remains as a safety net. This is acceptable — the alternative (distributed locking) adds complexity far exceeding the rare failure.
## Implementation
### Step 1: Gateway publishes to instance-type-specific topic
**File:** `hive/handlers/ec2/instance/service_nats.go`
Change:
```go
func (s *NATSInstanceService) RunInstances(input *ec2.RunInstancesInput) (*ec2.Reservation, error) {
topic := fmt.Sprintf("ec2.RunInstances.%s", aws.StringValue(input.InstanceType))
return utils.NATSRequest[ec2.Reservation](s.natsConn, topic, input, 5*time.Minute)
}
```
### Step 2: Gateway maps ErrNoResponders to InsufficientInstanceCapacity
**File:** `hive/utils/nats.go`
Update `NATSRequest` to detect `nats.ErrNoResponders` and return a specific error the gateway can map to `InsufficientInstanceCapacity`:
```go
msg, err := conn.Request(subject, jsonData, timeout)
if err != nil {
if errors.Is(err, nats.ErrNoResponders) {
return nil, fmt.Errorf("no responders for subject %s: %w", subject, nats.ErrNoResponders)
}
return nil, fmt.Errorf("NATS request failed: %w", err)
}
```
**File:** `hive/gateway/ec2/instance/RunInstances.go`
Map the no-responders error to the AWS-compatible error:
```go
reservationPtr, err := service.RunInstances(input)
if err != nil {
if errors.Is(err, nats.ErrNoResponders) {
return reservation, errors.New(awserrors.ErrorInsufficientInstanceCapacity)
}
return reservation, err
}
```
### Step 3: Add subscription management to ResourceManager
**File:** `hive/daemon/daemon.go`
Add to `ResourceManager`:
```go
type ResourceManager struct {
// ... existing fields ...
natsConn *nats.Conn
node string
instanceSubs map[string]*nats.Subscription // topic → subscription
handler nats.MsgHandler // RunInstances handler
}
```
Add methods:
```go
// updateInstanceSubscriptions recalculates which instance types can fit
// and subscribes/unsubscribes accordingly. Called after every allocate/deallocate.
func (rm *ResourceManager) updateInstanceSubscriptions() {
rm.mu.RLock()
defer rm.mu.RUnlock()
for typeName, typeInfo := range rm.instanceTypes {
topic := fmt.Sprintf("ec2.RunInstances.%s", typeName)
canFit := rm.canAllocateUnlocked(typeInfo, 1) >= 1
_, subscribed := rm.instanceSubs[topic]
if canFit && !subscribed {
sub, err := rm.natsConn.QueueSubscribe(topic, "hive-workers", rm.handler)
if err != nil {
slog.Error("Failed to subscribe to instance type topic", "topic", topic, "err", err)
continue
}
rm.instanceSubs[topic] = sub
slog.Info("Subscribed to instance type", "topic", topic)
} else if !canFit && subscribed {
rm.instanceSubs[topic].Unsubscribe()
delete(rm.instanceSubs, topic)
slog.Info("Unsubscribed from instance type (capacity full)", "topic", topic)
}
}
}
```
### Step 4: Call updateInstanceSubscriptions after allocate/deallocate
**File:** `hive/daemon/daemon.go`
Update `allocate()` to recalculate subscriptions after reserving resources:
```go
func (rm *ResourceManager) allocate(instanceType *ec2.InstanceTypeInfo) error {
// ... existing allocation logic ...
rm.updateInstanceSubscriptions()
return nil
}
```
Update `deallocate()` to recalculate subscriptions after freeing resources:
```go
func (rm *ResourceManager) deallocate(instanceType *ec2.InstanceTypeInfo) {
// ... existing deallocation logic ...
rm.updateInstanceSubscriptions()
}
```
### Step 5: Remove static ec2.RunInstances subscription from subscribeAll()
**File:** `hive/daemon/daemon.go`
Remove `{"ec2.RunInstances", d.handleEC2RunInstances, "hive-workers"}` from the `subscribeAll()` table. The initial subscriptions are created by `updateInstanceSubscriptions()` called during daemon startup (after `NewResourceManager()` sets up the handler).
### Step 6: Initialize subscription management on daemon start
**File:** `hive/daemon/daemon.go`
After `subscribeAll()` in `Start()`, initialize the instance type subscriptions:
```go
d.resourceMgr.handler = d.handleEC2RunInstances
d.resourceMgr.natsConn = d.natsConn
d.resourceMgr.node = d.node
d.resourceMgr.instanceSubs = make(map[string]*nats.Subscription)
d.resourceMgr.updateInstanceSubscriptions()
```
### Step 7: Update tests
**File:** `hive/daemon/daemon_test.go`
- Update tests that publish to `ec2.RunInstances` to publish to `ec2.RunInstances.{instanceType}` instead
- Add test: verify that after filling a node, it unsubscribes from the filled type
- Add test: verify that after deallocating, it re-subscribes
- Add test: verify `ErrNoResponders` when no node has capacity
**File:** `hive/handlers/ec2/instance/service_nats_test.go` (if exists)
- Update topic assertions
## Files summary
| File | Change |
|------|--------|
| `hive/handlers/ec2/instance/service_nats.go` | Publish to `ec2.RunInstances.{instanceType}` |
| `hive/utils/nats.go` | Preserve `ErrNoResponders` in error chain |
| `hive/gateway/ec2/instance/RunInstances.go` | Map `ErrNoResponders` → `InsufficientInstanceCapacity` |
| `hive/daemon/daemon.go` | Add subscription management to ResourceManager, remove static subscription |
| `hive/daemon/daemon_test.go` | Update topics, add subscribe/unsubscribe tests |
## Existing behavior preserved
- `canAllocate()` still runs as a safety net (handles race conditions)
- Instance type validation (`instanceType, exists := d.resourceMgr.instanceTypes[...]`) still runs
- All other NATS subscriptions unchanged
- DescribeInstanceTypes still uses fan-out (no queue group) — unchanged
## Verification
1. `make test` — all tests pass
2. `make build` — compiles
3. 3-node cluster test:
- Start cluster, all nodes subscribe to all 14 instance types
- Fill node1 with t3.2xlarge — node1 unsubscribes from all types
- `RunInstances t3.medium` → NATS routes to node2 or node3 (never node1)
- Fill all nodes → `RunInstances` returns `InsufficientInstanceCapacity` (NATS ErrNoResponders)
- Terminate instance on node1 → node1 re-subscribes → next RunInstances succeeds