Skip to content

Commit a8ad215

Browse files
bartek szabatclaude
andcommitted
Add byte-seconds limit types for cumulative resource usage over time
Add 4 new limit types (ram-usage-bsec, disk-usage-bsec, ram-request-bsec, disk-request-bsec) that track resource usage/reservation × time as byte-seconds. Each enforcement tick accumulates the current source value, and containers are killed via StopContainer when the limit is exceeded. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 45e06e5 commit a8ad215

8 files changed

Lines changed: 181 additions & 17 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ Dynamic resource limit management for Docker containers. Set, monitor, and enfor
1616
| **Disk I/O bytes** | Cumulative bytes | cgroup `io.max` throttle |
1717
| **Disk I/O ops** | Cumulative operations | cgroup `io.max` throttle |
1818
| **Spending** | USD cents | HTTP proxy budget block |
19+
| **RAM usage B·s** | Byte-seconds (actual RAM × time) | Container kill |
20+
| **Disk usage B·s** | Byte-seconds (actual disk × time) | Container kill |
21+
| **RAM request B·s** | Byte-seconds (ddl RAM limit × time) | Container kill |
22+
| **Disk request B·s** | Byte-seconds (ddl disk limit × time) | Container kill |
1923

2024
- **Per-container limits** — set, increase, or decrease any limit at any time
2125
- **Automatic enforcement** — daemon polls every second and applies/releases enforcement actions
@@ -107,6 +111,11 @@ ddl limits set <container> net 1g # 1 GiB network transfer
107111
ddl limits set <container> disk-io-bytes 5g
108112
ddl limits set <container> disk-io-ops 1000000
109113
ddl limits set <container> spending 10.00 # $10.00 USD
114+
115+
ddl limits set <container> ram-usage-bsec 100g # 100 GB·s of RAM usage over time
116+
ddl limits set <container> disk-usage-bsec 500g # 500 GB·s of disk usage over time
117+
ddl limits set <container> ram-request-bsec 1t # 1 TB·s of RAM reservation over time
118+
ddl limits set <container> disk-request-bsec 1t # 1 TB·s of disk reservation over time
110119
```
111120

112121
### Adjust limits
@@ -217,6 +226,7 @@ Token usage is extracted from responses and costs are calculated using built-in
217226
|---|---|
218227
| CPU time | `3600s`, `60m`, `1h` |
219228
| Bytes (RAM, disk, network, I/O) | `1024`, `512k`, `256m`, `1g`, `1.5t` |
229+
| Byte-seconds (usage/request B·s) | `100g`, `1.5t` (same byte suffixes, displayed as e.g. `1.5G·s`) |
220230
| I/O operations | Plain integer |
221231
| Spending | `10.00` (USD, stored as cents) |
222232

cmd/ddl/dashboard/app.js

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
const offlineBanner = document.getElementById('offline-banner');
2020

2121
// --- Value formatting (mirrors Go format.go) ---
22-
const LIMIT_TYPES = ['cpu', 'ram', 'net', 'disk', 'disk-io-bytes', 'disk-io-ops', 'spending'];
22+
const LIMIT_TYPES = ['cpu', 'ram', 'net', 'disk', 'disk-io-bytes', 'disk-io-ops', 'spending',
23+
'ram-usage-bsec', 'disk-usage-bsec', 'ram-request-bsec', 'disk-request-bsec'];
2324

2425
const LIMIT_LABELS = {
2526
'cpu': 'CPU',
@@ -28,9 +29,25 @@
2829
'net': 'Network',
2930
'disk-io-bytes': 'Disk I/O Bytes',
3031
'disk-io-ops': 'Disk I/O Ops',
31-
'spending': 'Spending'
32+
'spending': 'Spending',
33+
'ram-usage-bsec': 'RAM Usage B·s',
34+
'disk-usage-bsec': 'Disk Usage B·s',
35+
'ram-request-bsec': 'RAM Request B·s',
36+
'disk-request-bsec': 'Disk Request B·s'
3237
};
3338

39+
function formatByteSeconds(b) {
40+
var TB = 1024 * 1024 * 1024 * 1024;
41+
var GB = 1024 * 1024 * 1024;
42+
var MB = 1024 * 1024;
43+
var KB = 1024;
44+
if (b >= TB) return (b / TB).toFixed(1) + 'T\u00b7s';
45+
if (b >= GB) return (b / GB).toFixed(1) + 'G\u00b7s';
46+
if (b >= MB) return (b / MB).toFixed(1) + 'M\u00b7s';
47+
if (b >= KB) return (b / KB).toFixed(1) + 'K\u00b7s';
48+
return b + 'B\u00b7s';
49+
}
50+
3451
function formatValue(type, v) {
3552
if (v === 0) return '-';
3653
switch (type) {
@@ -40,6 +57,11 @@
4057
case 'net':
4158
case 'disk-io-bytes':
4259
return formatBytes(v);
60+
case 'ram-usage-bsec':
61+
case 'disk-usage-bsec':
62+
case 'ram-request-bsec':
63+
case 'disk-request-bsec':
64+
return formatByteSeconds(v);
4365
case 'spending':
4466
return '$' + (v / 100).toFixed(2);
4567
case 'disk-io-ops':
@@ -83,6 +105,11 @@
83105
case 'net':
84106
case 'disk-io-bytes':
85107
return parseBytes(s);
108+
case 'ram-usage-bsec':
109+
case 'disk-usage-bsec':
110+
case 'ram-request-bsec':
111+
case 'disk-request-bsec':
112+
return parseBytes(s);
86113
case 'disk-io-ops':
87114
return parseInt(s, 10);
88115
case 'spending':

cmd/ddl/format.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ func parseValue(limitType, s string) (int64, error) {
2020
case "ram", "disk", "net", "disk-io-bytes":
2121
// Accept "512m", "1g", "100k" for bytes
2222
return parseBytes(s)
23+
case "ram-usage-bsec", "disk-usage-bsec", "ram-request-bsec", "disk-request-bsec":
24+
// Same byte suffixes, unit is byte-seconds
25+
return parseBytes(s)
2326
case "disk-io-ops":
2427
// Plain integer
2528
return strconv.ParseInt(s, 10, 64)
@@ -110,6 +113,8 @@ func formatValue(limitType string, v int64) string {
110113
return fmt.Sprintf("%ds", v)
111114
case "ram", "disk", "net", "disk-io-bytes":
112115
return formatBytesHuman(v)
116+
case "ram-usage-bsec", "disk-usage-bsec", "ram-request-bsec", "disk-request-bsec":
117+
return formatByteSeconds(v)
113118
case "spending":
114119
return fmt.Sprintf("$%.2f", float64(v)/100)
115120
default:
@@ -138,6 +143,27 @@ func formatBytesHuman(b int64) string {
138143
}
139144
}
140145

146+
func formatByteSeconds(b int64) string {
147+
const (
148+
kb = 1024
149+
mb = kb * 1024
150+
gb = mb * 1024
151+
tb = gb * 1024
152+
)
153+
switch {
154+
case b >= tb:
155+
return fmt.Sprintf("%.1fT·s", float64(b)/float64(tb))
156+
case b >= gb:
157+
return fmt.Sprintf("%.1fG·s", float64(b)/float64(gb))
158+
case b >= mb:
159+
return fmt.Sprintf("%.1fM·s", float64(b)/float64(mb))
160+
case b >= kb:
161+
return fmt.Sprintf("%.1fK·s", float64(b)/float64(kb))
162+
default:
163+
return fmt.Sprintf("%dB·s", b)
164+
}
165+
}
166+
141167
func getJSONFloat(m map[string]interface{}, key string) float64 {
142168
if m == nil {
143169
return 0
@@ -157,7 +183,8 @@ func getJSONFloat(m map[string]interface{}, key string) float64 {
157183
func printLimitsOrUsage(m map[string]interface{}, label string) {
158184
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
159185
fmt.Fprintf(w, "TYPE\t%s\n", strings.ToUpper(label))
160-
types := []string{"cpu", "ram", "net", "disk", "disk-io-bytes", "disk-io-ops", "spending"}
186+
types := []string{"cpu", "ram", "net", "disk", "disk-io-bytes", "disk-io-ops", "spending",
187+
"ram-usage-bsec", "disk-usage-bsec", "ram-request-bsec", "disk-request-bsec"}
161188
for _, t := range types {
162189
v := int64(getJSONFloat(m, t))
163190
fmt.Fprintf(w, "%s\t%s\n", t, formatValue(t, v))

internal/docker/docker.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"io"
77
"strings"
8+
"time"
89

910
"github.com/docker/docker/api/types"
1011
"github.com/docker/docker/api/types/container"
@@ -25,6 +26,7 @@ type DockerClient interface {
2526
DisconnectNetwork(ctx context.Context, id string) error
2627
ReconnectNetwork(ctx context.Context, id string) error
2728
ContainerIP(ctx context.Context, id string) (string, error)
29+
StopContainer(ctx context.Context, id string) error
2830
}
2931

3032
// Client wraps the Docker Engine API.
@@ -229,3 +231,9 @@ func (c *Client) ContainerIP(ctx context.Context, id string) (string, error) {
229231
}
230232
return "", fmt.Errorf("no IP address for container %s", id)
231233
}
234+
235+
// StopContainer stops a running container (SIGTERM then SIGKILL after timeout).
236+
func (c *Client) StopContainer(ctx context.Context, id string) error {
237+
timeout := 10 * time.Second
238+
return c.cli.ContainerStop(ctx, id, &timeout)
239+
}

internal/enforcement/enforcement.go

Lines changed: 71 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -146,14 +146,28 @@ func (m *Manager) checkAndEnforce(ctx context.Context, containerID, dockerID str
146146
continue
147147
}
148148

149-
usage, err := m.getCurrentUsage(ctx, containerID, dockerID, lt, cgroupPath, cgroupErr)
150-
if err != nil {
151-
continue
149+
var usage int64
150+
if isByteSecondType(lt) {
151+
// Don't accumulate if already enforced (container killed)
152+
m.mu.Lock()
153+
wasEnforced := m.enforced[containerID][lt]
154+
m.mu.Unlock()
155+
156+
if !wasEnforced {
157+
source := m.getByteSecondSource(ctx, containerID, dockerID, lt, limits, cgroupPath, cgroupErr)
158+
m.store.AddUsage(containerID, lt, source)
159+
}
160+
usage, _ = m.store.GetUsage(containerID, lt)
161+
} else {
162+
var err error
163+
usage, err = m.getCurrentUsage(ctx, containerID, dockerID, lt, cgroupPath, cgroupErr)
164+
if err != nil {
165+
continue
166+
}
167+
// Persist usage to store
168+
m.store.SetUsage(containerID, lt, usage)
152169
}
153170

154-
// Persist usage to store
155-
m.store.SetUsage(containerID, lt, usage)
156-
157171
exceeded := usage >= limit
158172
m.mu.Lock()
159173
wasEnforced := m.enforced[containerID][lt]
@@ -234,6 +248,44 @@ func (m *Manager) getCurrentUsage(ctx context.Context, containerID, dockerID str
234248
}
235249
}
236250

251+
// isByteSecondType returns true for cumulative byte-second limit types.
252+
func isByteSecondType(lt model.LimitType) bool {
253+
switch lt {
254+
case model.LimitRAMUsageBSec, model.LimitDiskUsageBSec,
255+
model.LimitRAMRequestBSec, model.LimitDiskRequestBSec:
256+
return true
257+
}
258+
return false
259+
}
260+
261+
// getByteSecondSource returns the current source value to accumulate for a byte-second type.
262+
func (m *Manager) getByteSecondSource(ctx context.Context, containerID, dockerID string, lt model.LimitType, limits map[model.LimitType]int64, cgroupPath string, cgroupErr error) int64 {
263+
switch lt {
264+
case model.LimitRAMUsageBSec:
265+
if cgroupErr != nil {
266+
return 0
267+
}
268+
v, err := m.cgroup.MemoryCurrent(cgroupPath)
269+
if err != nil {
270+
return 0
271+
}
272+
return v
273+
case model.LimitDiskUsageBSec:
274+
v, err := m.docker.GetContainerDiskUsage(ctx, dockerID)
275+
if err != nil {
276+
return 0
277+
}
278+
return v
279+
case model.LimitRAMRequestBSec:
280+
v, _ := m.store.GetLimit(containerID, model.LimitRAM)
281+
return v
282+
case model.LimitDiskRequestBSec:
283+
v, _ := m.store.GetLimit(containerID, model.LimitDisk)
284+
return v
285+
}
286+
return 0
287+
}
288+
237289
func (m *Manager) enforce(ctx context.Context, containerID, dockerID string, lt model.LimitType, cgroupPath string) {
238290
var err error
239291
switch lt {
@@ -279,6 +331,13 @@ func (m *Manager) enforce(ctx context.Context, containerID, dockerID string, lt
279331
case model.LimitSpending:
280332
// Spending enforcement is handled by the proxy itself
281333
log.Printf("[enforcement] spending limit exceeded for %s", containerID)
334+
335+
case model.LimitRAMUsageBSec, model.LimitDiskUsageBSec,
336+
model.LimitRAMRequestBSec, model.LimitDiskRequestBSec:
337+
err = m.docker.StopContainer(ctx, dockerID)
338+
if err == nil {
339+
log.Printf("[enforcement] killed container %s: %s limit exceeded", containerID, lt)
340+
}
282341
}
283342

284343
if err != nil {
@@ -341,6 +400,12 @@ func (m *Manager) release(ctx context.Context, containerID, dockerID string, lt
341400

342401
case model.LimitSpending:
343402
log.Printf("[enforcement] spending limit released for %s", containerID)
403+
404+
case model.LimitRAMUsageBSec, model.LimitDiskUsageBSec,
405+
model.LimitRAMRequestBSec, model.LimitDiskRequestBSec:
406+
// Container was killed; if user increased the limit and restarted
407+
// the container, just clear the enforced flag (no Docker action needed).
408+
log.Printf("[enforcement] %s limit released for %s (container may be restarted)", lt, containerID)
344409
}
345410

346411
if err != nil {

internal/model/model.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,19 @@ const (
1212
LimitNetwork LimitType = "net" // cumulative network bytes
1313
LimitDiskIOByte LimitType = "disk-io-bytes" // cumulative disk IO bytes
1414
LimitDiskIOOps LimitType = "disk-io-ops" // cumulative disk IO operations
15-
LimitSpending LimitType = "spending" // spending budget in cents (USD)
15+
LimitSpending LimitType = "spending" // spending budget in cents (USD)
16+
LimitRAMUsageBSec LimitType = "ram-usage-bsec" // cumulative RAM usage × time (byte-seconds)
17+
LimitDiskUsageBSec LimitType = "disk-usage-bsec" // cumulative disk usage × time (byte-seconds)
18+
LimitRAMRequestBSec LimitType = "ram-request-bsec" // cumulative RAM request × time (byte-seconds)
19+
LimitDiskRequestBSec LimitType = "disk-request-bsec" // cumulative disk request × time (byte-seconds)
1620
)
1721

1822
// AllLimitTypes lists every supported limit type.
1923
var AllLimitTypes = []LimitType{
2024
LimitCPU, LimitRAM, LimitDisk, LimitNetwork,
2125
LimitDiskIOByte, LimitDiskIOOps, LimitSpending,
26+
LimitRAMUsageBSec, LimitDiskUsageBSec,
27+
LimitRAMRequestBSec, LimitDiskRequestBSec,
2228
}
2329

2430
// Container represents a managed container.

internal/model/model_test.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@ import "testing"
44

55
func TestAllLimitTypesCompleteness(t *testing.T) {
66
known := map[LimitType]bool{
7-
LimitCPU: true,
8-
LimitRAM: true,
9-
LimitDisk: true,
10-
LimitNetwork: true,
11-
LimitDiskIOByte: true,
12-
LimitDiskIOOps: true,
13-
LimitSpending: true,
7+
LimitCPU: true,
8+
LimitRAM: true,
9+
LimitDisk: true,
10+
LimitNetwork: true,
11+
LimitDiskIOByte: true,
12+
LimitDiskIOOps: true,
13+
LimitSpending: true,
14+
LimitRAMUsageBSec: true,
15+
LimitDiskUsageBSec: true,
16+
LimitRAMRequestBSec: true,
17+
LimitDiskRequestBSec: true,
1418
}
1519

1620
if len(AllLimitTypes) != len(known) {
@@ -36,6 +40,10 @@ func TestLimitTypeStringValues(t *testing.T) {
3640
{LimitDiskIOByte, "disk-io-bytes"},
3741
{LimitDiskIOOps, "disk-io-ops"},
3842
{LimitSpending, "spending"},
43+
{LimitRAMUsageBSec, "ram-usage-bsec"},
44+
{LimitDiskUsageBSec, "disk-usage-bsec"},
45+
{LimitRAMRequestBSec, "ram-request-bsec"},
46+
{LimitDiskRequestBSec, "disk-request-bsec"},
3947
}
4048

4149
for _, tc := range tests {

internal/testutil/mocks.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ type MockDocker struct {
181181
Containers map[string]*MockContainerState
182182
ClonedFrom []string // track clone calls
183183
CloneIDCounter int
184+
StoppedContainers []string // track StopContainer calls
184185
}
185186

186187
type MockContainerState struct {
@@ -353,6 +354,18 @@ func (d *MockDocker) ContainerIP(ctx context.Context, id string) (string, error)
353354
return state.IP, nil
354355
}
355356

357+
func (d *MockDocker) StopContainer(ctx context.Context, id string) error {
358+
d.mu.Lock()
359+
defer d.mu.Unlock()
360+
state, ok := d.Containers[id]
361+
if !ok {
362+
return fmt.Errorf("container %s not found", id)
363+
}
364+
state.Running = false
365+
d.StoppedContainers = append(d.StoppedContainers, id)
366+
return nil
367+
}
368+
356369
func (d *MockDocker) SetContainerIP(id, ip string) {
357370
d.mu.Lock()
358371
defer d.mu.Unlock()

0 commit comments

Comments
 (0)