Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions go/api/database/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ import (
// different user.
var ErrTaskOwnedByAnotherUser = errors.New("task id owned by another user")

// ErrSessionIDRetired means the session id belongs to a soft-deleted session.
// The id stays unusable for as long as the tombstone exists, and the tombstone
// and the events and tasks it owns are left as they are. Once retention
// hard-deletes the tombstone the id inserts normally again.
var ErrSessionIDRetired = errors.New("session id belongs to a deleted session")

var ErrIdempotencyConflict = errors.New("request id was already used with different parameters")

var ErrAgentInstanceConflict = errors.New("AgentInstance lifecycle operation conflicts with its current state")
Expand Down
12 changes: 11 additions & 1 deletion go/core/internal/database/client_postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,17 @@ func (c *postgresClient) StoreSession(ctx context.Context, session *dbpkg.Sessio
src := string(*session.Source)
params.Source = &src
}
return q.UpsertSession(ctx, params)
// UpsertSession returns no rows when the write was rejected: the id
// belongs to a soft-deleted session (deleted ids stay burned). Under
// READ COMMITTED the guard is evaluated against the latest committed
// row, so a delete committing mid-statement still rejects the write.
if _, err := q.UpsertSession(ctx, params); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbpkg.ErrSessionIDRetired
}
return fmt.Errorf("failed to store session %s: %w", session.ID, err)
}
return nil
})
}

Expand Down
42 changes: 42 additions & 0 deletions go/core/internal/database/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,48 @@ func TestStoreSessionIdempotence(t *testing.T) {
require.Error(t, err, "another user's session must not be readable")
}

// A soft-deleted session keeps its (id, user_id) row, so its id is burned:
// recreating it must fail loudly instead of updating a row that stays deleted
// and that no read path can see.
func TestDeletedSessionIdCannotBeReused(t *testing.T) {
db := setupTestDB(t)
client := NewClient(db)
ctx := context.Background()

name := "First incarnation"
session := &dbpkg.Session{ID: "s-dead", UserID: "alice", Name: &name}
require.NoError(t, client.StoreSession(ctx, session))
require.NoError(t, client.DeleteSession(ctx, "s-dead", "alice"))

tombstone := func() (createdAt, deletedAt *time.Time, storedName *string) {
require.NoError(t, db.QueryRow(ctx,
"SELECT created_at, deleted_at, name FROM session WHERE id = $1 AND user_id = $2",
"s-dead", "alice").Scan(&createdAt, &deletedAt, &storedName))
return
}
createdBefore, deletedBefore, _ := tombstone()
require.NotNil(t, deletedBefore, "delete must leave a tombstone")

renamed := "Second incarnation"
err := client.StoreSession(ctx, &dbpkg.Session{ID: "s-dead", UserID: "alice", Name: &renamed})
require.ErrorIs(t, err, dbpkg.ErrSessionIDRetired, "the owner must not silently resurrect a deleted id")

createdAfter, deletedAfter, storedName := tombstone()
assert.Equal(t, createdBefore, createdAfter, "the tombstone's created_at must not move")
assert.Equal(t, deletedBefore, deletedAfter, "the tombstone must stay deleted")
assert.Equal(t, name, *storedName, "the rejected write must not have touched the tombstone")

_, err = client.GetSession(ctx, "s-dead", "alice")
require.Error(t, err, "the session must stay deleted")

// A tombstone is not a live row, so the same id under another user does not
// collide with it.
require.NoError(t, client.StoreSession(ctx, &dbpkg.Session{ID: "s-dead", UserID: "bob", Name: &renamed}))
bobs, err := client.GetSession(ctx, "s-dead", "bob")
require.NoError(t, err)
assert.Equal(t, renamed, *bobs.Name)
}

