wsstate

package
v0.5.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 23, 2026 License: AGPL-3.0 Imports: 10 Imported by: 0

Documentation

Overview

Package wsstate holds the per-workspace state that ProxyHandler previously kept in process-local maps. Externalizing it to a Store abstraction is the foundation for sharing the state across replicas via a Redis backend in subsequent stories — eliminating the per-replica drift that caused the 2026-06-16 stuck-session class of bugs.

All Store methods MUST be safe for concurrent use. Callers may invoke them from any goroutine (request handlers, watcher callbacks, background timers).

connCount (HTTP connections per workspace) is intentionally NOT in this interface — it represents a per-replica resource (file descriptors, memory) and must remain local even after the Redis migration.

Index

Constants

View Source
const DefaultActiveSessTTL = 30 * time.Minute

DefaultActiveSessTTL is the auto-recovery TTL for stuck active-session entries. If a session is added but never removed (process crash, network partition), the entry expires after this duration so the workspace doesn't stay stuck — the multi-replica fix for the 2026-06-16 incident. 30 minutes matches the design spec.

View Source
const DefaultBackfilledTTL = 24 * time.Hour

DefaultBackfilledTTL is the TTL for the parent-backfill marker. 24 hours — backfill is idempotent, so re-running after TTL expiry is safe.

View Source
const DefaultConfigTTL = 5 * time.Minute

DefaultConfigTTL is the TTL for cached workspace config. Shorter than password TTL because config (MaxActiveSessions, AutoApprovePermissions) can change via CRD updates. 5 minutes matches the design spec.

View Source
const DefaultDeletedTTL = 30 * time.Minute

DefaultDeletedTTL is the TTL for per-session tombstones. Each tombstone expires independently (per-key TTL, not a shared SET TTL). After expiry, late SSE events for that session are no longer suppressed — but by then the session has been gone long enough that any late event is extremely unlikely. 30 minutes matches the design spec and is the same duration as activeSess TTL.

View Source
const DefaultPasswordTTL = 1 * time.Hour

DefaultPasswordTTL is the TTL for cached workspace passwords. Passwords are stable (only change on workspace recreate), so the TTL can be longer than for active sessions or tombstones. 1 hour matches the design spec. After TTL expiry, the next request re-fetches from K8s (password may have rotated).

View Source
const DefaultPriorPhaseTTL = 24 * time.Hour

DefaultPriorPhaseTTL is the TTL for prior-phase tracking. 24 hours — long enough to survive API replica restarts and watch reconnects.

Variables

This section is empty.

Functions

This section is empty.

Types

type Config

type Config struct {
	MaxActiveSessions      int
	AutoApprovePermissions bool
}

Config is the cached view of a workspace's spec-derived configuration (formerly ProxyHandler.workspaceConfig). It is populated from the Workspace CRD on first access and invalidated on phase transitions.

type InMemoryStore

type InMemoryStore struct {
	// contains filtered or unexported fields
}

InMemoryStore is the process-local implementation of Store. It is the ONLY implementation in this story; subsequent Epic 45 stories will add a Redis-backed implementation that multi-replica deployments can share.

All fields are private; access is via the Store methods only. The internal mutexes are granular per data type so a hot operation on one (e.g. active-session check) does not block unrelated operations (e.g. password cache reads).

func NewInMemoryStore

func NewInMemoryStore() *InMemoryStore

NewInMemoryStore returns a Store backed by process-local maps. The returned store is safe for concurrent use.

func (*InMemoryStore) ActiveSessionCount

func (s *InMemoryStore) ActiveSessionCount(ctx context.Context, workspaceID string) int

func (*InMemoryStore) CheckAndAddActiveSession

func (s *InMemoryStore) CheckAndAddActiveSession(ctx context.Context, workspaceID, sessionID string, maxSessions int) bool

func (*InMemoryStore) ClearActiveSessions

func (s *InMemoryStore) ClearActiveSessions(ctx context.Context, workspaceID string)

func (*InMemoryStore) ClearDeletedSessions

func (s *InMemoryStore) ClearDeletedSessions(ctx context.Context, workspaceID string)

func (*InMemoryStore) DeleteParentBackfilled

func (s *InMemoryStore) DeleteParentBackfilled(ctx context.Context, workspaceID string)

func (*InMemoryStore) DeletePriorPhase

func (s *InMemoryStore) DeletePriorPhase(ctx context.Context, workspaceID string)

func (*InMemoryStore) GetActiveSessions

func (s *InMemoryStore) GetActiveSessions(ctx context.Context, workspaceID string) []string

func (*InMemoryStore) GetCachedPassword

