Repository navigation
perf: stage timings, shared event subscription and honest Retry-After #134
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
23a3238
a2302b9
7169878
9876a3f
985f455
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,7 @@ import ( | |
|
|
||
| "github.com/stjosephsplacements/AetherCode-Execution-Engine/internal/db" | ||
| "github.com/stjosephsplacements/AetherCode-Execution-Engine/internal/eventlog" | ||
| "github.com/stjosephsplacements/AetherCode-Execution-Engine/internal/metrics" | ||
| "github.com/stjosephsplacements/AetherCode-Execution-Engine/internal/model" | ||
| "github.com/stjosephsplacements/AetherCode-Execution-Engine/internal/queue" | ||
| "github.com/stjosephsplacements/AetherCode-Execution-Engine/internal/sandbox" | ||
|
|
@@ -716,3 +717,31 @@ func TestLanguageAvailability(t *testing.T) { | |
| } | ||
| } | ||
| } | ||
|
|
||
| func TestBusyRetryAfterFollowsTheDrainRate(t *testing.T) { | ||
| _, rdb := newTestRedis(t) | ||
| h := NewHandler(nil, queue.NewStream(rdb, "ac:submissions:submit", "workers"), queue.NewStream(rdb, "ac:submissions:run", "workers"), nil, nil, rdb, nil, nil, 10, 8, nil) | ||
| ctx := context.Background() | ||
| for i := 0; i < 70; i++ { | ||
| rdb.XAdd(ctx, &goredis.XAddArgs{Stream: "ac:submissions:run", Values: map[string]any{"submission_id": fmt.Sprint(i)}}) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fail the test when a queue entry cannot be inserted. If every
|
||
| } | ||
|
|
||
| original := metrics.JobsDrain | ||
| t.Cleanup(func() { metrics.JobsDrain = original }) | ||
|
|
||
| now := time.Unix(5000, 0) | ||
| metrics.JobsDrain = metrics.NewDrain(func() time.Time { return now }) | ||
| if got := h.busyRetryAfter(ctx, "ac:submissions:run"); got != 10 { | ||
| t.Fatalf("Retry-After with no measured rate = %d, want the old fixed 10", got) | ||
| } | ||
|
|
||
| for i := 0; i < 201; i++ { | ||
| metrics.JobsDrain.Complete() | ||
| } | ||
| now = now.Add(time.Second) // the rate settles at about 60 jobs/s (alpha 0.3) | ||
| got := h.busyRetryAfter(ctx, "ac:submissions:run") | ||
| // 70 queued, limit 10: it must fall by 61 jobs, about a second at 60/s. | ||
| if got < 1 || got > 3 { | ||
| t.Fatalf("Retry-After = %d, want 1 to 3 seconds for a 61-job excess at about 60 jobs/s", got) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| package metrics | ||
|
|
||
| import ( | ||
| "math" | ||
| "sync" | ||
| "time" | ||
| ) | ||
|
|
||
| // Drain measures how fast jobs finish, as a smoothed rate per second. The | ||
| // gateway turns it into an honest Retry-After when it sheds load. | ||
| type Drain struct { | ||
| mu sync.Mutex | ||
| rate float64 // smoothed completions per second | ||
| window time.Time | ||
| count int | ||
| now func() time.Time | ||
| started bool | ||
| } | ||
|
|
||
| const ( | ||
| drainWindow = time.Second | ||
| // drainAlpha weights the newest second against the history. | ||
| drainAlpha = 0.3 | ||
| ) | ||
|
|
||
| // NewDrain returns a tracker that reads time from now. | ||
| func NewDrain(now func() time.Time) *Drain { | ||
| return &Drain{now: now} | ||
| } | ||
|
|
||
| // Complete records one finished job. | ||
| func (d *Drain) Complete() { | ||
| d.mu.Lock() | ||
| defer d.mu.Unlock() | ||
| d.advance() | ||
| d.count++ | ||
| } | ||
|
|
||
| // Rate is the smoothed completions per second; zero until a full second passed. | ||
| func (d *Drain) Rate() float64 { | ||
| d.mu.Lock() | ||
| defer d.mu.Unlock() | ||
| d.advance() | ||
| return d.rate | ||
| } | ||
|
|
||
| // RetryAfter is how many seconds a caller should wait for depth queued jobs to | ||
| // drain, between lo and hi. With no measured rate it returns lo. | ||
| func (d *Drain) RetryAfter(depth int64, lo, hi int) int { | ||
| rate := d.Rate() | ||
| if rate <= 0 || depth <= 0 { | ||
| return lo | ||
| } | ||
| seconds := int(math.Ceil(float64(depth) / rate)) | ||
| return max(lo, min(hi, seconds)) | ||
|
Comment on lines
+54
to
+55
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Apply the upper bound before converting to After roughly two minutes without completions, a previously measured rate can become so small that
|
||
| } | ||
|
|
||
| // advance folds every full elapsed window into the smoothed rate. Idle windows | ||
| // decay it toward zero. | ||
| func (d *Drain) advance() { | ||
| now := d.now() | ||
| if !d.started { | ||
| d.started, d.window = true, now | ||
| return | ||
| } | ||
| for now.Sub(d.window) >= drainWindow { | ||
| d.rate = drainAlpha*float64(d.count) + (1-drainAlpha)*d.rate | ||
| d.count = 0 | ||
| d.window = d.window.Add(drainWindow) | ||
| } | ||
| } | ||
|
|
||
| // JobsDrain is the process-wide tracker: workers feed it, the gateway reads it. | ||
| var JobsDrain = NewDrain(time.Now) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| package metrics | ||
|
|
||
| import ( | ||
| "testing" | ||
| "time" | ||
| ) | ||
|
|
||
| func TestDrainRateAndRetryAfter(t *testing.T) { | ||
| now := time.Unix(1000, 0) | ||
| d := NewDrain(func() time.Time { return now }) | ||
|
|
||
| if got := d.RetryAfter(500, 1, 60); got != 1 { | ||
| t.Fatalf("RetryAfter with no measurements = %d, want the lower bound", got) | ||
| } | ||
| d.Complete() // starts the first window | ||
| for i := 0; i < 100; i++ { | ||
| d.Complete() | ||
| } | ||
| now = now.Add(time.Second) | ||
| rate := d.Rate() | ||
| if rate < 25 || rate > 35 { | ||
| t.Fatalf("rate after one 101-job second = %.1f, want about 30 (alpha 0.3)", rate) | ||
| } | ||
| if got := d.RetryAfter(600, 1, 60); got < 15 || got > 25 { | ||
| t.Fatalf("RetryAfter(600) = %d, want about 20 at 30 jobs/s", got) | ||
| } | ||
| if got := d.RetryAfter(1_000_000, 1, 60); got != 60 { | ||
| t.Fatalf("RetryAfter above the cap = %d, want 60", got) | ||
| } | ||
| if got := d.RetryAfter(0, 2, 60); got != 2 { | ||
| t.Fatalf("RetryAfter on an empty queue = %d, want the lower bound", got) | ||
| } | ||
| now = now.Add(30 * time.Second) | ||
| if d.Rate() > 0.1 { | ||
| t.Fatalf("rate after 30 idle seconds = %.3f, want it to have decayed", d.Rate()) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: stjosephsplacements/AetherCode-Execution-Engine
Length of output: 4967
Script executed:
Repository: stjosephsplacements/AetherCode-Execution-Engine
Length of output: 5328
Base admission and
Retry-Afteron outstanding jobs, notXLen.XACKremoves an entry from the pending list, but the entry stays in the stream. Completed jobs do not lowerXLen. Rejected requests add no entries, so once a stream reachesadmissionMaxQueueDepth,AdmissionCheck(internal/ratelimit/limiter.go:122-127) keeps rejecting. The only trimming is approximateMaxLen: 100_000onXADD, which does not reopen a stream below the limit.The new
busyRetryAftersubtracts the limit from the same unchanged length (internal/gateway/handler.go:871-875). It can advertise a 1-second retry while admission stays closed. Clients that follow the header retry into the same rejection and add load during overload.Admission and the estimate need a count of outstanding work, such as pending entries or consumer-group lag. Retained stream entries should not count as queued jobs.