Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 15 additions & 12 deletions backend/internal/database/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,29 +147,32 @@ func (db *Database) CountAPIKeys() (int, error) {
// reads this at pool creation time; runtime changes require pool recreation
// (restart Orva, or wait for the pool to be torn down).
type PoolConfig struct {
FunctionID string `json:"function_id"`
MinWarm int `json:"min_warm"`
MaxWarm int `json:"max_warm"`
IdleTTLS int `json:"idle_ttl_seconds"`
TargetConcurrency int `json:"target_concurrency"` // req/worker before scale-up considered (Knative-style)
ScaleToZero bool `json:"scale_to_zero"` // if true, scale down to 0 when idle (cold-start on next req)
FunctionID string `json:"function_id"`
MinWarm int `json:"min_warm"`
MaxWarm int `json:"max_warm"`
IdleTTLS int `json:"idle_ttl_seconds"`
ScaleToZero bool `json:"scale_to_zero"`
}

func (db *Database) UpsertPoolConfig(cfg *PoolConfig) error {
if cfg.ScaleToZero {
cfg.MinWarm = 0
} else if cfg.MinWarm < 1 {
cfg.MinWarm = 1
}
sc := 0
if cfg.ScaleToZero {
sc = 1
}
_, err := db.write.Exec(`
INSERT INTO pool_config (function_id, min_warm, max_warm, idle_ttl_s, target_concurrency, scale_to_zero)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO pool_config (function_id, min_warm, max_warm, idle_ttl_s, scale_to_zero)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(function_id) DO UPDATE SET
min_warm = excluded.min_warm,
max_warm = excluded.max_warm,
idle_ttl_s = excluded.idle_ttl_s,
target_concurrency = excluded.target_concurrency,
scale_to_zero = excluded.scale_to_zero`,
cfg.FunctionID, cfg.MinWarm, cfg.MaxWarm, cfg.IdleTTLS, cfg.TargetConcurrency, sc,
cfg.FunctionID, cfg.MinWarm, cfg.MaxWarm, cfg.IdleTTLS, sc,
)
return err
}
Expand All @@ -179,9 +182,9 @@ func (db *Database) GetPoolConfig(functionID string) (*PoolConfig, error) {
var sc int
err := db.read.QueryRow(`
SELECT function_id, min_warm, max_warm, idle_ttl_s,
COALESCE(target_concurrency, 10), COALESCE(scale_to_zero, 0)
COALESCE(scale_to_zero, 0)
FROM pool_config WHERE function_id = ?`, functionID,
).Scan(&cfg.FunctionID, &cfg.MinWarm, &cfg.MaxWarm, &cfg.IdleTTLS, &cfg.TargetConcurrency, &sc)
).Scan(&cfg.FunctionID, &cfg.MinWarm, &cfg.MaxWarm, &cfg.IdleTTLS, &sc)
if err != nil {
return nil, err
}
Expand Down
75 changes: 72 additions & 3 deletions backend/internal/database/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,9 @@ CREATE TABLE IF NOT EXISTS execution_logs (
CREATE TABLE IF NOT EXISTS pool_config (
function_id TEXT PRIMARY KEY,
min_warm INTEGER NOT NULL DEFAULT 1,
max_warm INTEGER NOT NULL DEFAULT 50, -- Knative-style soft cap; autoscaler respects mem/cpu budget
max_warm INTEGER NOT NULL DEFAULT 50,
idle_ttl_s INTEGER NOT NULL DEFAULT 600,
max_use_count INTEGER NOT NULL DEFAULT 1000,
target_concurrency INTEGER NOT NULL DEFAULT 10, -- Knative target concurrency per worker
scale_to_zero INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (function_id) REFERENCES functions(id) ON DELETE CASCADE
);
Expand Down Expand Up @@ -536,7 +535,6 @@ PRAGMA foreign_keys = ON;
// Additive columns for the smart autoscaler. Idempotent — SQLite errors
// if the column already exists, which we ignore.
for _, stmt := range []string{
"ALTER TABLE pool_config ADD COLUMN target_concurrency INTEGER NOT NULL DEFAULT 10",
"ALTER TABLE pool_config ADD COLUMN scale_to_zero INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE api_keys ADD COLUMN key_prefix TEXT NOT NULL DEFAULT ''",
"ALTER TABLE deployments ADD COLUMN code_hash TEXT NOT NULL DEFAULT ''",
Expand Down Expand Up @@ -785,6 +783,14 @@ PRAGMA foreign_keys = ON;
}
}

// Pool controller v2 removes the public target_concurrency knob. Rebuild
// the table because SQLite cannot DROP COLUMN safely across all supported
// versions. The copy normalizes the scale contract at the same time:
// scale-to-zero rows own min_warm=0; active-minimum rows own min_warm>=1.
if err := migratePoolConfigV2(db); err != nil {
return fmt.Errorf("pool config v2 migration: %w", err)
}

// v0.4 A3 fix: drop the legacy FK on execution_requests.execution_id
// for databases that were created before the FK-removal change. SQLite
// has no ALTER TABLE DROP CONSTRAINT, so the only way to remove an FK
Expand Down Expand Up @@ -865,6 +871,69 @@ PRAGMA foreign_keys = ON;
return nil
}

