Files
pad/internal/server/task3585_push_key_test.go
T
xarmian ccd915f1cc feat(push): idempotency keys for push, and the monitor drops a re-published push (TASK-3585) (#2071)
POST .../items/{ref}/push accepts op_key (body) or Idempotency-Key,
claimed after every refusal. Push writes no row, so its answer cannot
commit with it; per the lead's ruling the key is completed AFTER the
publish (store.CompleteIdempotencyKey, its own transaction):
- a clean publish stores the response; a same-key retry replays it and
  publishes nothing;
- an unconfirmed publish stores 409 push_outcome_unknown, so a retry
  says "check your agent session" instead of delivering again;
- a closed bus published nothing and releases the key.

A crash between the publish and the completion lets the lease expire and
a retry take the claim over and publish again. Every publish therefore
carries a push_id derived from (workspace, user, key), the same across a
takeover, on watchevents.Notification and the stream payload, and
`pad watch --stream --for-session` drops a push_id it printed in the last
~256 events / 10 minutes, across reconnects. A monitor that predates
push_id prints every publish (at-least-once, as before).

CLI: `pad push --op-key` (fresh per invocation by default) and one keyed
retry after a 5xx, only against a server advertising idempotency_keys.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 16:44:56 -04:00

142 lines
5.0 KiB
Go

package server
import (
"context"
"encoding/json"
"errors"
"net/http"
"sync"
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/watchevents"
)
// TASK-3585: idempotency keys on push. Push writes no row, so the key is
// completed after the publish; every publish carries a push_id derived from
// the key so a monitor can drop a re-publish after a claim takeover.
// capturingBus records what was published and fails with err when set.
type capturingBus struct {
mu sync.Mutex
err error
sent []watchevents.Notification
}
func (b *capturingBus) Publish(n watchevents.Notification) error {
b.mu.Lock()
defer b.mu.Unlock()
b.sent = append(b.sent, n)
return b.err
}
func (b *capturingBus) published() []watchevents.Notification {
b.mu.Lock()
defer b.mu.Unlock()
return append([]watchevents.Notification(nil), b.sent...)
}
func (b *capturingBus) setErr(err error) { b.mu.Lock(); b.err = err; b.mu.Unlock() }
func (b *capturingBus) Subscribe() (chan watchevents.Notification, <-chan struct{}, error) {
return nil, nil, nil
}
func (b *capturingBus) SubscribeAndReplaySince(context.Context, int64) (chan watchevents.Notification, []watchevents.Notification, <-chan struct{}, error) {
return nil, nil, nil, nil
}
func (b *capturingBus) Unsubscribe(chan watchevents.Notification) {}
func (b *capturingBus) EventsSince(int64) []watchevents.Notification {
return nil
}
func (b *capturingBus) Close() {}
func pushOnce(t *testing.T, srv *Server, slug, itemSlug, token, key string) *http.Response {
t.Helper()
rr := bearerJSON(t, srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+itemSlug+"/push", token,
map[string]interface{}{"message": "triage this", "op_key": key})
return rr.Result()
}
func TestTASK3585_PushReplaysWithoutRedelivering(t *testing.T) {
srv := testServer(t)
bus := &capturingBus{}
srv.SetWatchEventsBus(bus)
slug, item, tok, _ := setupWatchTestUser(t, srv)
first := pushOnce(t, srv, slug, item.Slug, tok.Token, "p-1")
again := pushOnce(t, srv, slug, item.Slug, tok.Token, "p-1")
if first.StatusCode != http.StatusOK || again.StatusCode != http.StatusOK || again.Header.Get("Idempotent-Replayed") != "true" {
t.Fatalf("replay: %d/%d", first.StatusCode, again.StatusCode)
}
if n := len(bus.published()); n != 1 {
t.Fatalf("a same-key retry published again: %d publishes", n)
}
if bus.published()[0].PushID == "" {
t.Fatal("a push must carry a push_id")
}
}
func TestTASK3585_AmbiguousPushIsNotRedelivered(t *testing.T) {
srv := testServer(t)
bus := &capturingBus{err: errors.New("redis: i/o timeout")}
srv.SetWatchEventsBus(bus)
slug, item, tok, _ := setupWatchTestUser(t, srv)
first := pushOnce(t, srv, slug, item.Slug, tok.Token, "p-2")
if first.StatusCode != http.StatusBadGateway {
t.Fatalf("premise: an unconfirmed publish answers 502; got %d", first.StatusCode)
}
bus.setErr(nil)
again := pushOnce(t, srv, slug, item.Slug, tok.Token, "p-2")
var env struct {
Error struct{ Code string } `json:"error"`
}
_ = json.NewDecoder(again.Body).Decode(&env)
if again.StatusCode != http.StatusConflict || env.Error.Code != pushOutcomeUnknownCode {
t.Fatalf("retry after an ambiguous publish: %d %q", again.StatusCode, env.Error.Code)
}
if n := len(bus.published()); n != 1 {
t.Fatalf("the ambiguous push was delivered again: %d publishes", n)
}
}
func TestTASK3585_ClosedBusReleasesThePushKey(t *testing.T) {
srv := testServer(t)
bus := &capturingBus{err: watchevents.ErrBusClosed}
srv.SetWatchEventsBus(bus)
slug, item, tok, _ := setupWatchTestUser(t, srv)
if r := pushOnce(t, srv, slug, item.Slug, tok.Token, "p-3"); r.StatusCode != http.StatusServiceUnavailable {
t.Fatalf("premise: a closed bus answers 503; got %d", r.StatusCode)
}
bus.setErr(nil)
again := pushOnce(t, srv, slug, item.Slug, tok.Token, "p-3")
if again.StatusCode != http.StatusOK || again.Header.Get("Idempotent-Replayed") != "" {
t.Fatalf("a push that published nothing must be sendable again with its key: %d", again.StatusCode)
}
}
// A server that dies between publishing and completing the key leaves a
// claim whose lease expires. The retry takes it over and publishes again,
// with the SAME push_id, which is what lets a monitor drop the repeat.
func TestTASK3585_PushTakeoverKeepsThePushID(t *testing.T) {
srv := testServer(t)
bus := &capturingBus{}
srv.SetWatchEventsBus(bus)
slug, item, tok, user := setupWatchTestUser(t, srv)
ws, err := srv.store.GetWorkspaceBySlug(slug)
if err != nil || ws == nil {
t.Fatalf("workspace: %v", err)
}
hash := idemRequestHash(idempotencyRoutePush, item.ID, pushRequest{Message: "triage this"})
// The dead first attempt: claimed, never completed, lease already over.
if _, err := srv.store.ClaimIdempotencyKey(ws.ID, user.ID, "p-4", idempotencyRoutePush, hash, -time.Second); err != nil {
t.Fatal(err)
}
r := pushOnce(t, srv, slug, item.Slug, tok.Token, "p-4")
if r.StatusCode != http.StatusOK {
t.Fatalf("takeover: %d", r.StatusCode)
}
sent := bus.published()
if len(sent) != 1 || sent[0].PushID != pushIDFor(ws.ID, user.ID, "p-4") {
t.Fatalf("the takeover must publish the key's stable push_id: %+v", sent)
}
}