Skip to content

Commit cb6400a

Browse files
committed
fix: validate serve limits before opening store
1 parent 3d99bd0 commit cb6400a

8 files changed

Lines changed: 307 additions & 96 deletions

File tree

cmd/rin/config_validation.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"strings"
99
"time"
1010

11+
"github.com/sunrioa/rin/generation"
12+
"github.com/sunrioa/rin/jobs"
1113
rinruntime "github.com/sunrioa/rin/runtime"
1214
)
1315

@@ -163,6 +165,8 @@ type serveConfiguration struct {
163165
scrubInterval time.Duration
164166
scrubTimeout time.Duration
165167
scrubMaxEvents int
168+
jobConfig jobs.Config
169+
generationConfig generation.Config
166170
}
167171

168172
func validateServeConfiguration(config serveConfiguration) error {
@@ -198,7 +202,22 @@ func validateServeConfiguration(config serveConfiguration) error {
198202
"scrub-max-events must be between 1 and %d",
199203
rinruntime.MaxScrubEventBudget,
200204
)
201-
default:
202-
return nil
203205
}
206+
if err := rinruntime.ValidateEngineOptions(rinruntime.EngineOptions{
207+
SessionSoftLimitBytes: config.sessionSoftLimitBytes,
208+
SessionHardLimitBytes: config.sessionHardLimitBytes,
209+
MaxSessionStateBytes: config.maxSessionStateBytes,
210+
MaxTransferBytes: config.maxTransferBytes,
211+
MaxTransferEvents: config.maxTransferEvents,
212+
MaxConcurrentTransfers: config.maxConcurrentTransfers,
213+
}); err != nil {
214+
return fmt.Errorf("invalid Runtime limits: %w", err)
215+
}
216+
if err := jobs.ValidateConfig(config.jobConfig); err != nil {
217+
return fmt.Errorf("invalid Proposal Job limits: %w", err)
218+
}
219+
if err := generation.ValidateConfig(config.generationConfig); err != nil {
220+
return fmt.Errorf("invalid Generation limits: %w", err)
221+
}
222+
return nil
204223
}