func migratePoolConfigV2(db *Database) error {
rows, err := db.read.Query(`PRAGMA table_info(pool_config)`)
if err != nil {
return err
}
hasTarget := false
for rows.Next() {
var cid, notNull, pk int
var name, typ string
var defaultValue any
if err := rows.Scan(&cid, &name, &typ, &notNull, &defaultValue, &pk); err != nil {
_ = rows.Close()
return err
}
if name == "target_concurrency" {
hasTarget = true
}
}
if err := rows.Close(); err != nil {
return err
}
if !hasTarget {
// Keep legacy-but-valid rows aligned even after a prior successful
// rebuild; this also makes the migration idempotent after interruption.
_, err := db.write.Exec(`UPDATE pool_config SET min_warm = CASE
WHEN scale_to_zero = 1 THEN 0
WHEN min_warm < 1 THEN 1
ELSE min_warm END`)
return err
}

tx, err := db.write.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
for _, stmt := range []string{
`DROP TABLE IF EXISTS pool_config_v2`,
`CREATE TABLE pool_config_v2 (
function_id TEXT PRIMARY KEY,
min_warm INTEGER NOT NULL DEFAULT 1,
max_warm INTEGER NOT NULL DEFAULT 50,
idle_ttl_s INTEGER NOT NULL DEFAULT 600,
max_use_count INTEGER NOT NULL DEFAULT 1000,
scale_to_zero INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (function_id) REFERENCES functions(id) ON DELETE CASCADE
)`,
`INSERT INTO pool_config_v2
(function_id, min_warm, max_warm, idle_ttl_s, max_use_count, scale_to_zero)
SELECT function_id,
CASE WHEN scale_to_zero = 1 THEN 0 WHEN min_warm < 1 THEN 1 ELSE min_warm END,
max_warm, idle_ttl_s, max_use_count, scale_to_zero
FROM pool_config`,
`DROP TABLE pool_config`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the pool migration additive-only

Upgrading any database that still has target_concurrency executes this DROP TABLE pool_config, despite the repository's explicit additive-only migration invariant. The controller can simply leave the now-unused column in place and normalize min_warm with an idempotent UPDATE; rebuilding the table also removes independently added indexes or triggers and makes downgrade compatibility unnecessarily unsafe.

AGENTS.md reference: backend/AGENTS.md:L87-L89

Useful? React with 👍 / 👎.

`ALTER TABLE pool_config_v2 RENAME TO pool_config`,
} {
if _, err := tx.Exec(stmt); err != nil {
return err
}
}
return tx.Commit()
}

// dropExecutionRequestsFK rebuilds the execution_requests table without
// the legacy FK on execution_id. Idempotent: if the stored CREATE TABLE
// no longer contains a REFERENCES clause we skip the rebuild. The proxy
Expand Down
83 changes: 83 additions & 0 deletions backend/internal/database/pool_config_v2_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package database

import (
"path/filepath"
"testing"
)

