From d020520f6731022b2d8076261c3178472d7f1126 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Sat, 7 Nov 2026 05:07:21 +0000 Subject: [PATCH] Let capacity pressure expire idle unattached Environments --- contracts/agents-api/environments.md | 2 +- contracts/agents-api/index.md | 1 + contracts/agents-api/zh/environments.md | 4 +-- contracts/agents-api/zh/index.md | 3 +- docs/sandbox-provider.md | 2 +- docs/zh/sandbox-provider.md | 4 +-- .../db/queries/runtime_allocations.sql | 10 ++++++ .../db/sqlc/runtime_allocations.sql.go | 18 ++++++++++ .../internal/deployment/allocations_test.go | 2 ++ services/core/internal/deployment/storage.go | 6 +++- .../deployment/suspension_pressure.go | 13 ++++---- .../deploymentpg/retention_pressure_test.go | 33 +++++++++++++++++++ .../deploymentpg/suspension_pressure.go | 21 ++++++++++-- 13 files changed, 103 insertions(+), 16 deletions(-) diff --git a/contracts/agents-api/environments.md b/contracts/agents-api/environments.md index 3cea28ea5..d06e33d50 100644 --- a/contracts/agents-api/environments.md +++ b/contracts/agents-api/environments.md @@ -86,7 +86,7 @@ A `pending` Environment is accepted: the Session's first input waits for prepara **Files.** [Environment files](./environment-files.md#where-files-work) owns the files routes while no Session is attached. -**Lifecycle without a Session.** An unattached Environment provisions and initializes without a Harness, and its lifetime depends on the Provider. With checkpoint support it suspends after the deployment's idle timeout and expires when snapshot retention ends; [Suspension policy](./sandbox-deployment.md#suspension-policy) owns both values. Without checkpoint support it cannot be suspended, so it expires after the idle timeout without activity, counted from its last activity: the end of its initialization, a detach or later activity. A failed Environment releases its compute at once. On any Provider it also expires after confirmed compute loss, including compute released while a Session held it once that Session is deleted, because Core never replaces it on new compute: nothing identifies the work to replay. A [sandbox reset](./sandbox-deployment.md#reset) expires it as well. An expired Environment cannot be attached. An ended (`expired` or `failed`) unattached Environment releases its placement and workspace. +**Lifecycle without a Session.** An unattached Environment provisions and initializes without a Harness, and its lifetime depends on the Provider. With checkpoint support it suspends after the deployment's idle timeout and expires when snapshot retention ends; [Suspension policy](./sandbox-deployment.md#suspension-policy) owns both values. When a node's full retained slots block waiting placement, [capacity pressure](../../docs/sandbox-provider.md#cold-replacement-after-checkpoint-retention) can expire an idle suspended one earlier. Without checkpoint support it cannot be suspended, so it expires after the idle timeout without activity, counted from its last activity: the end of its initialization, a detach or later activity. A failed Environment releases its compute at once. On any Provider it also expires after confirmed compute loss, including compute released while a Session held it once that Session is deleted, because Core never replaces it on new compute: nothing identifies the work to replay. A [sandbox reset](./sandbox-deployment.md#reset) expires it as well. An expired Environment cannot be attached. An ended (`expired` or `failed`) unattached Environment releases its placement and workspace. ## Placements diff --git a/contracts/agents-api/index.md b/contracts/agents-api/index.md index 30cac32ed..268ef0865 100644 --- a/contracts/agents-api/index.md +++ b/contracts/agents-api/index.md @@ -72,6 +72,7 @@ Each item is Core's deliberate or native behavior where the official service beh - The pin says an incomplete hosted creation returns 409. Core completes a creation in one transaction, so a retry with the same key and body returns the Environment in its current state, `pending` included. - The Environment list's `limit` bounds and its 404 for an unresolved `after` ([Page size](./wire-semantics.md#page-size), [Cursors](./wire-semantics.md#cursors)) are Core's behavior and have no official sample. - An unattached Environment that [expires](./environments.md#standalone-environments) is never replaced on new compute. +- When a node's full retained slots block waiting placement, Core can expire an idle unattached Environment before its snapshot retention ends ([capacity pressure](../../docs/sandbox-provider.md#cold-replacement-after-checkpoint-retention)); the pin specifies no early expiry. **Lists** ([lists](./wire-semantics.md#lists)) diff --git a/contracts/agents-api/zh/environments.md b/contracts/agents-api/zh/environments.md index 07d90a21d..29ace25e7 100644 --- a/contracts/agents-api/zh/environments.md +++ b/contracts/agents-api/zh/environments.md @@ -1,7 +1,7 @@ --- title: "Environment 与模板" source: contracts/agents-api/environments.md -source_hash: 5a65935b1ac297109a14c525de83a05734587fe50593b2500e8c920507d16e87 +source_hash: effa1e3ba384fbb3272d26c5c1d56c9d7e2116f7f2bfa2ecce6cfe1a2a266552 --- Environment 是 Session 的执行资源,包括 Harness 运行所在的机器、工作区以及能力。Session 通过其 `environment` 配置创建 Environment,或挂载由 `POST /agents/environments` 预先创建的 Environment([独立 Environment](#standalone-environments))。Environment 模板是 Session 创建时解析的可复用准备配置。本契约涵盖这两类资源、两种放置方式、输入接纳、能力准备、Skills、Plugins 和 MCP 连接来源。 @@ -88,7 +88,7 @@ Core 在 Session 创建事务中创建 Environment 记录;Session upsert 会 **文件。** 没有挂载 Session 时的文件路由由 [Environment files](environment-files.md#where-files-work) 规定。 -**无 Session 时的生命周期。** 未挂载的 Environment 无需 Harness 即可供给和初始化,其存续时间取决于沙箱提供商。支持检查点时,它在部署的空闲超时后挂起,并在快照保留期结束时过期,两者的取值见[挂起策略](sandbox-deployment.md#suspension-policy)。不支持检查点时它无法挂起,因此在空闲超时内没有活动后过期,空闲时间从最近一次活动起算:初始化完成、卸载或之后的活动。失败的 Environment 会立即释放其计算。无论哪种提供商,确认计算丢失后它也会过期(包括 Session 持有期间被释放的计算,在该 Session 被删除后),因为 Core 从不在新计算上替换它:没有任何信息能标识要重放的工作。[沙箱重置](sandbox-deployment.md#reset)同样会让它过期。已过期的 Environment 不能挂载。已结束(`expired` 或 `failed`)的未挂载 Environment 会释放其放置和工作区。 +**无 Session 时的生命周期。** 未挂载的 Environment 无需 Harness 即可供给和初始化,其存续时间取决于沙箱提供商。支持检查点时,它在部署的空闲超时后挂起,并在快照保留期结束时过期,两者的取值见[挂起策略](sandbox-deployment.md#suspension-policy)。节点的 retained slot 已满并阻塞等待中的放置时,[容量压力](../../../docs/zh/sandbox-provider.md#cold-replacement-after-checkpoint-retention)可以让已挂起的空闲 Environment 提前过期。不支持检查点时它无法挂起,因此在空闲超时内没有活动后过期,空闲时间从最近一次活动起算:初始化完成、卸载或之后的活动。失败的 Environment 会立即释放其计算。无论哪种提供商,确认计算丢失后它也会过期(包括 Session 持有期间被释放的计算,在该 Session 被删除后),因为 Core 从不在新计算上替换它:没有任何信息能标识要重放的工作。[沙箱重置](sandbox-deployment.md#reset)同样会让它过期。已过期的 Environment 不能挂载。已结束(`expired` 或 `failed`)的未挂载 Environment 会释放其放置和工作区。 ## 放置方式 {#placements} diff --git a/contracts/agents-api/zh/index.md b/contracts/agents-api/zh/index.md index 821765c6a..9f71e0331 100644 --- a/contracts/agents-api/zh/index.md +++ b/contracts/agents-api/zh/index.md @@ -1,7 +1,7 @@ --- title: "OpenAI 兼容性" source: contracts/agents-api/index.md -source_hash: 40aa4ceb297c0866fa75cbc20a6c0d7d9b1f2d64aba7d009446b08d0ec299ded +source_hash: daa785e87ebf76a48a1ec28194e333c64343771bd6d32060a3f8bce127e973db --- Core 实现下方固定版本的 OpenAI Agents API。本台账列出各项资源的覆盖情况、相对于官方服务的所有差异以及已知缺口。 @@ -75,6 +75,7 @@ Core 自身字段位于 `x_agents_core` 中([Core extensions](../../../docs/zh - pin 规定未完成的 hosted 创建返回 409。Core 在一个事务中完成创建,因此使用相同键和正文的重试会返回处于当前状态的 Environment,包括 `pending`。 - Environment 列表的 `limit` 范围和对无法解析的 `after` 返回的 404([页面大小](wire-semantics.md#page-size)、[游标](wire-semantics.md#cursors))是 Core 自己的行为,没有官方样本。 - 过期的未挂载 [Environment](environments.md#standalone-environments) 绝不会在新计算上被替换。 +- 节点的 retained slot 已满并阻塞等待中的放置时,Core 可以在快照保留期结束前让空闲的未挂载 Environment 提前过期([容量压力](../../../docs/zh/sandbox-provider.md#cold-replacement-after-checkpoint-retention));固定版本的 API 未规定提前过期。 **列表**([lists](wire-semantics.md#lists)) diff --git a/docs/sandbox-provider.md b/docs/sandbox-provider.md index 8a13ed54b..6b4227d19 100644 --- a/docs/sandbox-provider.md +++ b/docs/sandbox-provider.md @@ -287,7 +287,7 @@ Snapshot retention bounds compute resources, not the lifetime of an eligible ret An unusable checkpoint, reported as `ErrCheckpointUnusable` by a restoring allocation's `Resume`, ends retention at once, exactly as if its deadline had passed. Core deletes the target and snapshot through the ordinary expiry cleanup, and a qualified Session continues as below, while an unqualified one ends as at ordinary expiry. -Capacity pressure ends retention early only for the node's oldest idle suspended allocation that meets these conditions, and only when waiting placement demand is blocked by the node's full retained slots. Core then treats the allocation as expired, one release per blocked demand, and every other suspended allocation keeps its snapshot and retained slot until its configured deadline. A checkpoint whose restore lacks capacity or a compatible node is not cold-replaced for that reason. Pending requests retain their original input deadline. The ordinary expiry cleanup must confirm resource deletion before another allocation can reserve the slot; files and qualified native history remain retained. +Capacity pressure ends retention early only for the node's oldest idle suspended allocation that meets these conditions or belongs to a standalone Environment with no Session attached, and only when waiting placement demand is blocked by the node's full retained slots. Core then treats the allocation as expired, one release per blocked demand, and every other suspended allocation keeps its snapshot and retained slot until its configured deadline. A checkpoint whose restore lacks capacity or a compatible node is not cold-replaced for that reason. Pending requests retain their original input deadline. The ordinary expiry cleanup must confirm resource deletion before another allocation can reserve the slot; a qualified Session keeps its files and native history, and an unattached Environment expires as at its deadline. Core confirms the original creation is settled and all owned compute and snapshots are cleaned up before releasing allocation and placement ownership. The old device's authority is revoked before replacement admission. Until Create, Kill or DeleteSnapshot has a confirmed outcome, it keeps ownership and admits no replacement writer. A recoverable Session and Environment remain disconnected with the same filesystem object and initialization result. Archive, reset and deletion take precedence and cannot be undone by a wake request. diff --git a/docs/zh/sandbox-provider.md b/docs/zh/sandbox-provider.md index 7d39fc087..96845ce7d 100644 --- a/docs/zh/sandbox-provider.md +++ b/docs/zh/sandbox-provider.md @@ -1,7 +1,7 @@ --- title: "沙箱提供商协议" source: docs/sandbox-provider.md -source_hash: b68b8050088b7f46038b70af6c4db4a70cf9560846aae838512ce691b41a0ff2 +source_hash: 0bb4f94a1520a4f03901d206b9f60458ea8725f52b960dcfd31861325d81f83e --- 沙箱提供商协议连接 Core 与为 Core 管理的(`openai_hosted`)Environment 提供计算资源的 adapter,并由该 adapter 运行启动 Runtime daemon 的有界引导流程。本页面向 adapter 作者:说明提供商必须实现什么,以及可以依赖 Core 做什么。microsandbox、E2B 和 OpenSandbox 实现了该协议。 @@ -289,7 +289,7 @@ E2B adapter 的 checkpoint 行为见 [E2B provider 指南](https://github.com/Mi 不可用的检查点(恢复中的 allocation 的 `Resume` 报告 `ErrCheckpointUnusable`)会立即结束保留,如同其 deadline 已到。Core 通过正常的到期清理删除 target 与快照;符合条件的 Session 按下文继续,不符合条件的则如正常到期一样结束。 -仅当等待中的放置需求被节点已满的 retained slot 阻塞时,容量压力才会提前结束保留期,且只针对该节点上最早挂起、空闲并满足上述条件的 allocation。Core 将其视为已过期,每个被阻塞的需求只释放一个;其他已挂起的 allocation 在配置的期限到达前保留快照并继续占用 retained slot。恢复时缺少容量或兼容节点的检查点不会因此被冷替换。等待请求保持原有的输入期限。常规 expiry cleanup 必须确认资源已删除,其他 allocation 才能预留该 slot;文件与已验证的原生历史继续保留。 +仅当等待中的放置需求被节点已满的 retained slot 阻塞时,容量压力才会提前结束保留期,且只针对该节点上最早挂起、空闲,并满足上述条件或属于未挂载 Session 的独立 Environment 的 allocation。Core 将其视为已过期,每个被阻塞的需求只释放一个;其他已挂起的 allocation 在配置的期限到达前保留快照并继续占用 retained slot。恢复时缺少容量或兼容节点的检查点不会因此被冷替换。等待请求保持原有的输入期限。常规 expiry cleanup 必须确认资源已删除,其他 allocation 才能预留该 slot;已验证的 Session 保留文件与原生历史,未挂载的 Environment 则像到期时一样过期。 Core 在释放 allocation 和 placement 归属前,确认原始创建已结算且所属计算资源与快照均已清理。替代计算资源准入前撤销旧 device 的权限。Create、Kill 或 DeleteSnapshot 的结果尚未确认时,Core 保留归属,不准入替代写入方。可恢复的 Session 与 Environment 保持断连,保留同一文件系统对象和初始化结果。归档、重置和删除优先,唤醒请求不能撤销它们。 diff --git a/services/core/internal/db/queries/runtime_allocations.sql b/services/core/internal/db/queries/runtime_allocations.sql index c7f425eaa..cffafe02f 100644 --- a/services/core/internal/db/queries/runtime_allocations.sql +++ b/services/core/internal/db/queries/runtime_allocations.sql @@ -72,3 +72,13 @@ SELECT EXISTS ( AND d.supported_agent_kinds @> jsonb_build_array(jsonb_build_object( 'kind', s.engine, 'available', true, 'capabilities', jsonb_build_object('retained_native_history', true))) )::boolean; + +-- name: IsUnattachedRuntimeEnvironment :one +-- An active Environment without a live Session is an unattached standalone one: +-- a Session-owned Environment has ended once its Session is gone. +SELECT EXISTS ( + SELECT 1 FROM runtime_allocations a + JOIN environment_liveness l ON l.id = a.environment_id AND l.active + WHERE a.id = $1 + AND NOT EXISTS (SELECT 1 FROM sessions s WHERE s.environment_id = a.environment_id AND s.deleted_at IS NULL) +)::boolean; diff --git a/services/core/internal/db/sqlc/runtime_allocations.sql.go b/services/core/internal/db/sqlc/runtime_allocations.sql.go index 29593c071..ec683459a 100644 --- a/services/core/internal/db/sqlc/runtime_allocations.sql.go +++ b/services/core/internal/db/sqlc/runtime_allocations.sql.go @@ -151,6 +151,24 @@ func (q *Queries) GetLatestRuntimeAllocation(ctx context.Context, arg GetLatestR return i, err } +const isUnattachedRuntimeEnvironment = `-- name: IsUnattachedRuntimeEnvironment :one +SELECT EXISTS ( + SELECT 1 FROM runtime_allocations a + JOIN environment_liveness l ON l.id = a.environment_id AND l.active + WHERE a.id = $1 + AND NOT EXISTS (SELECT 1 FROM sessions s WHERE s.environment_id = a.environment_id AND s.deleted_at IS NULL) +)::boolean +` + +// An active Environment without a live Session is an unattached standalone one: +// a Session-owned Environment has ended once its Session is gone. +func (q *Queries) IsUnattachedRuntimeEnvironment(ctx context.Context, id pgtype.UUID) (bool, error) { + row := q.db.QueryRow(ctx, isUnattachedRuntimeEnvironment, id) + var column_1 bool + err := row.Scan(&column_1) + return column_1, err +} + const listRuntimeAllocations = `-- name: ListRuntimeAllocations :many SELECT a.id, a.environment_id, a.device_id, a.provider_key, a.state, a.create_settled, a.created_at, a.released_at, a.compute_phase, a.compute_revision, a.compute_state, a.compute_activity_at, a.compute_wake_requested, a.compute_retained_until, a.node_id, a.observation_error, a.compute_phase_changed_at, a.deployment_generation, s.id AS attached_session_id, e.tenant_id, l.ended::boolean AS ended, (a.compute_phase NOT IN ('disabled', 'running') AND a.compute_retained_until IS NOT NULL AND a.compute_retained_until <= clock_timestamp())::boolean AS expired FROM runtime_allocations a diff --git a/services/core/internal/deployment/allocations_test.go b/services/core/internal/deployment/allocations_test.go index 331b5db15..9dee7a540 100644 --- a/services/core/internal/deployment/allocations_test.go +++ b/services/core/internal/deployment/allocations_test.go @@ -742,6 +742,8 @@ func (f *fakeAllocationTx) CanRetainEnvironment(current Allocation) (bool, error return f.canRetainEnvironment(current) } +func (f *fakeAllocationTx) CanReleaseCheckpoint(Allocation) (bool, error) { return false, nil } + func TestBeginRestoreDirectKeepsAllocationAndRetention(t *testing.T) { until := time.Now().Add(time.Hour) owner := Allocation{ID: uuid.NewString(), DeviceID: uuid.NewString(), TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), ProviderKey: uuid.NewString(), State: "running", ComputePhase: "suspended", ComputeRetainedUntil: &until} diff --git a/services/core/internal/deployment/storage.go b/services/core/internal/deployment/storage.go index d9633453d..fc956bb22 100644 --- a/services/core/internal/deployment/storage.go +++ b/services/core/internal/deployment/storage.go @@ -119,6 +119,10 @@ type AllocationTx interface { // CanRetainEnvironment checks the ready retained filesystem, completed // initialization and selected Harness's qualified history under the Session lock. CanRetainEnvironment(current Allocation) (bool, error) + // CanReleaseCheckpoint checks under the Environment lock that capacity + // pressure may end the allocation's retention: CanRetainEnvironment holds, + // or no Session is attached and the standalone Environment ends with it. + CanReleaseCheckpoint(current Allocation) (bool, error) // LoadAllocation returns the Environment's allocation. LoadAllocation() (Allocation, error) @@ -130,7 +134,7 @@ type AllocationTx interface { // LoadSuspensionDemand locks the deployment and reads demand and capacity. LoadSuspensionDemand(current Allocation) (SuspensionDemand, error) // OldestIdleCheckpoint reports whether current has the earliest retention - // deadline among its node's idle suspended allocations that can be retained. + // deadline among its node's idle suspended allocations that can be released. OldestIdleCheckpoint(current Allocation) (bool, error) // ExpireCheckpoint ends the retention of a suspended or restoring allocation now. ExpireCheckpoint(current Allocation) (Allocation, error) diff --git a/services/core/internal/deployment/suspension_pressure.go b/services/core/internal/deployment/suspension_pressure.go index 7e5920e7c..3e4b4c29b 100644 --- a/services/core/internal/deployment/suspension_pressure.go +++ b/services/core/internal/deployment/suspension_pressure.go @@ -98,7 +98,7 @@ func (e *ExecutionOperations) canSuspendForDemand(tx AllocationTx, pressure Susp if source.Retained >= int64(source.MaxRetained) { // A full node frees a retained slot only by releasing a checkpoint // after this suspension, so the owner must itself be releasable. - if retain, err := tx.CanRetainEnvironment(owner); err != nil || !retain { + if releasable, err := tx.CanReleaseCheckpoint(owner); err != nil || !releasable { return false, err } release = func(n *placement.Node) { n.Active--; n.Retained-- } @@ -109,9 +109,10 @@ func (e *ExecutionOperations) canSuspendForDemand(tx AllocationTx, pressure Susp // ExpireCheckpointForDemand ends the retention of an idle suspended // allocation, exactly as if its deadline had passed, when compatible waiting // demand is blocked only by the node's retained slots. It acts only on the -// node's oldest allocation whose Session can continue on new compute, so the -// ordinary expiry cleanup keeps files and native history. A refusal is -// ErrNotIdle. +// node's oldest releasable allocation: one whose Session continues on new +// compute, where the ordinary expiry cleanup keeps files and native history, +// or one with no Session attached, whose standalone Environment then ends. A +// refusal is ErrNotIdle. func (e *ExecutionOperations) ExpireCheckpointForDemand(ctx context.Context, owner Allocation) error { _, err := e.change(ctx, owner, true, func(tx AllocationTx, current Allocation) (Allocation, error) { if current.State != "running" || current.ComputePhase != "suspended" || current.Expired || current.ComputeRevision != owner.ComputeRevision || current.NodeID == "" { @@ -124,11 +125,11 @@ func (e *ExecutionOperations) ExpireCheckpointForDemand(ctx context.Context, own if activity.Busy || activity.WakeRequested { return Allocation{}, ErrNotIdle } - retain, err := tx.CanRetainEnvironment(current) + releasable, err := tx.CanReleaseCheckpoint(current) if err != nil { return Allocation{}, err } - if !retain { + if !releasable { return Allocation{}, ErrNotIdle } oldest, err := tx.OldestIdleCheckpoint(current) diff --git a/services/core/internal/persistence/postgres/deploymentpg/retention_pressure_test.go b/services/core/internal/persistence/postgres/deploymentpg/retention_pressure_test.go index 33ab1b24c..66337fde4 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/retention_pressure_test.go +++ b/services/core/internal/persistence/postgres/deploymentpg/retention_pressure_test.go @@ -84,6 +84,38 @@ func TestRetentionPressureReleasesOldestRetainableCheckpoint(t *testing.T) { } } +// detachEnvironment turns $1's Environment into an unattached standalone one. +const detachEnvironment = `WITH a AS (SELECT environment_id FROM runtime_allocations WHERE id=$1), + e AS (UPDATE environments SET session_owned=false,idempotency_key='key',request_hash=repeat('0',64),creator_kind='user',creator_id=gen_random_uuid() WHERE id=(SELECT environment_id FROM a)) + UPDATE sessions SET deleted_at=clock_timestamp() WHERE environment_id=(SELECT environment_id FROM a)` + +func TestRetentionPressureExpiresIdleUnattachedEnvironment(t *testing.T) { + f := newFixture(t) + changes, _ := f.execution(t) + installation, view := f.initialize(t, changes, sandbox.Selection{Provider: "microsandbox", DeploymentSpec: testSpecification("microsandbox")}) + node := f.enroll(t, view, deployment.Capacity{MaxActive: 1, MaxRetained: 1}) + f.connect(t, node.NodeID) + owner := qualifyPressureRetention(t, f, runningPressureOwner(t, f, changes, installation, node.NodeID, view.Generation)) + if _, err := f.pool.Exec(t.Context(), detachEnvironment, owner.ID); err != nil { + t.Fatal(err) + } + pendingPressureDemand(t, f) + if err := changes.ExpireCheckpointForDemand(t.Context(), owner); err != nil { + t.Fatal(err) + } + current, err := f.adapter.EnvironmentAllocation(t.Context(), owner.Key()) + if err != nil || !current.Expired { + t.Fatal("checkpoint not expired", current, err) + } + if _, err = changes.RequestCleanup(t.Context(), current); err != nil { + t.Fatal(err) + } + var status string + if err = f.pool.QueryRow(t.Context(), `SELECT status FROM environments WHERE id=$1`, owner.EnvironmentID).Scan(&status); err != nil || status != "expired" { + t.Fatal("unattached Environment not expired", status, err) + } +} + func TestRetentionPressureKeepsIneligibleCheckpoints(t *testing.T) { for _, test := range []struct { name string @@ -130,6 +162,7 @@ func TestPressureSuspendsReleasableOwnerWhenRetainedSlotsAreFull(t *testing.T) { }{ {"releasable", "", true}, {"no retained native history", `UPDATE devices SET supported_agent_kinds='[]' WHERE id=(SELECT device_id FROM runtime_allocations WHERE id=$1)`, false}, + {"unattached", detachEnvironment, true}, } { t.Run(test.name, func(t *testing.T) { f := newFixture(t) diff --git a/services/core/internal/persistence/postgres/deploymentpg/suspension_pressure.go b/services/core/internal/persistence/postgres/deploymentpg/suspension_pressure.go index c47d30825..f8124d5e5 100644 --- a/services/core/internal/persistence/postgres/deploymentpg/suspension_pressure.go +++ b/services/core/internal/persistence/postgres/deploymentpg/suspension_pressure.go @@ -81,15 +81,32 @@ func (t *allocationTx) OldestIdleCheckpoint(current deployment.Allocation) (bool if uuidString(id) == current.ID { return true, nil } - // An older peer that can be retained is released first. Its own Session + // An older peer that can be released is released first. Its own Session // lock rechecks this when it is chosen. - if retained, err := t.q.CanRetainRuntimeEnvironment(t.ctx, id); err != nil || retained { + if releasable, err := canReleaseCheckpoint(t.ctx, t.q, id); err != nil || releasable { return false, err } } return false, nil } +func (t *allocationTx) CanReleaseCheckpoint(current deployment.Allocation) (bool, error) { + id, err := parseID(current.ID) + if err != nil { + return false, err + } + return canReleaseCheckpoint(t.ctx, t.q, id) +} + +// canReleaseCheckpoint holds when the Session continues on new compute or no +// Session is attached, so the standalone Environment ends with the checkpoint. +func canReleaseCheckpoint(ctx context.Context, q *sqlc.Queries, id pgtype.UUID) (bool, error) { + if unattached, err := q.IsUnattachedRuntimeEnvironment(ctx, id); err != nil || unattached { + return unattached, err + } + return q.CanRetainRuntimeEnvironment(ctx, id) +} + func (t *allocationTx) ExpireCheckpoint(current deployment.Allocation) (deployment.Allocation, error) { return t.change(current, func(ctx context.Context, id pgtype.UUID) (sqlc.RuntimeAllocation, error) { return t.q.ExpireRuntimeCheckpoint(ctx, sqlc.ExpireRuntimeCheckpointParams{ID: id, ComputeRevision: current.ComputeRevision})