From 37e4f845f5c486bf054d8aa10df225adc29afb57 Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Fri, 3 Jul 2026 23:21:15 +1000 Subject: [PATCH 01/29] Move service instance summary under status --- web/components/service/details/service-details-overview.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/components/service/details/service-details-overview.tsx b/web/components/service/details/service-details-overview.tsx index 274d8795..2d8926d5 100644 --- a/web/components/service/details/service-details-overview.tsx +++ b/web/components/service/details/service-details-overview.tsx @@ -404,11 +404,11 @@ function ServiceConfigPanel({
{formatInstanceSummary(overview)}
+{validationError}
+ )} + + {hasChanges && ( + + )} ++ Containers scale down to zero when idle and wake on traffic. Requests + while sleeping are queued and served after the container is ready. +
+ {validationError && ({validationError}
)} From 5918ddfe3e89d4ad33fda9a68fe0c73a01387c6a Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:15:34 +1000 Subject: [PATCH 11/29] Add serverless gateway lifecycle logs --- agent/internal/serverless/gateway.go | 143 +++++++++++++++++++++++++-- 1 file changed, 135 insertions(+), 8 deletions(-) diff --git a/agent/internal/serverless/gateway.go b/agent/internal/serverless/gateway.go index c2c6de1a..8dd2263a 100644 --- a/agent/internal/serverless/gateway.go +++ b/agent/internal/serverless/gateway.go @@ -198,13 +198,27 @@ func (g *Gateway) resolveUpstreams(host string) ([]agenthttp.ServerlessUpstream, return nil, fmt.Errorf("no ready upstreams for host %s", host) } + wakeStartedAt := time.Now() if len(ready) >= max(1, route.MinReadyReplicas) || (len(ready) > 0 && hasAlwaysOnUpstream(ready)) { + log.Printf( + "[serverless-gateway] wake requested host=%s deployments=%d ready_upstreams=%d mode=background", + host, + len(sleepingLocalIDs), + len(ready), + ) go g.wakeLocalDeployments(route, state, sleepingLocalIDs) return ready, nil } + log.Printf( + "[serverless-gateway] wake requested host=%s deployments=%d ready_upstreams=%d mode=blocking timeout=%s", + host, + len(sleepingLocalIDs), + len(ready), + wakeTimeout(route.WakeTimeoutSeconds), + ) g.wakeLocalDeployments(route, state, sleepingLocalIDs) - return g.waitForReadyUpstreams(route, route.WakeTimeoutSeconds) + return g.waitForReadyUpstreams(route, route.WakeTimeoutSeconds, wakeStartedAt) } func (g *Gateway) readyUpstreams(route *agenthttp.ServerlessRoute, state *agenthttp.ExpectedState) ([]agenthttp.ServerlessUpstream, []string, error) { @@ -264,29 +278,54 @@ func (g *Gateway) wakeLocalDeployments(route *agenthttp.ServerlessRoute, state * for _, deploymentID := range deploymentIDs { expected, ok := expectedByDeploymentID[deploymentID] if !ok { + log.Printf( + "[serverless-gateway] wake skipped host=%s deployment=%s reason=missing_expected_state", + route.Domain, + deploymentID, + ) continue } + deployStartedAt := time.Now() + log.Printf( + "[serverless-gateway] wake starting host=%s deployment=%s service=%s container=%s", + route.Domain, + deploymentID, + expected.ServiceID, + expected.Name, + ) g.runtime.QueueServerlessTransition(agenthttp.ServerlessTransition{ Type: "wake_started", DeploymentID: deploymentID, }) if err := g.runtime.DeployServerlessContainer(expected); err != nil { - log.Printf("[serverless-gateway] wake failed for deployment %s: %v", deploymentID, err) + log.Printf( + "[serverless-gateway] wake failed host=%s deployment=%s service=%s latency=%s error=%v", + route.Domain, + deploymentID, + expected.ServiceID, + roundDuration(time.Since(deployStartedAt)), + err, + ) g.runtime.QueueServerlessTransition(agenthttp.ServerlessTransition{ Type: "wake_failed", DeploymentID: deploymentID, Error: err.Error(), }) + continue } + log.Printf( + "[serverless-gateway] wake container started host=%s deployment=%s service=%s latency=%s", + route.Domain, + deploymentID, + expected.ServiceID, + roundDuration(time.Since(deployStartedAt)), + ) } g.evictUpstreams(route.Domain) } -func (g *Gateway) waitForReadyUpstreams(route *agenthttp.ServerlessRoute, wakeTimeoutSeconds int) ([]agenthttp.ServerlessUpstream, error) { - timeout := time.Duration(wakeTimeoutSeconds) * time.Second - if timeout <= 0 { - timeout = 5 * time.Minute - } +func (g *Gateway) waitForReadyUpstreams(route *agenthttp.ServerlessRoute, wakeTimeoutSeconds int, startedAt time.Time) ([]agenthttp.ServerlessUpstream, error) { + timeout := wakeTimeout(wakeTimeoutSeconds) deadline := time.Now().Add(timeout) for { @@ -296,12 +335,29 @@ func (g *Gateway) waitForReadyUpstreams(route *agenthttp.ServerlessRoute, wakeTi return nil, err } if len(ready) >= max(1, route.MinReadyReplicas) || (len(ready) > 0 && len(sleepingLocalIDs) == 0) { + log.Printf( + "[serverless-gateway] wake ready host=%s upstreams=%d latency=%s", + route.Domain, + len(ready), + roundDuration(time.Since(startedAt)), + ) return ready, nil } if time.Now().After(deadline) { if len(ready) > 0 { + log.Printf( + "[serverless-gateway] wake partially ready host=%s upstreams=%d latency=%s", + route.Domain, + len(ready), + roundDuration(time.Since(startedAt)), + ) return ready, nil } + log.Printf( + "[serverless-gateway] wake timed out host=%s latency=%s", + route.Domain, + roundDuration(time.Since(startedAt)), + ) return nil, fmt.Errorf("timed out waiting for local serverless wake") } time.Sleep(wakePollInterval) @@ -425,7 +481,13 @@ func (g *Gateway) sleepHost(host string) { activity := g.activity(host) activity.mu.Lock() if activity.activeRequests > 0 { + activeRequests := activity.activeRequests activity.mu.Unlock() + log.Printf( + "[serverless-gateway] sleep skipped host=%s reason=active_requests active=%d", + host, + activeRequests, + ) return } activity.sleepTimer = nil @@ -434,8 +496,14 @@ func (g *Gateway) sleepHost(host string) { state := g.runtime.ExpectedState() route := findRoute(state, host) if route == nil { + log.Printf("[serverless-gateway] sleep skipped host=%s reason=missing_route_metadata", host) return } + log.Printf( + "[serverless-gateway] sleep timer fired host=%s deployments=%d", + host, + len(route.LocalDeploymentIDs), + ) actualContainers, err := g.runtime.ListServerlessContainers() if err != nil { @@ -448,23 +516,70 @@ func (g *Gateway) sleepHost(host string) { for _, deploymentID := range route.LocalDeploymentIDs { expected, ok := expectedByDeploymentID[deploymentID] if !ok { + log.Printf( + "[serverless-gateway] sleep skipped host=%s deployment=%s reason=missing_expected_state", + host, + deploymentID, + ) continue } actual, ok := actualByDeploymentID[deploymentID] if !ok || actual.State != "running" { + reason := "no_local_container" + if ok { + reason = "container_not_running" + } + log.Printf( + "[serverless-gateway] sleep skipped host=%s deployment=%s reason=%s", + host, + deploymentID, + reason, + ) continue } + sleepStartedAt := time.Now() if expected.DesiredState != "stopped" { + log.Printf( + "[serverless-gateway] sleep starting host=%s deployment=%s service=%s container=%s", + host, + deploymentID, + expected.ServiceID, + actual.ID, + ) g.runtime.QueueServerlessTransition(agenthttp.ServerlessTransition{ Type: "sleep", DeploymentID: deploymentID, ContainerID: actual.ID, }) + } else { + log.Printf( + "[serverless-gateway] sleep cleanup starting host=%s deployment=%s service=%s container=%s reason=already_expected_stopped", + host, + deploymentID, + expected.ServiceID, + actual.ID, + ) } if err := g.runtime.RemoveServerlessContainer(actual.ID); err != nil { - log.Printf("[serverless-gateway] failed to sleep deployment %s: %v", deploymentID, err) + log.Printf( + "[serverless-gateway] sleep failed host=%s deployment=%s service=%s container=%s latency=%s error=%v", + host, + deploymentID, + expected.ServiceID, + actual.ID, + roundDuration(time.Since(sleepStartedAt)), + err, + ) continue } + log.Printf( + "[serverless-gateway] sleep complete host=%s deployment=%s service=%s container=%s latency=%s", + host, + deploymentID, + expected.ServiceID, + actual.ID, + roundDuration(time.Since(sleepStartedAt)), + ) } g.evictUpstreams(host) } @@ -577,6 +692,18 @@ func compareUpstream(a, b agenthttp.ServerlessUpstream) int { return strings.Compare(a.Url, b.Url) } +func wakeTimeout(wakeTimeoutSeconds int) time.Duration { + timeout := time.Duration(wakeTimeoutSeconds) * time.Second + if timeout <= 0 { + return 5 * time.Minute + } + return timeout +} + +func roundDuration(duration time.Duration) time.Duration { + return duration.Round(time.Millisecond) +} + func cloneUpstreams(upstreams []agenthttp.ServerlessUpstream) []agenthttp.ServerlessUpstream { return append([]agenthttp.ServerlessUpstream(nil), upstreams...) } From a7a1c89f1bbda9a37ca4cd8589f84f7db9da4227 Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Sun, 5 Jul 2026 16:23:58 +1000 Subject: [PATCH 12/29] Require public endpoint for serverless services --- web/actions/projects.ts | 19 ++++++ .../service/details/serverless-section.tsx | 28 +++++++-- web/lib/agent/expected-state.ts | 33 +++++++++- web/tests/expected-state.test.ts | 60 +++++++++++++++++++ 4 files changed, 135 insertions(+), 5 deletions(-) diff --git a/web/actions/projects.ts b/web/actions/projects.ts index 27ecdac8..46776037 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -1049,6 +1049,25 @@ export async function updateServiceServerlessSettings( } if (validated.enabled) { + const publicHttpPorts = await tx + .select({ id: servicePorts.id }) + .from(servicePorts) + .where( + and( + eq(servicePorts.serviceId, serviceId), + eq(servicePorts.isPublic, true), + eq(servicePorts.protocol, "http"), + isNotNull(servicePorts.domain), + ), + ) + .limit(1); + + if (publicHttpPorts.length === 0) { + throw new Error( + "Serverless services require a public HTTP port with a domain", + ); + } + const configuredReplicas = await tx .select({ count: serviceReplicas.count }) .from(serviceReplicas) diff --git a/web/components/service/details/serverless-section.tsx b/web/components/service/details/serverless-section.tsx index eb6e7007..a3c755c6 100644 --- a/web/components/service/details/serverless-section.tsx +++ b/web/components/service/details/serverless-section.tsx @@ -28,6 +28,19 @@ export const ServerlessSection = memo(function ServerlessSection({ const [minReadyReplicas, setMinReadyReplicas] = useState( String(service.serverlessMinReadyReplicas ?? 1), ); + const hasPublicHttpEndpoint = useMemo( + () => + service.ports.some( + (port) => port.isPublic && port.protocol === "http" && !!port.domain, + ), + [service.ports], + ); + const unavailableReason = service.stateful + ? "Serverless is only supported for stateless services" + : !hasPublicHttpEndpoint + ? "Add a public HTTP port with a domain to enable serverless" + : null; + const optionsDisabled = !!unavailableReason || isSaving; const parsed = useMemo( () => ({ @@ -39,8 +52,8 @@ export const ServerlessSection = memo(function ServerlessSection({ ); const validationError = useMemo(() => { - if (service.stateful && enabled) { - return "Serverless is only supported for stateless services"; + if (unavailableReason && enabled) { + return unavailableReason; } if ( !Number.isInteger(parsed.sleepAfterSeconds) || @@ -64,7 +77,7 @@ export const ServerlessSection = memo(function ServerlessSection({ return "Minimum ready replicas must be between 1 and 10"; } return null; - }, [enabled, parsed, service.stateful]); + }, [enabled, parsed, unavailableReason]); const hasChanges = enabled !== service.serverlessEnabled || @@ -108,7 +121,7 @@ export const ServerlessSection = memo(function ServerlessSection({{unavailableReason}.
+ )} + {validationError && ({validationError}
)} diff --git a/web/lib/agent/expected-state.ts b/web/lib/agent/expected-state.ts index ee6d57f2..1943a2a2 100644 --- a/web/lib/agent/expected-state.ts +++ b/web/lib/agent/expected-state.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray, isNull } from "drizzle-orm"; +import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; import { db } from "@/db"; import { deploymentPorts, @@ -218,6 +218,8 @@ async function buildExpectedContainers( .where(inArray(serviceVolumes.serviceId, serviceIds)), ], ); + const serverlessRoutableServiceIds = + await fetchPublicHttpServiceIds(serviceIds); return buildExpectedContainersFromRows({ deployments: serverDeployments, @@ -226,6 +228,7 @@ async function buildExpectedContainers( secrets: serviceSecrets, volumes, serverIsProxy, + serverlessRoutableServiceIds, }); } @@ -243,6 +246,24 @@ async function fetchDeploymentPorts(deploymentIds: string[]) { .where(inArray(deploymentPorts.deploymentId, deploymentIds)); } +async function fetchPublicHttpServiceIds(serviceIds: string[]) { + if (serviceIds.length === 0) return new Set