{"record":{"id":"34c3ad2ec11e50a3","repo":"Tencent/WeKnora","slug":"end-sandbox-turn-lease-w","errorCode":null,"errorMessage":"end sandbox turn lease: %w","messagePattern":"end sandbox turn lease: %w","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"internal/sandbox/session_binding_redis.go","lineNumber":335,"sourceCode":"\t\tttlMS = (30 * time.Minute).Milliseconds()\n\t}\n\tif err := beginTurnScript.Run(ctx, s.client, []string{s.turnKey(key)}, ttlMS).Err(); err != nil {\n\t\treturn fmt.Errorf(\"begin sandbox turn lease: %w\", err)\n\t}\n\treturn nil\n}\n\n// EndTurn releases one chat-turn lease. The last release drops the lease so\n// a later resolve may rebuild a stale sandbox immediately.\nfunc (s *RedisSessionSandboxBindingStore) EndTurn(\n\tctx context.Context,\n\tkey SessionSandboxKey,\n) error {\n\tif err := key.Validate(); err != nil {\n\t\treturn err\n\t}\n\tif err := endTurnScript.Run(ctx, s.client, []string{s.turnKey(key)}).Err(); err != nil {\n\t\treturn fmt.Errorf(\"end sandbox turn lease: %w\", err)\n\t}\n\treturn nil\n}\n\n// TurnState reports whether a chat turn is open and whether its first\n// resolve may still rebuild a stale sandbox.\nfunc (s *RedisSessionSandboxBindingStore) TurnState(\n\tctx context.Context,\n\tkey SessionSandboxKey,\n) (bool, bool, error) {\n\tif err := key.Validate(); err != nil {\n\t\treturn false, false, err\n\t}\n\tvalues, err := s.client.HGetAll(ctx, s.turnKey(key)).Result()\n\tif err != nil {\n\t\treturn false, false, fmt.Errorf(\"read sandbox turn lease: %w\", err)\n\t}\n\tif len(values) == 0 {","sourceCodeStart":317,"sourceCodeEnd":353,"githubUrl":"https://github.com/Tencent/WeKnora/blob/988cbb03305e055d8ebb7d46d9ac6cc0803cd074/internal/sandbox/session_binding_redis.go#L317-L353","documentation":"Returned by RedisSessionSandboxBindingStore.EndTurn when the endTurnScript (HINCRBY refs then DEL at zero) fails to run — same transport/server failure class as BeginTurn. The turn lease is not released; it will linger until the 30-minute TTL expires, temporarily blocking stale-sandbox rebuilds. Wrapped as 'end sandbox turn lease: %w'.","triggerScenarios":"Calling EndTurn(ctx, key) at chat-turn end when Redis is unreachable, ctx cancelled after a long turn, connection dropped, or the server rejects the EVAL (OOM/READONLY/NOAUTH).","commonSituations":"Turn processing exceeded its request timeout so the context died before cleanup; Redis blip at the exact end of a long turn; process killed between BeginTurn and EndTurn leaving a leaked lease (bounded by sessionTurnLeaseTTL = 30m).","solutions":["Release the lease with a fresh context (background/deadline-extended) rather than the request ctx — cleanup must survive the request's lifecycle","Retry EndTurn; it is idempotent (script no-ops when the key is gone)","Accept the bounded impact if one release is lost: the 30-minute TTL self-heals leaked leases","If leaks are frequent, add a watchdog that calls EndTurn on turn completion via defer and a detached context"],"exampleFix":"// before\nif err := store.EndTurn(ctx, key); err != nil { return err } // request ctx may be dead by now\n// after: cleanup with a detached context so the lease is always released\nfunc handleTurn(key sandbox.SessionSandboxKey, done func() error) error {\n    if err := done(); err != nil { return err }\n    endCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n    defer cancel()\n    if err := store.EndTurn(endCtx, key); err != nil {\n        return fmt.Errorf(\"release turn (lease expires in ≤30m): %w\", err)\n    }\n    return nil\n}","handlingStrategy":"try-catch","validationCode":"// no meaningful pre-check: the failure mode is transport-level mid-cleanup.\n// Instead, guarantee the call happens even on panic:\ndefer func() {\n    ectx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\n    defer cancel()\n    _ = store.EndTurn(ectx, key) // best-effort; TTL bounds any leak at 30m\n}()","typeGuard":"func isEndTurnError(err error) bool {\n    return err != nil && strings.Contains(err.Error(), \"end sandbox turn lease\")\n}","tryCatchPattern":"endCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\ndefer cancel()\nif err := store.EndTurn(endCtx, key); err != nil {\n    if isEndTurnError(err) {\n        // non-fatal: the 30-minute lease TTL self-heals; log for ops\n        log.Printf(\"turn lease for %s will expire via TTL: %v\", key.SessionID, err)\n    } else {\n        return err\n    }\n}","preventionTips":["Never use the request context for EndTurn — turn cleanup outlives the request","Always EndTurn in a defer so early returns and panics still release the lease","Remember the refcount model: one EndTurn per successful BeginTurn (extras are no-ops)","Alert on turn-lease keys persisting near the 30m TTL — a sign of frequent release failures or crashes"],"tags":["redis","lua-script","lease-cleanup","go"],"backgroundTag":"turn-lease-release-failed","analyzedSha":"988cbb03305e055d8ebb7d46d9ac6cc0803cd074","analyzedAt":"2026-09-02T14:41:08.344Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-08T10:18:20.063Z"}