func TestPoolConfigV2RebuildPreservesRowsAndForeignKey(t *testing.T) {
db, err := New(filepath.Join(t.TempDir(), "pool-v2.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := db.Migrate(); err != nil {
t.Fatal(err)
}
fn := &Function{Name: "legacy-pool", Runtime: "node", Entrypoint: "handler.js", MemoryMB: 64, CPUs: 1, TimeoutMS: 1000, Status: "active"}
if err := db.InsertFunction(fn); err != nil {
t.Fatal(err)
}
for _, stmt := range []string{
`DROP TABLE pool_config`,
`CREATE TABLE pool_config (
function_id TEXT PRIMARY KEY, min_warm INTEGER NOT NULL, max_warm INTEGER NOT NULL,
idle_ttl_s INTEGER NOT NULL, max_use_count INTEGER NOT NULL,
target_concurrency INTEGER NOT NULL, scale_to_zero INTEGER NOT NULL,
FOREIGN KEY (function_id) REFERENCES functions(id) ON DELETE CASCADE)`,
`INSERT INTO pool_config VALUES ('` + fn.ID + `', 7, 23, 321, 777, 9, 1)`,
} {
if _, err := db.write.Exec(stmt); err != nil {
t.Fatal(err)
}
}
if err := db.Migrate(); err != nil {
t.Fatal(err)
}
cfg, err := db.GetPoolConfig(fn.ID)
if err != nil {
t.Fatal(err)
}
if cfg.MinWarm != 0 || cfg.MaxWarm != 23 || cfg.IdleTTLS != 321 || !cfg.ScaleToZero {
t.Fatalf("migrated config mismatch: %+v", cfg)
}
var targetColumns int
if err := db.read.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('pool_config') WHERE name='target_concurrency'`).Scan(&targetColumns); err != nil {
t.Fatal(err)
}
if targetColumns != 0 {
t.Fatal("target_concurrency survived rebuild")
}
if _, err := db.write.Exec(`DELETE FROM functions WHERE id=?`, fn.ID); err != nil {
t.Fatal(err)
}
var rows int
if err := db.read.QueryRow(`SELECT COUNT(*) FROM pool_config WHERE function_id=?`, fn.ID).Scan(&rows); err != nil {
t.Fatal(err)
}
if rows != 0 {
t.Fatal("pool_config foreign-key cascade was not preserved")
}
}

func TestPoolConfigNormalization(t *testing.T) {
db := newTestDB(t)
fn := &Function{Name: "normalize-pool", Runtime: "node", Entrypoint: "handler.js", MemoryMB: 64, CPUs: 1, TimeoutMS: 1000, Status: "active"}
if err := db.InsertFunction(fn); err != nil {
t.Fatal(err)
}
cfg := &PoolConfig{FunctionID: fn.ID, MinWarm: 4, MaxWarm: 10, IdleTTLS: 600, ScaleToZero: true}
if err := db.UpsertPoolConfig(cfg); err != nil {
t.Fatal(err)
}
if got, _ := db.GetPoolConfig(fn.ID); got.MinWarm != 0 {
t.Fatalf("scale-to-zero min=%d", got.MinWarm)
}
cfg.ScaleToZero = false
if err := db.UpsertPoolConfig(cfg); err != nil {
t.Fatal(err)
}
if got, _ := db.GetPoolConfig(fn.ID); got.MinWarm != 1 {
t.Fatalf("active min=%d", got.MinWarm)
}
}
2 changes: 1 addition & 1 deletion backend/internal/mcp/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -1243,7 +1243,7 @@ Failed deliveries (non-2xx, timeout, network) retry up to 5× with exponential b
- Network is OFF by default — sandbox has only loopback (no DNS, no outbound TCP). The user must flip "Allow outbound network" in the editor's Settings modal to call external HTTPS APIs (Stripe, OpenAI, a remote DB). Tell the user to do this whenever your code makes outbound calls.
- orva.kv / orva.invoke / orva.jobs ALSO require egress — the SDK reaches orvad over the bridge network via HTTP, so a function with `network_mode: "none"` will see every SDK call fail with ENETUNREACH / OrvaUnavailableError. If the handler imports the orva module, set `network_mode: "egress"` at create time (or update later) — the editor's deploy step will warn you when the import meets `none`.
- When egress IS enabled, the operator can still block specific destinations with the egress policy, and can pin resolvers / host overrides with the sandbox DNS settings (both on the dashboard's Egress controls page). A destination blocked by policy fails with ECONNREFUSED — distinct from the ENETUNREACH you get with `network_mode: "none"`. Handle both.
- Concurrency: each warm worker handles one request at a time. The pool autoscales workers up to the function's max_concurrent setting. Don't rely on in-process module-level state surviving across requests beyond best-effort caching.
- Concurrency: each warm worker handles one request at a time. Pool Controller v2 sizes workers from arrival rate, queue pressure, service time, and cold-start time, bounded by the function's pool ceiling and effective host CPU/memory capacity. Don't rely on in-process module-level state surviving across requests beyond best-effort caching.
</sandbox_limits>

<auth_modes>
Expand Down
54 changes: 34 additions & 20 deletions backend/internal/mcp/tools_pool.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,37 @@ package mcp

import (
"context"
"fmt"

"github.com/Harsh-2002/Orva/backend/internal/database"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
)

type PoolConfigView struct {
FunctionID string `json:"function_id"`
MinWarm int `json:"min_warm"`
MaxWarm int `json:"max_warm"`
IdleTTLSeconds int `json:"idle_ttl_seconds"`
TargetConcurrency int `json:"target_concurrency"`
ScaleToZero bool `json:"scale_to_zero"`
FunctionID string `json:"function_id"`
MinWarm int `json:"min_warm"`
MaxWarm int `json:"max_warm"`
IdleTTLSeconds int `json:"idle_ttl_seconds"`
ScaleToZero bool `json:"scale_to_zero"`
}

type GetPoolConfigInput struct {
FunctionID string `json:"function_id" jsonschema:"function id (UUID) or name"`
}

type SetPoolConfigInput struct {
FunctionID string `json:"function_id"`
MinWarm *int `json:"min_warm,omitempty"`
MaxWarm *int `json:"max_warm,omitempty"`
IdleTTLSeconds *int `json:"idle_ttl_seconds,omitempty"`
TargetConcurrency *int `json:"target_concurrency,omitempty" jsonschema:"req per worker before scale-up considered"`
ScaleToZero *bool `json:"scale_to_zero,omitempty"`
FunctionID string `json:"function_id"`
MinWarm *int `json:"min_warm,omitempty"`
MaxWarm *int `json:"max_warm,omitempty"`
IdleTTLSeconds *int `json:"idle_ttl_seconds,omitempty"`
ScaleToZero *bool `json:"scale_to_zero,omitempty"`
}

func toPoolConfigView(c *database.PoolConfig) PoolConfigView {
return PoolConfigView{
FunctionID: c.FunctionID, MinWarm: c.MinWarm, MaxWarm: c.MaxWarm,
IdleTTLSeconds: c.IdleTTLS, TargetConcurrency: c.TargetConcurrency,
ScaleToZero: c.ScaleToZero,
IdleTTLSeconds: c.IdleTTLS,
ScaleToZero: c.ScaleToZero,
}
}

Expand All @@ -44,7 +43,7 @@ func registerPoolTools(rc *regCtx) {
&mcpsdk.Tool{
Name: "get_pool_config",
Title: "Get Pool Config",
Description: "Get the autoscaler pool config for a function (min_warm, max_warm, idle_ttl, target_concurrency, scale_to_zero). Returns nulls/defaults if no override is configured.",
Description: "Get the Pool Controller v2 config for a function (min_warm, max_warm, idle_ttl, scale_to_zero). Returns defaults if no override is configured.",
Annotations: &mcpsdk.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: ptrFalse()},
},
func(_ context.Context, _ *mcpsdk.CallToolRequest, in GetPoolConfigInput) (*mcpsdk.CallToolResult, PoolConfigView, error) {
Expand All @@ -57,7 +56,7 @@ func registerPoolTools(rc *regCtx) {
// no row = use defaults
return nil, PoolConfigView{
FunctionID: fn.ID, MinWarm: 1, MaxWarm: 50,
IdleTTLSeconds: 600, TargetConcurrency: 10,
IdleTTLSeconds: 600,
}, nil
}
return nil, toPoolConfigView(cfg), nil
Expand All @@ -80,7 +79,7 @@ func registerPoolTools(rc *regCtx) {
if err != nil {
cfg = &database.PoolConfig{
FunctionID: fn.ID, MinWarm: 1, MaxWarm: 50,
IdleTTLS: 600, TargetConcurrency: 10,
IdleTTLS: 600,
}
}
if in.MinWarm != nil {
Expand All @@ -92,12 +91,27 @@ func registerPoolTools(rc *regCtx) {
if in.IdleTTLSeconds != nil {
cfg.IdleTTLS = *in.IdleTTLSeconds
}
if in.TargetConcurrency != nil {
cfg.TargetConcurrency = *in.TargetConcurrency
}
if in.ScaleToZero != nil {
cfg.ScaleToZero = *in.ScaleToZero
}
if in.MinWarm != nil {
if cfg.ScaleToZero && cfg.MinWarm != 0 {
return nil, PoolConfigView{}, fmt.Errorf("scale_to_zero=true requires min_warm=0")
}
if !cfg.ScaleToZero && cfg.MinWarm < 1 {
return nil, PoolConfigView{}, fmt.Errorf("scale_to_zero=false requires min_warm>=1")
}
}
if in.ScaleToZero != nil && in.MinWarm == nil {
if cfg.ScaleToZero {
cfg.MinWarm = 0
} else if cfg.MinWarm < 1 {
cfg.MinWarm = 1
}
}
if cfg.MaxWarm < 1 || cfg.MinWarm > cfg.MaxWarm || cfg.IdleTTLS < 0 {
return nil, PoolConfigView{}, fmt.Errorf("require min_warm <= max_warm, max_warm >= 1, and idle_ttl_seconds >= 0")
}
if err := deps.DB.UpsertPoolConfig(cfg); err != nil {
return nil, PoolConfigView{}, err
}
Expand Down
Loading