cmd/rin/main.go

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,32 @@ Serve options:
160160
if err := validateServeEnvironment(); err != nil {
161161
return err
162162
}
163+
jobConfig := jobs.Config{
164+
Workers: envInt("RIN_JOB_WORKERS", 2),
165+
QueueSize: envInt("RIN_JOB_QUEUE_SIZE", 64),
166+
MaxJobs: envInt("RIN_JOB_MAX_RETAINED", 512),
167+
JobTTL: envDuration("RIN_JOB_TTL", 30*time.Minute),
168+
}
169+
generationConfig := generation.Config{
170+
Workers: envInt("RIN_GENERATION_WORKERS", 2),
171+
QueueSize: envInt("RIN_GENERATION_QUEUE_SIZE", 64),
172+
MaxJobs: envInt("RIN_GENERATION_MAX_RETAINED", 512),
173+
JobTTL: envDuration("RIN_GENERATION_JOB_TTL", 30*time.Minute),
174+
CacheEntries: envInt("RIN_GENERATION_CACHE_ENTRIES", 256),
175+
CacheTTL: envDuration("RIN_GENERATION_CACHE_TTL", 30*time.Minute),
176+
MaxOutputBytes: envInt(
177+
"RIN_GENERATION_MAX_OUTPUT_BYTES",
178+
512*1024,
179+
),
180+
MaxRetainedBytes: envUint64(
181+
"RIN_GENERATION_MAX_RETAINED_BYTES",
182+
64<<20,
183+
),
184+
CleanupInterval: envDuration(
185+
"RIN_GENERATION_CLEANUP_INTERVAL",
186+
time.Minute,
187+
),
188+
}
163189
if err := validateServeConfiguration(serveConfiguration{
164190
maxBodyBytes: *maxBody,
165191
sessionSoftLimitBytes: *sessionSoftLimit,
@@ -174,6 +200,8 @@ Serve options:
174200
scrubInterval: *scrubInterval,
175201
scrubTimeout: *scrubTimeout,
176202
scrubMaxEvents: *scrubMaxEvents,
203+
jobConfig: jobConfig,
204+
generationConfig: generationConfig,
177205
}); err != nil {
178206
return err
179207
}
@@ -217,29 +245,16 @@ Serve options:
217245
defer cancel()
218246
resultErr = errors.Join(resultErr, engine.Close(closeContext))
219247
}()
220-
jobManager, err := jobs.New(engine, jobs.Config{
221-
Workers: envInt("RIN_JOB_WORKERS", 2), QueueSize: envInt("RIN_JOB_QUEUE_SIZE", 64),
222-
MaxJobs: envInt("RIN_JOB_MAX_RETAINED", 512), JobTTL: envDuration("RIN_JOB_TTL", 30*time.Minute),
223-
})
248+
jobManager, err := jobs.New(engine, jobConfig)
224249
if err != nil {
225250
return err
226251
}
227252
var generationManager *generation.Manager
228253
if modelRuntime.GenerationProvider != nil {
229-
generationManager, err = generation.New(modelRuntime.GenerationProvider, generation.Config{
230-
Workers: envInt("RIN_GENERATION_WORKERS", 2), QueueSize: envInt("RIN_GENERATION_QUEUE_SIZE", 64),
231-
MaxJobs: envInt("RIN_GENERATION_MAX_RETAINED", 512), JobTTL: envDuration("RIN_GENERATION_JOB_TTL", 30*time.Minute),
232-
CacheEntries: envInt("RIN_GENERATION_CACHE_ENTRIES", 256), CacheTTL: envDuration("RIN_GENERATION_CACHE_TTL", 30*time.Minute),
233-
MaxOutputBytes: envInt("RIN_GENERATION_MAX_OUTPUT_BYTES", 512*1024),
234-
MaxRetainedBytes: envUint64(
235-
"RIN_GENERATION_MAX_RETAINED_BYTES",
236-
64<<20,
237-
),
238-
CleanupInterval: envDuration(
239-
"RIN_GENERATION_CLEANUP_INTERVAL",
240-
time.Minute,
241-
),
242-
})
254+
generationManager, err = generation.New(
255+
modelRuntime.GenerationProvider,
256+
generationConfig,
257+
)
243258
if err != nil {
244259
closeContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
245260
defer cancel()

cmd/rin/main_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,44 @@ func TestValidateServeConfigurationRejectsExplicitFallbackValues(
348348
config.scrubMaxEvents = rinruntime.MaxScrubEventBudget + 1
349349
},
350350
},
351+
{
352+
name: "Session State upper bound",
353+
mutate: func(config *serveConfiguration) {
354+
config.maxSessionStateBytes =
355+
rinruntime.MaxConfigurableSessionStateBytes + 1
356+
},
357+
},
358+
{
359+
name: "Transfer byte upper bound",
360+
mutate: func(config *serveConfiguration) {
361+
config.maxTransferBytes = (1 << 40) + 1
362+
},
363+
},
364+
{
365+
name: "Transfer event JSON ceiling",
366+
mutate: func(config *serveConfiguration) {
367+
config.maxTransferEvents =
368+
uint64(protocol.MaxJSONSafeInteger) + 1
369+
},
370+
},
371+
{
372+
name: "Transfer concurrency upper bound",
373+
mutate: func(config *serveConfiguration) {
374+
config.maxConcurrentTransfers = 65
375+
},
376+
},
377+
{
378+
name: "Proposal Job workers",
379+
mutate: func(config *serveConfiguration) {
380+
config.jobConfig.Workers = 33
381+
},
382+
},
383+
{
384+
name: "Generation output",
385+
mutate: func(config *serveConfiguration) {
386+
config.generationConfig.MaxOutputBytes = 4*1024*1024 + 1
387+
},
388+
},
351389
}
352390
for _, test := range tests {
353391
t.Run(test.name, func(t *testing.T) {
@@ -363,6 +401,89 @@ func TestValidateServeConfigurationRejectsExplicitFallbackValues(
363401
}
364402
}
365403

404+
func TestInvalidServeLimitsDoNotTouchDataDirectory(t *testing.T) {
405+
tests := []struct {
406+
name string
407+
env string
408+
args []string
409+
}{
410+
{
411+
name: "Session State",
412+
args: []string{
413+
"-session-state-max-bytes",
414+
fmt.Sprint(rinruntime.MaxConfigurableSessionStateBytes + 1),
415+
},
416+
},
417+
{
418+
name: "Transfer bytes",
419+
args: []string{"-transfer-max-bytes", fmt.Sprint((1 << 40) + 1)},
420+
},
421+
{
422+
name: "Transfer concurrency",
423+
args: []string{"-transfer-max-concurrent", "65"},
424+
},
425+
{
426+
name: "Proposal Job workers",
427+
env: "RIN_JOB_WORKERS=33",
428+
},
429+
{
430+
name: "Generation output",
431+
env: "RIN_GENERATION_MAX_OUTPUT_BYTES=4194305",
432+
},
433+
}
434+
for _, test := range tests {
435+
t.Run(test.name, func(t *testing.T) {
436+
clearServeEnvironment(t)
437+
if test.env != "" {
438+
key, value, _ := strings.Cut(test.env, "=")
439+
t.Setenv(key, value)
440+
}
441+
parent := t.TempDir()
442+
missing := filepath.Join(parent, "rin-data")
443+
arguments := append(
444+
[]string{"serve", "-data", missing},
445+
test.args...,
446+
)
447+
if err := run(arguments); err == nil {
448+
t.Fatal("invalid limits started the Sidecar")
449+
}
450+
if _, err := os.Stat(missing); !errors.Is(err, os.ErrNotExist) {
451+
t.Fatalf("invalid limits touched the data directory: %v", err)
452+
}
453+
})
454+
}
455+
456+
clearServeEnvironment(t)
457+
existing := t.TempDir()
458+
sentinelPath := filepath.Join(existing, "operator-owned")
459+
sentinel := []byte("must remain byte-identical")
460+
if err := os.WriteFile(sentinelPath, sentinel, 0o600); err != nil {
461+
t.Fatal(err)
462+
}
463+
if err := run([]string{
464+
"serve",
465+
"-data", existing,
466+
"-transfer-max-events",
467+
fmt.Sprint(uint64(protocol.MaxJSONSafeInteger) + 1),
468+
}); err == nil {
469+
t.Fatal("invalid Transfer event limit started the Sidecar")
470+
}
471+
after, err := os.ReadFile(sentinelPath)
472+
if err != nil {
473+
t.Fatal(err)
474+
}
475+
if !bytes.Equal(after, sentinel) {
476+
t.Fatal("invalid configuration changed existing operator data")
477+
}
478+
entries, err := os.ReadDir(existing)
479+
if err != nil {
480+
t.Fatal(err)
481+
}
482+
if len(entries) != 1 || entries[0].Name() != "operator-owned" {
483+
t.Fatalf("invalid configuration changed existing directory: %v", entries)
484+
}
485+
}
486+
366487
func TestRunScrubLoopStartsImmediatelyAndStopsWithContext(t *testing.T) {
367488
scrubber := &blockingScrubber{calls: make(chan int, 1)}
368489
logger := slog.New(slog.NewTextHandler(io.Discard, nil))

docs/operations.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,10 @@ are all present.
105105

106106
Capacity, concurrency, timeout, and boolean environment variables fail fast
107107
when explicitly set to an invalid value; Rin does not silently replace a typo
108-
with a default. The same rule applies to explicit non-positive CLI limits.
108+
with a default. The same rule applies to explicit CLI limits. Runtime,
109+
Proposal Job, and Generation lower/upper bounds are all validated before Rin
110+
opens or performs recovery on the data directory, so a rejected configuration
111+
does not create or maintain Store files.
109112

110113
The bundled Sidecar starts a checkpoint-independent event-log scrub
111114
immediately, then every 15 minutes. Each pass verifies at most 4,096 events and

docs/operations.zh-CN.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,9 @@ rin serve -addr 10.0.0.12:7374 -allow-remote
9595
`-allow-remote`、Token 或该声明,会在打开数据目录前直接失败。
9696

9797
容量、并发、Timeout 与 Boolean 环境变量一旦显式设置为非法值,Rin 会立即失败,
98-
不会把拼写错误静默替换成默认值;命令行显式设置的非正数 Limit 也遵循同一规则。
98+
不会把拼写错误静默替换成默认值;命令行显式 Limit 也遵循同一规则。Runtime、
99+
Proposal Job 与 Generation 的上下限全部在打开数据目录或执行恢复维护前完成校验,
100+
因此被拒绝的配置不会创建或维护 Store 文件。
99101

100102
随附 Sidecar 会在启动后立即运行一次 checkpoint-independent Event Log Scrub,
101103
之后默认每 15 分钟运行一次。每个 Pass 最多校验 4,096 个事件,Deadline 为

generation/manager.go

Lines changed: 41 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -130,23 +130,54 @@ func New(
130130
if generationProvider == nil {
131131
return nil, errors.New("structured generation provider is required")
132132
}
133+
config, err := normalizeConfig(config)
134+
if err != nil {
135+
return nil, err
136+
}
137+
ctx, cancel := context.WithCancel(context.Background())
138+
manager := &Manager{
139+
provider: generationProvider, config: config, ctx: ctx, cancel: cancel,
140+
queue: make(chan string, config.QueueSize), jobs: make(map[string]*jobState),
141+
byRequest: make(map[string]string), cache: make(map[string]cacheEntry),
142+
now: time.Now, done: make(chan struct{}),
143+
}
144+
for index := 0; index < config.Workers; index++ {
145+
manager.wait.Add(1)
146+
go manager.worker()
147+
}
148+
manager.wait.Add(1)
149+
go manager.cleanupWorker()
150+
go func() {
151+
manager.wait.Wait()
152+
close(manager.done)
153+
}()
154+
return manager, nil
155+
}
156+
157+
// ValidateConfig applies the same limits as New without starting workers.
158+
func ValidateConfig(config Config) error {
159+
_, err := normalizeConfig(config)
160+
return err
161+
}
162+
163+
func normalizeConfig(config Config) (Config, error) {
133164
if config.Workers <= 0 {
134165
config.Workers = 2
135166
}
136167
if config.Workers > 32 {
137-
return nil, errors.New("generation workers must not exceed 32")
168+
return Config{}, errors.New("generation workers must not exceed 32")
138169
}
139170
if config.QueueSize <= 0 {
140171
config.QueueSize = 64
141172
}
142173
if config.QueueSize > 4096 {
143-
return nil, errors.New("generation queue size must not exceed 4096")
174+
return Config{}, errors.New("generation queue size must not exceed 4096")
144175
}
145176
if config.MaxJobs <= 0 {
146177
config.MaxJobs = 512
147178
}
148179
if config.MaxJobs < config.QueueSize || config.MaxJobs > 16384 {
149-
return nil, errors.New("generation max jobs must be between queue size and 16384")
180+
return Config{}, errors.New("generation max jobs must be between queue size and 16384")
150181
}
151182
if config.JobTTL <= 0 {
152183
config.JobTTL = 30 * time.Minute
@@ -155,7 +186,7 @@ func New(
155186
config.CacheEntries = 256
156187
}
157188
if config.CacheEntries > 16384 {
158-
return nil, errors.New("generation cache entries must not exceed 16384")
189+
return Config{}, errors.New("generation cache entries must not exceed 16384")
159190
}
160191
if config.CacheTTL <= 0 {
161192
config.CacheTTL = 30 * time.Minute
@@ -164,15 +195,17 @@ func New(
164195
config.MaxOutputBytes = 512 * 1024
165196
}
166197
if config.MaxOutputBytes < 1024 || config.MaxOutputBytes > 4*1024*1024 {
167-
return nil, errors.New("generation output limit must be between 1 KiB and 4 MiB")
198+
return Config{}, errors.New(
199+
"generation output limit must be between 1 KiB and 4 MiB",
200+
)
168201
}
169202
if config.MaxRetainedBytes == 0 {
170203
config.MaxRetainedBytes = 64 << 20
171204
}
172205
minimumRetained := uint64(config.MaxOutputBytes)*2 + (64 << 10)
173206
if config.MaxRetainedBytes < minimumRetained ||
174207
config.MaxRetainedBytes > 1<<30 {
175-
return nil, errors.New(
208+
return Config{}, errors.New(
176209
"generation retained-memory limit must fit two outputs plus 64 KiB and not exceed 1 GiB",
177210
)
178211
}
@@ -181,29 +214,11 @@ func New(
181214
}
182215
if config.CleanupInterval < 10*time.Millisecond ||
183216
config.CleanupInterval > time.Hour {
184-
return nil, errors.New(
217+
return Config{}, errors.New(
185218
"generation cleanup interval must be between 10 ms and 1 hour",
186219
)
187220
}
188-
189-
ctx, cancel := context.WithCancel(context.Background())
190-
manager := &Manager{
191-
provider: generationProvider, config: config, ctx: ctx, cancel: cancel,
192-
queue: make(chan string, config.QueueSize), jobs: make(map[string]*jobState),
193-
byRequest: make(map[string]string), cache: make(map[string]cacheEntry),
194-
now: time.Now, done: make(chan struct{}),
195-
}
196-
for index := 0; index < config.Workers; index++ {
197-
manager.wait.Add(1)
198-
go manager.worker()
199-
}
200-
manager.wait.Add(1)
201-
go manager.cleanupWorker()
202-
go func() {
203-
manager.wait.Wait()
204-
close(manager.done)
205-
}()
206-
return manager, nil
221+
return config, nil
207222
}
208223

209224
func (m *Manager) Submit(request protocol.GenerationRequest) (protocol.GenerationJobSubmission, error) {

0 commit comments

Comments
 (0)