func (s *InMemoryStore) GetCachedPassword(ctx context.Context, workspaceID string) (string, bool)

func (*InMemoryStore) GetParentBackfilled

func (s *InMemoryStore) GetParentBackfilled(ctx context.Context, workspaceID string) bool

func (*InMemoryStore) GetPriorPhase

func (s *InMemoryStore) GetPriorPhase(ctx context.Context, workspaceID string) (string, bool)

func (*InMemoryStore) GetWorkspaceConfig

func (s *InMemoryStore) GetWorkspaceConfig(ctx context.Context, workspaceID string) (Config, bool)

func (*InMemoryStore) InvalidateAll

func (s *InMemoryStore) InvalidateAll(ctx context.Context, workspaceID string)

func (*InMemoryStore) InvalidatePassword

func (s *InMemoryStore) InvalidatePassword(ctx context.Context, workspaceID string)

func (*InMemoryStore) InvalidateWorkspaceConfig

func (s *InMemoryStore) InvalidateWorkspaceConfig(ctx context.Context, workspaceID string)

func (*InMemoryStore) IsSessionActive

func (s *InMemoryStore) IsSessionActive(ctx context.Context, workspaceID, sessionID string) bool

func (*InMemoryStore) IsSessionDeleted

func (s *InMemoryStore) IsSessionDeleted(ctx context.Context, workspaceID, sessionID string) bool

func (*InMemoryStore) MarkSessionDeleted

func (s *InMemoryStore) MarkSessionDeleted(ctx context.Context, workspaceID, sessionID string)

func (*InMemoryStore) RemoveActiveSession

func (s *InMemoryStore) RemoveActiveSession(ctx context.Context, workspaceID, sessionID string)

func (*InMemoryStore) SetCachedPassword

func (s *InMemoryStore) SetCachedPassword(ctx context.Context, workspaceID, password string)

func (*InMemoryStore) SetParentBackfilled

func (s *InMemoryStore) SetParentBackfilled(ctx context.Context, workspaceID string)

func (*InMemoryStore) SetPriorPhase

func (s *InMemoryStore) SetPriorPhase(ctx context.Context, workspaceID, phase string)

func (*InMemoryStore) SetWorkspaceConfig

func (s *InMemoryStore) SetWorkspaceConfig(ctx context.Context, workspaceID string, cfg Config)

func (*InMemoryStore) TouchActiveSessions

func (s *InMemoryStore) TouchActiveSessions(ctx context.Context, workspaceID string)

TouchActiveSessions is a no-op for the InMemoryStore: in-memory entries have no TTL (they persist until explicitly removed). The Redis implementation refreshes the key TTL; see redis.go TouchActiveSessions.

type RedisStore

type RedisStore struct {
	// contains filtered or unexported fields
}

RedisStore is the multi-replica-safe implementation of Store. All six state sections (activeSess, deletedSessions, pwCache, wsConfig, priorPhase, parentBackfilled) are backed by Redis. The RedisStore is the sole production path — the InMemoryStore exists only as the default for ProxyHandler when no Redis client is configured (unit tests, local dev without Redis).

func NewRedisStore

func NewRedisStore(client *redis.Client, activeSessTTL time.Duration) *RedisStore

NewRedisStore returns a Store backed by Redis for active sessions and by InMemoryStore for the remaining (not-yet-migrated) sections. The active-session TTL is set to DefaultActiveSessTTL.

func NewRedisStoreWithLogger

func NewRedisStoreWithLogger(client *redis.Client, activeSessTTL time.Duration, logger pkginterfaces.LoggerInterface) *RedisStore

NewRedisStoreWithLogger is like NewRedisStore but also accepts a logger for fail-open event recording. The logger may be nil — Prometheus metrics are recorded regardless.

func (*RedisStore) ActiveSessionCount

func (s *RedisStore) ActiveSessionCount(ctx context.Context, workspaceID string) int

ActiveSessionCount returns the number of sessions currently in the workspace's active set. Returns 0 on Redis error.

func (*RedisStore) CheckAndAddActiveSession

func (s *RedisStore) CheckAndAddActiveSession(ctx context.Context, workspaceID, sessionID string, maxSessions int) bool

CheckAndAddActiveSession atomically adds sessionID to the workspace's active set if there's room. Fail-open: if Redis is unreachable, returns true and records the error. The rationale (per design): better to allow a request than block legit traffic when Redis hiccups.

func (*RedisStore) ClearActiveSessions

func (s *RedisStore) ClearActiveSessions(ctx context.Context, workspaceID string)

