Tencent/WeKnora · error
scan sandbox bindings
Error message
scan sandbox bindings: %w
What it means
Returned by listTenantBindingKeys (used by InvalidateByConfig) when client.Scan fails while paging through the workspace's binding keys with MATCH pattern and COUNT 200. SCAN errors are rare but occur on connection loss, timeouts, or cluster misrouting. The invalidation pass aborts so no bindings get marked stale. Wrapped as 'scan sandbox bindings: %w'.
Solutions
- Retry InvalidateByConfig — SCAN with cursor is safe to restart from scratch
- Raise the context timeout so a long SCAN across many keys completes
- Check node health/logs if on Redis Cluster; remember SCAN covers only the node reached — route to master or scan per node
- Verify network stability between app and Redis (look for pooled-conn reset logs)
Example fix
// before
n, err := store.InvalidateByConfig(ctx, tenantID, configID)
if err != nil { return err }
// after: retry with fresh context
var n int
for attempt := 0; attempt < 3; attempt++ {
scanCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
n, err = store.InvalidateByConfig(scanCtx, tenantID, configID)
cancel()
if err == nil { break }
}
if err != nil { return err } Defensive patterns
Strategy: retry
Validate before calling
// pre-check Redis health and give the scan a generous deadline
scanCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := rdb.Ping(scanCtx).Err(); err != nil {
return fmt.Errorf("skip invalidation, redis down: %w", err)
} Type guard
func isScanError(err error) bool {
return err != nil && strings.Contains(err.Error(), "scan sandbox bindings")
} Try / catch
n, err := store.InvalidateByConfig(ctx, tenantID, configID)
if isScanError(err) {
// restart from scratch: SCAN is stateless, safe to retry
n, err = store.InvalidateByConfig(ctx, tenantID, configID)
}
if err != nil { return err } Prevention
- Size the context timeout to the tenant's key count, not the default request timeout
- Avoid Redis Cluster for this store, or scan each master node individually (documented single-node limitation)
- Run InvalidateByConfig from an async job with retries, not inline in a request handler
- Keep namespaces free of glob characters — the store escapes them, but simpler namespaces are safer
When it happens
Trigger: Calling InvalidateByConfig(ctx, tenantID, configID) when: the Redis connection drops mid-scan; ctx deadline exceeded over many batches; Redis Cluster where the node serving the SCAN errors; OOM/auth errors on the server.
Common situations: Very large keyspaces extending the scan past a request timeout; cluster mode where SCAN hits a single node (documented limitation — plus errors when that node fails); transient network blip during config invalidation after rotating an API key.
Related errors
- create sandbox binding
- delete sandbox binding
- get sandbox binding
- begin sandbox turn lease
- decode sandbox binding
AI-assisted analysis of Tencent/WeKnora@988cbb0330 (2026-09-02).
Data as JSON: /api/errors/9f945f87f2ab1548.
Report an issue: GitHub.
Appendix: source
Thrown at internal/sandbox/session_binding_redis.go:241
// A single-node Redis (what the container wires) answers this completely. On a
// Redis Cluster, SCAN reaches one node, so bindings living on the others would
// go unmarked and their sessions would keep the previous image until they end.
func (s *RedisSessionSandboxBindingStore) listTenantBindingKeys(
ctx context.Context,
tenantID uint64,
) ([]SessionSandboxKey, error) {
prefix := fmt.Sprintf(
"weknora:sandbox:session:{%s:%d:", s.namespace, tenantID,
)
const suffix = "}:binding"
pattern := escapeRedisGlob(prefix) + "*" + suffix
var keys []SessionSandboxKey
var cursor uint64
for {
batch, next, err := s.client.Scan(ctx, cursor, pattern, redisBindingScanCount).Result()
if err != nil {
return nil, fmt.Errorf("scan sandbox bindings: %w", err)
}
for _, raw := range batch {
sessionID := strings.TrimSuffix(strings.TrimPrefix(raw, prefix), suffix)
key := SessionSandboxKey{TenantID: tenantID, SessionID: sessionID}
if key.Validate() != nil {
continue
}
keys = append(keys, key)
}
if next == 0 {
return keys, nil
}
cursor = next
}
}
// markBindingStale writes the marked binding back only while the stored one
// still names the same sandbox.View on GitHub (pinned to 988cbb0330)