func TestListSessionsOrdersByRecentActivity(t *testing.T) {
db := setupTestDB(t)
client := NewClient(db)
Expand Down
7 changes: 6 additions & 1 deletion go/core/internal/database/gen/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 13 additions & 4 deletions go/core/internal/database/gen/sessions.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 9 additions & 2 deletions go/core/internal/database/queries/sessions.sql
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,21 @@ WHERE agent_id = $1 AND deleted_at IS NULL
AND (source IS NULL OR source != 'agent')
ORDER BY updated_at DESC, created_at DESC;

-- name: UpsertSession :exec
-- UpsertSession returns the upserted id, or no rows when the write was
-- rejected: the id belongs to a soft-deleted session. A deleted id is never
-- updated or resurrected, it stays burned, so the tombstone and the events and
-- tasks it owns are left exactly as they are. Callers map "no rows" to a
-- conflict error.
-- name: UpsertSession :one
INSERT INTO session (id, user_id, name, agent_id, source, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
ON CONFLICT (id, user_id) DO UPDATE SET
name = EXCLUDED.name,
agent_id = EXCLUDED.agent_id,
source = EXCLUDED.source,
updated_at = NOW();
updated_at = NOW()
WHERE session.deleted_at IS NULL
RETURNING id;

-- name: SoftDeleteSession :exec
UPDATE session SET deleted_at = NOW()
Expand Down
19 changes: 19 additions & 0 deletions go/core/internal/grpcserver/session_task_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ type generatedClientSessionTaskStore struct {
lastTaskListUserID string
recordedShareUserID string
recordedShareID int64
// retired maps a deleted session id to its owner: a soft delete keeps the
// row, so the id stays burned for that user.
retired map[string]string
}

func newGeneratedClientSessionTaskStore() *generatedClientSessionTaskStore {
Expand All @@ -50,10 +53,14 @@ func newGeneratedClientSessionTaskStore() *generatedClientSessionTaskStore {
shares: make(map[string]*database.SessionShare),
tasks: make(map[string]*a2a.Task),
taskOwners: make(map[string]string),
retired: make(map[string]string),
}
}

func (s *generatedClientSessionTaskStore) StoreSession(_ context.Context, value *database.Session) error {
if owner, ok := s.retired[value.ID]; ok && owner == value.UserID {
return database.ErrSessionIDRetired
}
copy := *value
if copy.CreatedAt.IsZero() {
copy.CreatedAt = time.Date(2026, time.August, 2, 10, 0, 0, 0, time.UTC)
Expand Down Expand Up @@ -111,6 +118,7 @@ func (s *generatedClientSessionTaskStore) DeleteSession(_ context.Context, id, u
return database.ErrNotFound
}
delete(s.sessions, id)
s.retired[id] = userID
return nil
}

Expand Down Expand Up @@ -411,6 +419,17 @@ func TestSessionAndTaskGeneratedClients(t *testing.T) {
if _, err := sessionClient.DeleteSession(userContext, &apiv1alpha1.DeleteSessionRequest{SessionId: sessionID}); err != nil {
t.Fatalf("DeleteSession() error = %v", err)
}

// Recreating a deleted id must conflict rather than report a server fault,
// so a client can tell a retired id from an unhealthy server.
_, err = sessionClient.CreateSession(userContext, &apiv1alpha1.CreateSessionRequest{
Id: &sessionID,
AgentRef: "default/agent",
Name: &name,
})
if status.Code(err) != codes.AlreadyExists {
t.Fatalf("CreateSession() on a deleted id error = %v, want already exists", err)
}
}

func assertTaskObject(t *testing.T, object *a2apb.Task, taskID, contextID string) {
Expand Down
6 changes: 6 additions & 0 deletions go/core/internal/service/session/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ func (s *Service) Create(ctx context.Context, request CreateRequest) (*database.
Source: request.Source,
}
if err := s.store.StoreSession(ctx, value); err != nil {
if errors.Is(err, database.ErrSessionIDRetired) {
return nil, serviceerrors.NewAlreadyExists("Session ID belongs to a deleted session", err)
}
return nil, serviceerrors.NewInternal("Failed to create session", err)
}
stored, err := s.store.GetSession(ctx, id, userID)
Expand Down Expand Up @@ -245,6 +248,9 @@ func (s *Service) Update(ctx context.Context, request UpdateRequest) (*database.
session.AgentID = &agent.ID
}
if err := s.store.StoreSession(ctx, session); err != nil {
if errors.Is(err, database.ErrSessionIDRetired) {
return nil, serviceerrors.NewNotFound("Session not found", err)
}
return nil, serviceerrors.NewInternal("Failed to update session", err)
}
return session, nil
Expand Down
19 changes: 19 additions & 0 deletions go/core/internal/service/session/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,3 +275,22 @@ func TestMissingAuthenticationAndStoreErrorsAreCanonical(t *testing.T) {
t.Fatalf("Get() error = %v, want internal", err)
}
}

func TestCreateAndUpdateMapRetiredSessionID(t *testing.T) {
store := newSessionTestStore()
store.agents["default__NS__agent"] = &database.Agent{ID: "default__NS__agent"}
store.storeSessionError = database.ErrSessionIDRetired
id := "retired-session"

_, err := NewService(store).Create(sessionContext("user-a", ""), CreateRequest{ID: &id, AgentRef: "default/agent"})
if !serviceerrors.IsCode(err, serviceerrors.CodeAlreadyExists) {
t.Fatalf("Create() error = %v, want already exists", err)
}

store.sessions[id] = &database.Session{ID: id, UserID: "user-a"}
name := "renamed"
_, err = NewService(store).Update(sessionContext("user-a", ""), UpdateRequest{SessionID: id, Name: &name})
if !serviceerrors.IsCode(err, serviceerrors.CodeNotFound) {
t.Fatalf("Update() error = %v, want not found", err)
}
}
Loading