ClearActiveSessions deletes the workspace's entire active set, removing the Redis key entirely so no orphan TTL countdown lingers. Also cleans up the Prometheus gauge label to bound cardinality.

func (*RedisStore) ClearDeletedSessions

func (s *RedisStore) ClearDeletedSessions(ctx context.Context, workspaceID string)

ClearDeletedSessions removes all tombstones for the workspace. Uses SCAN to find keys matching `ws:{workspace_id}:deleted:*` and DELs them in batches. No-op on Redis error (the tombstones will expire via TTL).

func (*RedisStore) DeleteParentBackfilled

func (s *RedisStore) DeleteParentBackfilled(ctx context.Context, workspaceID string)

func (*RedisStore) DeletePriorPhase

func (s *RedisStore) DeletePriorPhase(ctx context.Context, workspaceID string)

func (*RedisStore) GetActiveSessions

func (s *RedisStore) GetActiveSessions(ctx context.Context, workspaceID string) []string

GetActiveSessions returns the IDs of all sessions currently in the workspace's active set. Returns nil on Redis error or empty set.

func (*RedisStore) GetCachedPassword

func (s *RedisStore) GetCachedPassword(ctx context.Context, workspaceID string) (string, bool)

GetCachedPassword returns the cached password for the workspace, if present. Cache-only — never returns false data on Redis error. Returns ("", false) on miss OR on Redis error so the caller falls through to the K8s Secret fetch.

func (*RedisStore) GetParentBackfilled

func (s *RedisStore) GetParentBackfilled(ctx context.Context, workspaceID string) bool

func (*RedisStore) GetPriorPhase

func (s *RedisStore) GetPriorPhase(ctx context.Context, workspaceID string) (string, bool)

func (*RedisStore) GetWorkspaceConfig

func (s *RedisStore) GetWorkspaceConfig(ctx context.Context, workspaceID string) (Config, bool)

func (*RedisStore) InvalidateAll

func (s *RedisStore) InvalidateAll(ctx context.Context, workspaceID string)

InvalidateAll clears all Redis-backed state (active sessions, deleted tombstones, password cache, config cache, parent backfill) for the workspace. priorPhase is INTENTIONALLY PRESERVED — onPhaseChange relies on it to distinguish first-invocation from Active→Active reconcile (per US-45.1 contract). Terminate/Terminating calls DeletePriorPhase explicitly when the workspace is truly gone.

func (*RedisStore) InvalidatePassword

func (s *RedisStore) InvalidatePassword(ctx context.Context, workspaceID string)

InvalidatePassword clears the cached password for the workspace. DEL is the single source of truth — replicas hitting Redis on miss fall through to K8s. No pubsub needed (per design: replicas hit Redis on every request anyway).

func (*RedisStore) InvalidateWorkspaceConfig

func (s *RedisStore) InvalidateWorkspaceConfig(ctx context.Context, workspaceID string)

func (*RedisStore) IsSessionActive

func (s *RedisStore) IsSessionActive(ctx context.Context, workspaceID, sessionID string) bool

IsSessionActive reports whether sessionID is in the workspace's active set. Returns false on Redis error (do not trap the user in 409 based on possibly-stale state).

func (*RedisStore) IsSessionDeleted

func (s *RedisStore) IsSessionDeleted(ctx context.Context, workspaceID, sessionID string) bool

IsSessionDeleted reports whether the session was recently deleted via the API. Fail-CLOSED: returns TRUE on Redis error (assume deleted to prevent zombie session resurrection).

Worklog 371 M1: each fail-closed return increments ws_state_is_session_deleted_fail_closed_total so operators can alert on the silent suppression of session-event processing during a Redis outage (title persistence, context-token recording, queue drain, and sessionIndex.RecordMessage are all gated on !isSessionDeleted).

func (*RedisStore) MarkSessionDeleted

func (s *RedisStore) MarkSessionDeleted(ctx context.Context, workspaceID, sessionID string)

MarkSessionDeleted records a per-session tombstone in Redis with TTL. Silently fails on Redis error — the tombstone is not recorded, but the system continues. When Redis recovers, the session can be re-deleted.

func (*RedisStore) RemoveActiveSession

func (s *RedisStore) RemoveActiveSession(ctx context.Context, workspaceID, sessionID string)

RemoveActiveSession removes sessionID from the workspace's active set. If the set becomes empty, the Redis key is deleted so it does not linger as an orphan with TTL countdown.

The SREM, SCARD-check, and conditional DEL run inside a single Lua script so the entire operation is atomic. Without atomicity a race could exist: between SREM and a separate DEL-on-empty check, another replica could SADD a new session; the subsequent DEL would erase it.

