Skip to content

Commit b412855

Browse files
authored
fix(sandbox): skip identical skill installs and prune old snapshots (#2791)
* fix(sandbox): skip identical skill installs and prune old snapshots Re-uploading the same archive of a ready skill was still booting a billed sandbox and growing a new generation. Superceded snapshots also accumulated on the provider because the ledger never deleted them. Skip the rebuild when the digest already matches a skill still in the live image, and let the reaper delete superseded provider snapshots after 24h while leaving extras not in the ledger untouched. * fix(sandbox): retry stale same-zip installs and prune active leftovers A re-upload of the same archive skipped every installing row, so a dead process could not recover until the reaper. Skip only recent in-flight runs, still refresh the stored bundle, and prune non-live active snapshots after retention. * fix(sandbox): beat install liveness and guard the snapshot prune A fixed window on the submission time cannot tell a slow install from a dead one: a single agent command may take installCommandTimeout and an install runs several of them, so a two-minute window called live runs dead, reset their progress and pushed the reaper's own deadline out. Stamp InstallingSince while the run works and read that instead, which also stops the reaper from failing installs that are merely long. Answer the skip question from the image only for a ready row. The ledger records which skill a snapshot carries, not which archive, so a row installing a new bundle would look finished from the previous bundle's snapshot and report a success that never happened. Prune now refuses to delete once the credentials no longer address the account that built the image: the delete would come back not-found, and the ledger would record another account's still-billed snapshots as gone. It also waits for an eligible row before building a provider client, since most configs have nothing to prune on most sweeps.
1 parent 01ec6c0 commit b412855

8 files changed

Lines changed: 1062 additions & 44 deletions

File tree

internal/application/service/tenant_skill_bundle.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ type SkillBundle struct {
3636
Description string
3737
Instructions string
3838
// SHA256 is over the uploaded bytes, so re-uploading the same archive is
39-
// recognisable in the UI and in the ledger.
39+
// recognisable in the UI and in the ledger, and a ready skill with this
40+
// digest can skip a billed snapshot rebuild.
4041
SHA256 string
4142
// Files maps skill-root-relative paths to contents, SKILL.md included.
4243
Files map[string][]byte

internal/application/service/tenant_skill_install.go

Lines changed: 161 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"path"
99
"sort"
1010
"strings"
11+
"sync"
1112
"time"
1213

1314
"github.com/google/uuid"
@@ -63,6 +64,12 @@ func (s *TenantSkillService) InstallSkill(
6364
if err != nil {
6465
return "", err
6566
}
67+
if s.canSkipInstall(ctx, existing, bundle) {
68+
if err := s.refreshSkippedBundle(ctx, existing, archive); err != nil {
69+
return "", fmt.Errorf("store bundle for skill %s: %w", existing.ID, err)
70+
}
71+
return existing.ID, nil
72+
}
6673

6774
skillID := uuid.NewString()
6875
now := s.now()
@@ -201,6 +208,14 @@ func (s *TenantSkillService) runInstall(
201208
return nil
202209
}
203210

211+
// From here on this run is the row's owner, and everything below can take
212+
// minutes. The heartbeat is what tells a second upload of the same archive
213+
// (and the reaper) that those minutes are work rather than a dead process.
214+
// It is deferred before it is stopped explicitly below, so a failure path
215+
// still stops it ahead of the deferred failSkill.
216+
stopHeartbeat := s.startInstallHeartbeat(ctx, tenantID, configID, skillID)
217+
defer stopHeartbeat()
218+
204219
// The name comes from SKILL.md and is already validated on parse, so a
205220
// rejection here means the bundle was accepted by a looser rule than the
206221
// one the image path enforces. Failing before any sandbox work keeps that
@@ -349,6 +364,10 @@ func (s *TenantSkillService) runInstall(
349364
return err
350365
}
351366
pointerSwitched = true
367+
// The heartbeat writes the whole row, so it must be gone before the
368+
// terminal "ready" write below: a beat landing after it would put the row
369+
// back to installing and have the reaper fail a skill that is serving.
370+
stopHeartbeat()
352371
s.markPreviousSnapshotsSuperseded(ctx, tenantID, configID, installRowID)
353372

354373
// The terminal write is the one that must not be best-effort: the pointer
@@ -873,9 +892,10 @@ func (s *TenantSkillService) failSkill(
873892
// installStillOwnsTheRow is the lock-side counterpart of InstallSkill's
874893
// optimistic row write. A remove that ran first deleted the row; a newer
875894
// upload of the same name replaced BundleSHA256; a queued remove flipped the
876-
// status. Any of those means this run must not snapshot — failSkill would
877-
// stamp the newer owner's row, and a snapshot with no matching row is an
878-
// orphan the ledger cannot name.
895+
// status; a sibling retry of the same archive found the first run had already
896+
// landed in the live image. Any of those means this run must not snapshot —
897+
// failSkill would stamp the newer owner's row, and a snapshot with no matching
898+
// row is an orphan the ledger cannot name.
879899
func (s *TenantSkillService) installStillOwnsTheRow(
880900
ctx context.Context, tenantID uint64, configID, skillID string, bundle *SkillBundle,
881901
) (bool, error) {
@@ -892,9 +912,147 @@ func (s *TenantSkillService) installStillOwnsTheRow(
892912
if bundle != nil && current.BundleSHA256 != "" && current.BundleSHA256 != bundle.SHA256 {
893913
return false, nil
894914
}
915+
if current.Status == types.SkillStatusReady {
916+
_, inImage, ok := s.skillFilesInLiveImage(ctx, current)
917+
if ok && inImage {
918+
return false, nil
919+
}
920+
}
895921
return true, nil
896922
}
897923

924+
// canSkipInstall reports whether this upload is a no-op. Re-uploading the
925+
// exact archive of a skill that is already ready (and still in the live image)
926+
// must not boot a billed sandbox or grow a new snapshot. An install of the
927+
// same bytes that is still beating is the same situation: the first run owns
928+
// the work.
929+
//
930+
// Only a ready row is answered from the image. An installing row is answered
931+
// from the heartbeat alone, deliberately: the ledger records which skill an
932+
// install snapshotted, not which archive, so a row that is installing bundle
933+
// B while the image still carries the earlier bundle A would look "already
934+
// installed" and this upload would report a success that never happened.
935+
//
936+
// A failed skill with the same digest is a retry: the previous attempt never
937+
// made it into the image. A removal in flight is not a skip either — taking
938+
// the row back to installing is how an upload cancels it.
939+
func (s *TenantSkillService) canSkipInstall(
940+
ctx context.Context, existing *types.TenantSkillEntity, bundle *SkillBundle,
941+
) bool {
942+
if existing == nil || bundle == nil {
943+
return false
944+
}
945+
if existing.BundleSHA256 == "" || existing.BundleSHA256 != bundle.SHA256 {
946+
return false
947+
}
948+
switch existing.Status {
949+
case types.SkillStatusInstalling:
950+
return s.installIsInFlight(existing)
951+
case types.SkillStatusReady:
952+
_, inImage, ok := s.skillFilesInLiveImage(ctx, existing)
953+
return ok && inImage
954+
default:
955+
return false
956+
}
957+
}
958+
959+
// installIsInFlight reports whether an installing row still belongs to a live
960+
// process. The answer is the heartbeat: a running install restamps
961+
// InstallingSince every skillInstallHeartbeatInterval, so silence past
962+
// skillInstallInFlightSkip means the process is gone and the next upload must
963+
// be allowed to start a new run rather than wait for the stuck-run reaper.
964+
//
965+
// Reading the submission time instead would force a choice between calling a
966+
// slow install dead — a single agent command may take installCommandTimeout,
967+
// and an install runs several — and leaving a dead one unrecoverable.
968+
func (s *TenantSkillService) installIsInFlight(existing *types.TenantSkillEntity) bool {
969+
if existing == nil || existing.InstallingSince == nil {
970+
return false
971+
}
972+
return !existing.InstallingSince.Before(s.clock()().Add(-skillInstallInFlightSkip))
973+
}
974+
975+
// startInstallHeartbeat keeps this run's liveness visible while it works, and
976+
// returns the stop function that must be called before any terminal write.
977+
//
978+
// The heartbeat writes the whole row, so it would otherwise race the "ready"
979+
// write past the pointer switch and revive an installing status. Both callers
980+
// stop it before that point: runInstall stops it the moment the pointer moves,
981+
// and the deferred stop runs before the deferred failSkill.
982+
func (s *TenantSkillService) startInstallHeartbeat(
983+
ctx context.Context, tenantID uint64, configID, skillID string,
984+
) func() {
985+
interval := s.installHeartbeat
986+
if interval <= 0 {
987+
interval = skillInstallHeartbeatInterval
988+
}
989+
beatCtx, stop := context.WithCancel(ctx)
990+
done := make(chan struct{})
991+
go func() {
992+
defer close(done)
993+
ticker := time.NewTicker(interval)
994+
defer ticker.Stop()
995+
for {
996+
select {
997+
case <-beatCtx.Done():
998+
return
999+
case <-ticker.C:
1000+
s.beatInstallHeartbeat(beatCtx, tenantID, configID, skillID)
1001+
}
1002+
}
1003+
}()
1004+
var once sync.Once
1005+
return func() {
1006+
once.Do(func() {
1007+
stop()
1008+
<-done
1009+
})
1010+
}
1011+
}
1012+
1013+
// beatInstallHeartbeat stamps InstallingSince for a row this run still owns.
1014+
// A row that has left the installing status belongs to a newer upload, a
1015+
// queued removal, or a finished run, and reviving its timestamp would hide
1016+
// one of those from the reaper.
1017+
func (s *TenantSkillService) beatInstallHeartbeat(
1018+
ctx context.Context, tenantID uint64, configID, skillID string,
1019+
) {
1020+
current, err := s.skills.GetSkill(ctx, tenantID, configID, skillID)
1021+
if err != nil {
1022+
logger.Warnf(ctx, "[skill] load %s for install heartbeat failed: %v", skillID, err)
1023+
return
1024+
}
1025+
if current == nil || current.Status != types.SkillStatusInstalling {
1026+
return
1027+
}
1028+
at := s.clock()()
1029+
current.InstallingSince = &at
1030+
if err := s.skills.UpdateSkill(ctx, current); err != nil {
1031+
logger.Warnf(ctx, "[skill] install heartbeat for %s failed: %v", skillID, err)
1032+
}
1033+
}
1034+
1035+
// refreshSkippedBundle stores the uploaded archive even when the image work
1036+
// is skipped. read_skill serves file contents from it, so a re-upload of a
1037+
// ready skill is how a missing object-store blob gets repaired without
1038+
// growing a new snapshot. A failure here is returned to the caller rather
1039+
// than turning the ready row into a failed install.
1040+
func (s *TenantSkillService) refreshSkippedBundle(
1041+
ctx context.Context, existing *types.TenantSkillEntity, archive []byte,
1042+
) error {
1043+
if existing == nil {
1044+
return nil
1045+
}
1046+
ref, err := s.saveBundle(ctx, existing.TenantID, existing.ID, archive)
1047+
if err != nil {
1048+
return err
1049+
}
1050+
return s.updateSkillFields(ctx, existing.TenantID, existing.SandboxConfigID, existing.ID,
1051+
func(e *types.TenantSkillEntity) {
1052+
e.BundleRef = ref
1053+
})
1054+
}
1055+
8981056
// startMaintenanceSession opens the session one image operation runs in. The
8991057
// operation name is carried into the session because the transcript is kept
9001058
// deliberately, for troubleshooting: filing a removal's under "Skill install"

0 commit comments

Comments
 (0)