Claim atomically reserves (userID, key) for the caller. The outcome determines what the HTTP layer should do next: - OutcomeAcquired → run the side effect, then Complete on success or Release on caller-side abort. - OutcomeReplay → write Cached to the response, do NOT re-run the side effect. - Ou
(ctx context.Context, userID, key, path, bodyHash string)
| 153 | // lock and the loser sees a non-stale in_progress on its follow-up |
| 154 | // read. |
| 155 | func (s *Store) Claim(ctx context.Context, userID, key, path, bodyHash string) (ClaimResult, error) { |
| 156 | if userID == "" { |
| 157 | return ClaimResult{}, errors.New("idempotency: userID required") |
| 158 | } |
| 159 | if key == "" { |
| 160 | return ClaimResult{}, errors.New("idempotency: key required") |
| 161 | } |
| 162 | |
| 163 | staleSecs := int(StaleClaimWindow.Seconds()) |
| 164 | |
| 165 | // Atomic claim path. Returns a row iff we own the slot — either as |
| 166 | // a fresh INSERT, or as a stale-takeover where the DO UPDATE's |
| 167 | // WHERE clause is true. Returns no rows when an existing row |
| 168 | // blocks us (completed, or in_progress but not yet stale), in |
| 169 | // which case we read the existing row to classify the outcome. |
| 170 | var owned int |
| 171 | err := s.pool.QueryRow(ctx, |
| 172 | `INSERT INTO idempotency_keys ( |
| 173 | user_id, key, request_path, request_body_hash, |
| 174 | response_status, response_content_type, response_body, |
| 175 | status, created_at, completed_at |
| 176 | ) |
| 177 | VALUES ($1, $2, $3, $4, 0, '', ''::bytea, 'in_progress', now(), NULL) |
| 178 | ON CONFLICT (user_id, key) DO UPDATE |
| 179 | SET request_path = EXCLUDED.request_path, |
| 180 | request_body_hash = EXCLUDED.request_body_hash, |
| 181 | response_status = 0, |
| 182 | response_content_type = '', |
| 183 | response_body = ''::bytea, |
| 184 | status = 'in_progress', |
| 185 | created_at = now(), |
| 186 | completed_at = NULL |
| 187 | WHERE idempotency_keys.status = 'in_progress' |
| 188 | AND idempotency_keys.created_at < now() - make_interval(secs => $5) |
| 189 | RETURNING 1`, |
| 190 | userID, key, path, bodyHash, staleSecs, |
| 191 | ).Scan(&owned) |
| 192 | if err == nil { |
| 193 | return ClaimResult{Outcome: OutcomeAcquired}, nil |
| 194 | } |
| 195 | if !errors.Is(err, pgx.ErrNoRows) { |
| 196 | return ClaimResult{}, err |
| 197 | } |
| 198 | |
| 199 | // Lost the race. Read the existing row to classify. |
| 200 | var ( |
| 201 | gotStatus string |
| 202 | gotHash string |
| 203 | gotCode int |
| 204 | gotCT string |
| 205 | gotBody []byte |
| 206 | ) |
| 207 | err = s.pool.QueryRow(ctx, |
| 208 | `SELECT status, request_body_hash, response_status, |
| 209 | response_content_type, response_body |
| 210 | FROM idempotency_keys |
| 211 | WHERE user_id = $1 AND key = $2`, |
| 212 | userID, key, |