On transition to empty the Prometheus gauge label is cleaned up via DeleteLabelValues — without this, workspaces that churn through create/suspend/terminate cycles would accumulate orphan time series forever (workspace_id is a UUID, so cardinality is unbounded).

func (*RedisStore) SetCachedPassword

func (s *RedisStore) SetCachedPassword(ctx context.Context, workspaceID, password string)

SetCachedPassword populates the password cache for the workspace. Silently fails on Redis error — the next read returns a miss and falls through to K8s. Idempotent: re-setting the same password refreshes the TTL.

H3 (worklog 371): the password is stored in PLAINTEXT in Redis. This is intentional: the API needs the plaintext to set Basic-Auth on every proxied request to opencode, so hashing (which would prevent plaintext retrieval) is not viable — every cache hit would fall through to the K8s Secret fetch, defeating the cache. Production deployments MUST configure Redis with:

  • TLS in-transit (rediss:// or a TLS sidecar) so the plaintext is not exposed on the internal network.
  • At-rest encryption (Redis 7 ACL + encryption, or disk-level encryption on the Redis PVC) so RDB/AOF dumps and backups do not expose it.
  • NetworkPolicy restricting ingress to the API pods only.

These are deployment responsibilities, not code-level controls — see the chart's redis section in values.yaml and the production runbook. The passwords are per-workspace generated credentials (not user passwords), bounded by the 1h TTL, and the source of truth is the K8s Secret (also encrypted at rest).

func (*RedisStore) SetParentBackfilled

func (s *RedisStore) SetParentBackfilled(ctx context.Context, workspaceID string)

func (*RedisStore) SetPriorPhase

func (s *RedisStore) SetPriorPhase(ctx context.Context, workspaceID, phase string)

func (*RedisStore) SetWorkspaceConfig

func (s *RedisStore) SetWorkspaceConfig(ctx context.Context, workspaceID string, cfg Config)

func (*RedisStore) TouchActiveSessions

func (s *RedisStore) TouchActiveSessions(ctx context.Context, workspaceID string)

TouchActiveSessions refreshes the TTL of the workspace's active session set (worklog 371 C3). Called on SSE activity so a multi-hour agentic turn does not let the 30-minute TTL expire mid-turn and admit a concurrent request that corrupts opencode's SQLite session history.

EXPIRE on a non-existent key is a no-op (returns 0, no error), so it is safe to call unconditionally on every SSE event even when the workspace has no active sessions.

type Store

type Store interface {

	// CheckAndAddActiveSession atomically adds sessionID to the
	// workspace's active set if it is not already present AND the set
	// size is below maxSessions. Returns true if the session is now
	// active (newly added OR already present), false if the maxSessions
	// limit blocked the add. Atomicity is required so that two
	// concurrent calls for different sessions cannot both observe
	// size == maxSessions and both succeed (which would exceed the
	// limit by one). The InMemoryStore implements this with a mutex;
	// a future Redis implementation will use a Lua script for the
	// same atomicity guarantee.
	CheckAndAddActiveSession(ctx context.Context, workspaceID, sessionID string, maxSessions int) bool

	// RemoveActiveSession removes sessionID from the workspace's active
	// set. No-op if not present. Cleans up the per-workspace map entry
	// when the set becomes empty to keep memory bounded.
	RemoveActiveSession(ctx context.Context, workspaceID, sessionID string)

	// IsSessionActive reports whether sessionID is in the workspace's
	// active set.
	IsSessionActive(ctx context.Context, workspaceID, sessionID string) bool

	// ActiveSessionCount returns the number of sessions currently in
	// the workspace's active set. Returns 0 if the workspace has no
	// active set (no sessions ever added).
	ActiveSessionCount(ctx context.Context, workspaceID string) int

	// GetActiveSessions returns the IDs of all sessions currently in
	// the workspace's active set. Returns nil for an empty/unknown
	// workspace. Order is unspecified; callers must not rely on it.
	GetActiveSessions(ctx context.Context, workspaceID string) []string

	// ClearActiveSessions removes the workspace's entire active set.
	// Called by InvalidateAll on phase transitions.
	ClearActiveSessions(ctx context.Context, workspaceID string)

	// TouchActiveSessions refreshes the TTL of the workspace's active
	// session set without adding or removing any session. Called on SSE
	// activity (worklog 371 C3) so that a multi-hour agentic turn — which
	// emits session.status=busy once at turn start and no further session
	// events until completion — does not let the 30-minute TTL expire and
	// admit a concurrent turn that would corrupt opencode's SQLite session
	// history. For InMemoryStore this is a no-op (no TTL); for RedisStore
	// it runs EXPIRE on the active-set key.
	TouchActiveSessions(ctx context.Context, workspaceID string)

	// MarkSessionDeleted records that sessionID in workspaceID was
	// explicitly deleted via the API, so late SSE events arriving after
	// deletion are suppressed (preventing zombie sessions in
	// session_index).
	MarkSessionDeleted(ctx context.Context, workspaceID, sessionID string)

	// IsSessionDeleted reports whether the session was recently deleted.
	// Implementations may age out tombstones (the InMemoryStore bounds
	// the set to 500 entries with batch eviction); callers must treat a
	// false response as "not recently deleted" rather than "never
	// deleted".
	IsSessionDeleted(ctx context.Context, workspaceID, sessionID string) bool

	// ClearDeletedSessions removes all tombstones for the workspace.
	// Called by InvalidateAll.
	ClearDeletedSessions(ctx context.Context, workspaceID string)

	// GetCachedPassword returns the cached password for the workspace,
	// if present. Cache-only — does NOT fall back to the K8s Secret
	// fetch. The fallback stays in ProxyHandler.getPassword so the
	// store remains pure-state (no I/O dependencies).
	GetCachedPassword(ctx context.Context, workspaceID string) (string, bool)

	// SetCachedPassword populates the password cache for the workspace.
	SetCachedPassword(ctx context.Context, workspaceID, password string)

	// InvalidatePassword clears the cached password for the workspace.
	// Called on 401 from upstream and on phase transitions.
	InvalidatePassword(ctx context.Context, workspaceID string)

	// GetWorkspaceConfig returns the cached config for the workspace, if
	// present. Cache-only — ProxyHandler.shouldAutoApprovePermissions
	// falls back to fetching the Workspace CRD on miss.
	GetWorkspaceConfig(ctx context.Context, workspaceID string) (Config, bool)

	// SetWorkspaceConfig populates the config cache for the workspace.
	SetWorkspaceConfig(ctx context.Context, workspaceID string, cfg Config)

	// InvalidateWorkspaceConfig clears the cached config.
	InvalidateWorkspaceConfig(ctx context.Context, workspaceID string)

	// GetPriorPhase returns the workspace's last-observed phase, if any.
	// Used by onPhaseChange to detect real transitions vs no-op events.
	GetPriorPhase(ctx context.Context, workspaceID string) (string, bool)

	// SetPriorPhase records the workspace's current phase as the prior
	// phase for the next onPhaseChange invocation.
	SetPriorPhase(ctx context.Context, workspaceID, phase string)

	// DeletePriorPhase removes the prior-phase entry. Called on
	// terminate so the workspace starts fresh if ever re-created with
	// the same name. NOT called by InvalidateAll — see the contract
	// doc on InvalidateAll for why.
	DeletePriorPhase(ctx context.Context, workspaceID string)

	// GetParentBackfilled reports whether the workspace's session-parent
	// backfill has already run. The marker is per-replica today; a
	// future Redis-backed implementation will move it to a shared key
	// so only one replica performs the backfill.
	GetParentBackfilled(ctx context.Context, workspaceID string) bool

	// SetParentBackfilled marks the workspace's backfill as done.
	SetParentBackfilled(ctx context.Context, workspaceID string)

	// DeleteParentBackfilled clears the marker, allowing the backfill
	// to re-run on the next opportunity. Called on backfill failure so
	// it can be retried, and on workspace terminate.
	DeleteParentBackfilled(ctx context.Context, workspaceID string)

	// InvalidateAll clears the workspace state that becomes stale on a
	// phase transition: active sessions, deleted markers, cached
	// password, cached config, and parent-backfill marker. Does NOT
	// affect connCount (which is not in this Store) and does NOT affect
	// prior phase — the onPhaseChange handler relies on prior phase
	// surviving invalidation to distinguish first-invocation from
	// Active→Active reconcile. Terminate/Terminating explicitly calls
	// DeletePriorPhase when the workspace is truly gone.
	InvalidateAll(ctx context.Context, workspaceID string)
}

Store is the per-workspace state contract used by ProxyHandler.

The interface groups six logically distinct pieces of state that share the same lifecycle (per-workspace, invalidated together on phase change). They are grouped rather than split into separate interfaces because:

  • the current consumer (ProxyHandler) uses all six together;
  • the Redis key namespace is shared (`ws:{workspace_id}:*`);
  • the invalidation semantics are shared (InvalidateAll).

Future consumers that need only a subset should define a narrower interface at the call site (Go's structural typing makes this free).

Method naming follows the existing ProxyHandler method names where possible to minimize churn at the call sites.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL