867 lines
26 KiB
Go
867 lines
26 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package personal
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
eventlock "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/lock"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
|
)
|
|
|
|
const (
|
|
// AttemptStateFileName stores retry suppression state for personal
|
|
// subscription creation. It intentionally does not share the successful
|
|
// subscription run-state file.
|
|
AttemptStateFileName = "personal_subscription_attempts.json"
|
|
// AttemptStateLockFileName serializes cross-process attempt-state changes.
|
|
AttemptStateLockFileName = "personal_subscription_attempts.lock"
|
|
|
|
attemptStateVersion = 1
|
|
|
|
attemptLockWaitTimeout = 5 * time.Second
|
|
attemptLockRetryDelay = 25 * time.Millisecond
|
|
attemptFailureReset = 24 * time.Hour
|
|
attemptTerminalHold = time.Hour
|
|
attemptMaxFieldLength = 256
|
|
)
|
|
|
|
var (
|
|
// ErrAttemptClaimStale means another process replaced at least one claimed
|
|
// fingerprint. Completion is rejected as a unit so an old process cannot
|
|
// overwrite newer state.
|
|
ErrAttemptClaimStale = errors.New("personal event: subscription attempt claim is stale")
|
|
)
|
|
|
|
// AttemptState is the persisted lifecycle state of one subscription attempt.
|
|
type AttemptState string
|
|
|
|
const (
|
|
AttemptStateInFlight AttemptState = "in_flight"
|
|
AttemptStateCooldown AttemptState = "cooldown"
|
|
AttemptStateTerminalHold AttemptState = "terminal_hold"
|
|
)
|
|
|
|
// Retryability preserves the distinction between an explicit server decision
|
|
// and an error for which the server did not provide retry guidance.
|
|
type Retryability string
|
|
|
|
const (
|
|
RetryabilityUnknown Retryability = "unknown"
|
|
RetryabilityRetryable Retryability = "retryable"
|
|
RetryabilityNonRetryable Retryability = "non_retryable"
|
|
)
|
|
|
|
// Value converts retryability to a bool while preserving whether it is known.
|
|
func (r Retryability) Value() (value bool, known bool) {
|
|
switch r {
|
|
case RetryabilityRetryable:
|
|
return true, true
|
|
case RetryabilityNonRetryable:
|
|
return false, true
|
|
default:
|
|
return false, false
|
|
}
|
|
}
|
|
|
|
// AttemptSpec identifies one normalized subscription request. Fingerprint must
|
|
// come from Fingerprint; only the digest and the non-sensitive event key are
|
|
// persisted.
|
|
type AttemptSpec struct {
|
|
Fingerprint string
|
|
EventKey string
|
|
}
|
|
|
|
// AttemptClaim is the ownership token returned by Claim. Callers must finish
|
|
// it with CompleteSuccess, CompleteFailure, or Release.
|
|
type AttemptClaim struct {
|
|
AttemptID string
|
|
Fingerprints []string
|
|
|
|
previous map[string]attemptPrevious
|
|
}
|
|
|
|
type attemptPrevious struct {
|
|
record attemptRecord
|
|
present bool
|
|
}
|
|
|
|
// AttemptBlockedError reports a local suppression decision. It never includes
|
|
// the raw endpoint, idempotency key, profile selector, rule parameter, or
|
|
// filter.
|
|
type AttemptBlockedError struct {
|
|
Fingerprint string
|
|
EventKey string
|
|
State AttemptState
|
|
Retryability Retryability
|
|
RetryAfter time.Duration
|
|
NextAllowedAt time.Time
|
|
FailureCount int
|
|
ErrorCode string
|
|
TraceID string
|
|
}
|
|
|
|
func (e *AttemptBlockedError) Error() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf(
|
|
"personal event: subscription attempt %s until %s",
|
|
e.State,
|
|
e.NextAllowedAt.UTC().Format(time.RFC3339),
|
|
)
|
|
}
|
|
|
|
// AttemptFailure describes the one failed item in a claimed batch.
|
|
type AttemptFailure struct {
|
|
Fingerprint string
|
|
Retryability Retryability
|
|
RetryAfter time.Duration
|
|
ErrorCode string
|
|
TraceID string
|
|
}
|
|
|
|
// AttemptHold is the persisted suppression decision after CompleteFailure.
|
|
type AttemptHold struct {
|
|
Fingerprint string
|
|
State AttemptState
|
|
Retryability Retryability
|
|
RetryAfter time.Duration
|
|
NextAllowedAt time.Time
|
|
FailureCount int
|
|
}
|
|
|
|
// AttemptStoreOption customizes an AttemptStore.
|
|
type AttemptStoreOption func(*AttemptStore)
|
|
|
|
// WithAttemptClock installs a clock, primarily for deterministic tests.
|
|
func WithAttemptClock(now func() time.Time) AttemptStoreOption {
|
|
return func(store *AttemptStore) {
|
|
if now != nil {
|
|
store.now = now
|
|
}
|
|
}
|
|
}
|
|
|
|
// WithAttemptIDGenerator installs an attempt-ID generator. IDs are persisted
|
|
// only as CAS tokens and must not contain request data.
|
|
func WithAttemptIDGenerator(generate func() (string, error)) AttemptStoreOption {
|
|
return func(store *AttemptStore) {
|
|
if generate != nil {
|
|
store.newID = generate
|
|
}
|
|
}
|
|
}
|
|
|
|
// AttemptStore coordinates personal subscription creation across CLI
|
|
// processes sharing one identity work directory.
|
|
type AttemptStore struct {
|
|
workDir string
|
|
now func() time.Time
|
|
newID func() (string, error)
|
|
|
|
readFile func(string) ([]byte, error)
|
|
mkdirAll func(string, os.FileMode) error
|
|
tryAcquire func(string) (*eventlock.File, error)
|
|
remove func(string) error
|
|
marshal func(any, string, string) ([]byte, error)
|
|
writeFile func(string, []byte, os.FileMode) error
|
|
chmod func(string, os.FileMode) error
|
|
rename func(string, string) error
|
|
sleep func(time.Duration)
|
|
}
|
|
|
|
// NewAttemptStore creates a fail-closed persistent attempt guard rooted in the
|
|
// identity-specific personal event work directory.
|
|
func NewAttemptStore(workDir string, options ...AttemptStoreOption) *AttemptStore {
|
|
store := &AttemptStore{
|
|
workDir: strings.TrimSpace(workDir),
|
|
now: time.Now,
|
|
newID: newAttemptID,
|
|
readFile: os.ReadFile,
|
|
mkdirAll: os.MkdirAll,
|
|
tryAcquire: eventlock.TryAcquire,
|
|
remove: os.Remove,
|
|
marshal: json.MarshalIndent,
|
|
writeFile: os.WriteFile,
|
|
chmod: os.Chmod,
|
|
rename: os.Rename,
|
|
sleep: time.Sleep,
|
|
}
|
|
for _, option := range options {
|
|
if option != nil {
|
|
option(store)
|
|
}
|
|
}
|
|
return store
|
|
}
|
|
|
|
// Fingerprint hashes an endpoint, the existing subscription idempotency key,
|
|
// and optional profile selectors into a stable non-reversible store key.
|
|
// Passing the active selector ensures two profiles resolving to the same
|
|
// corp/user can recover independently after a profile change.
|
|
func Fingerprint(endpoint, idempotencyKey string, profileSelector ...string) string {
|
|
endpoint = normalizeAttemptEndpoint(endpoint)
|
|
idempotencyKey = strings.TrimSpace(idempotencyKey)
|
|
if endpoint == "" || idempotencyKey == "" {
|
|
return ""
|
|
}
|
|
h := sha256.New()
|
|
writeFingerprintPart(h, "dws-personal-subscription-attempt-v1")
|
|
writeFingerprintPart(h, endpoint)
|
|
writeFingerprintPart(h, idempotencyKey)
|
|
for _, selector := range profileSelector {
|
|
if selector = strings.TrimSpace(selector); selector != "" {
|
|
writeFingerprintPart(h, selector)
|
|
}
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil))
|
|
}
|
|
|
|
type fingerprintWriter interface {
|
|
Write([]byte) (int, error)
|
|
}
|
|
|
|
func writeFingerprintPart(w fingerprintWriter, value string) {
|
|
var size [8]byte
|
|
binary.BigEndian.PutUint64(size[:], uint64(len(value)))
|
|
_, _ = w.Write(size[:])
|
|
_, _ = w.Write([]byte(value))
|
|
}
|
|
|
|
func normalizeAttemptEndpoint(raw string) string {
|
|
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
|
return raw
|
|
}
|
|
parsed.Scheme = strings.ToLower(parsed.Scheme)
|
|
parsed.Host = strings.ToLower(parsed.Host)
|
|
parsed.Fragment = ""
|
|
parsed.Path = strings.TrimRight(parsed.Path, "/")
|
|
return parsed.String()
|
|
}
|
|
|
|
// Claim atomically reserves every fingerprint in specs. If any fingerprint is
|
|
// still suppressed, none are reserved and an AttemptBlockedError is returned.
|
|
func (s *AttemptStore) Claim(specs []AttemptSpec, lease time.Duration) (*AttemptClaim, error) {
|
|
normalized, err := normalizeAttemptSpecs(specs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if lease <= 0 {
|
|
return nil, errors.New("personal event: subscription attempt lease must be positive")
|
|
}
|
|
if err := s.validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
attemptID, err := s.newID()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("personal event: generate subscription attempt id: %w", err)
|
|
}
|
|
attemptID = strings.TrimSpace(attemptID)
|
|
if attemptID == "" || len(attemptID) > attemptMaxFieldLength {
|
|
return nil, errors.New("personal event: generated subscription attempt id is invalid")
|
|
}
|
|
|
|
now := s.now().UTC()
|
|
claim := &AttemptClaim{
|
|
AttemptID: attemptID,
|
|
Fingerprints: make([]string, 0, len(normalized)),
|
|
previous: make(map[string]attemptPrevious, len(normalized)),
|
|
}
|
|
var blocked *AttemptBlockedError
|
|
err = s.withLock(func() error {
|
|
state, err := s.load()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pruneAttemptRecords(state, now)
|
|
|
|
for _, spec := range normalized {
|
|
record, ok := state.Records[spec.Fingerprint]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if candidate := blockedAttempt(record, now); candidate != nil {
|
|
blocked = candidate
|
|
return nil
|
|
}
|
|
}
|
|
for _, spec := range normalized {
|
|
previous, present := state.Records[spec.Fingerprint]
|
|
claim.previous[spec.Fingerprint] = attemptPrevious{
|
|
record: previous,
|
|
present: present,
|
|
}
|
|
claim.Fingerprints = append(claim.Fingerprints, spec.Fingerprint)
|
|
record := previous
|
|
record.Fingerprint = spec.Fingerprint
|
|
record.EventKey = spec.EventKey
|
|
record.State = AttemptStateInFlight
|
|
record.AttemptID = attemptID
|
|
record.LeaseUntil = now.Add(lease)
|
|
record.NextAllowedAt = time.Time{}
|
|
record.Retryability = ""
|
|
record.ErrorCode = ""
|
|
record.TraceID = ""
|
|
state.Records[spec.Fingerprint] = record
|
|
}
|
|
return s.write(state)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if blocked != nil {
|
|
return nil, blocked
|
|
}
|
|
return claim, nil
|
|
}
|
|
|
|
// CompleteSuccess clears failure history for every item in a successful
|
|
// claimed batch.
|
|
func (s *AttemptStore) CompleteSuccess(claim *AttemptClaim) error {
|
|
if err := validateAttemptClaim(claim); err != nil {
|
|
return err
|
|
}
|
|
if err := s.validate(); err != nil {
|
|
return err
|
|
}
|
|
return s.withLock(func() error {
|
|
state, err := s.load()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !claimOwnsAll(state, claim) {
|
|
return ErrAttemptClaimStale
|
|
}
|
|
for _, fingerprint := range claim.Fingerprints {
|
|
delete(state.Records, fingerprint)
|
|
}
|
|
return s.write(state)
|
|
})
|
|
}
|
|
|
|
// CompleteFailure records the failed item, clears successfully completed
|
|
// items, and restores every unexecuted item to its exact pre-claim state.
|
|
func (s *AttemptStore) CompleteFailure(
|
|
claim *AttemptClaim,
|
|
succeeded []string,
|
|
failure AttemptFailure,
|
|
) (AttemptHold, error) {
|
|
var zero AttemptHold
|
|
if err := validateAttemptClaim(claim); err != nil {
|
|
return zero, err
|
|
}
|
|
failure.Fingerprint = strings.TrimSpace(failure.Fingerprint)
|
|
if !containsFingerprint(claim.Fingerprints, failure.Fingerprint) {
|
|
return zero, errors.New("personal event: failed fingerprint is not part of the attempt claim")
|
|
}
|
|
if !validRetryability(failure.Retryability) {
|
|
return zero, fmt.Errorf("personal event: invalid retryability %q", failure.Retryability)
|
|
}
|
|
succeededSet, err := normalizeSucceededFingerprints(claim, succeeded, failure.Fingerprint)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
if err := s.validate(); err != nil {
|
|
return zero, err
|
|
}
|
|
|
|
now := s.now().UTC()
|
|
var hold AttemptHold
|
|
err = s.withLock(func() error {
|
|
state, err := s.load()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !claimOwnsAll(state, claim) {
|
|
return ErrAttemptClaimStale
|
|
}
|
|
|
|
for _, fingerprint := range claim.Fingerprints {
|
|
switch {
|
|
case fingerprint == failure.Fingerprint:
|
|
previous := claim.previous[fingerprint]
|
|
record := previous.record
|
|
count := record.FailureCount
|
|
if record.LastFailureAt.IsZero() || now.Sub(record.LastFailureAt) >= attemptFailureReset {
|
|
count = 0
|
|
}
|
|
count++
|
|
delay, stateName := attemptFailureDelay(fingerprint, count, failure)
|
|
record.Fingerprint = fingerprint
|
|
record.EventKey = state.Records[fingerprint].EventKey
|
|
record.State = stateName
|
|
record.AttemptID = ""
|
|
record.LeaseUntil = time.Time{}
|
|
record.FailureCount = count
|
|
record.LastFailureAt = now
|
|
record.NextAllowedAt = now.Add(delay)
|
|
record.Retryability = failure.Retryability
|
|
record.ErrorCode = boundedAttemptField(failure.ErrorCode)
|
|
record.TraceID = boundedAttemptField(failure.TraceID)
|
|
state.Records[fingerprint] = record
|
|
hold = AttemptHold{
|
|
Fingerprint: fingerprint,
|
|
State: stateName,
|
|
Retryability: failure.Retryability,
|
|
RetryAfter: delay,
|
|
NextAllowedAt: record.NextAllowedAt,
|
|
FailureCount: count,
|
|
}
|
|
case succeededSet[fingerprint]:
|
|
delete(state.Records, fingerprint)
|
|
default:
|
|
restoreAttemptPrevious(state, fingerprint, claim.previous[fingerprint])
|
|
}
|
|
}
|
|
return s.write(state)
|
|
})
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
return hold, nil
|
|
}
|
|
|
|
// Release restores every claimed item without recording a remote failure. It
|
|
// is intended for local failures before a subscription request is sent.
|
|
func (s *AttemptStore) Release(claim *AttemptClaim) error {
|
|
if err := validateAttemptClaim(claim); err != nil {
|
|
return err
|
|
}
|
|
if err := s.validate(); err != nil {
|
|
return err
|
|
}
|
|
return s.withLock(func() error {
|
|
state, err := s.load()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !claimOwnsAll(state, claim) {
|
|
return ErrAttemptClaimStale
|
|
}
|
|
for _, fingerprint := range claim.Fingerprints {
|
|
restoreAttemptPrevious(state, fingerprint, claim.previous[fingerprint])
|
|
}
|
|
return s.write(state)
|
|
})
|
|
}
|
|
|
|
func (s *AttemptStore) validate() error {
|
|
if s == nil {
|
|
return errors.New("personal event: nil subscription attempt store")
|
|
}
|
|
if s.workDir == "" {
|
|
return errors.New("personal event: subscription attempt work directory is required")
|
|
}
|
|
if s.now == nil || s.newID == nil || s.readFile == nil || s.mkdirAll == nil ||
|
|
s.tryAcquire == nil || s.remove == nil || s.marshal == nil ||
|
|
s.writeFile == nil || s.chmod == nil || s.rename == nil || s.sleep == nil {
|
|
return errors.New("personal event: subscription attempt store is not initialized")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeAttemptSpecs(specs []AttemptSpec) ([]AttemptSpec, error) {
|
|
if len(specs) == 0 {
|
|
return nil, errors.New("personal event: at least one subscription attempt is required")
|
|
}
|
|
out := make([]AttemptSpec, 0, len(specs))
|
|
seen := make(map[string]struct{}, len(specs))
|
|
for _, spec := range specs {
|
|
spec.Fingerprint = strings.TrimSpace(spec.Fingerprint)
|
|
spec.EventKey = strings.TrimSpace(spec.EventKey)
|
|
if !validAttemptFingerprint(spec.Fingerprint) {
|
|
return nil, errors.New("personal event: subscription attempt fingerprint is invalid")
|
|
}
|
|
if len(spec.EventKey) > attemptMaxFieldLength {
|
|
return nil, errors.New("personal event: subscription attempt event key is too long")
|
|
}
|
|
if _, ok := seen[spec.Fingerprint]; ok {
|
|
continue
|
|
}
|
|
seen[spec.Fingerprint] = struct{}{}
|
|
out = append(out, spec)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func normalizeSucceededFingerprints(
|
|
claim *AttemptClaim,
|
|
succeeded []string,
|
|
failed string,
|
|
) (map[string]bool, error) {
|
|
out := make(map[string]bool, len(succeeded))
|
|
for _, fingerprint := range succeeded {
|
|
fingerprint = strings.TrimSpace(fingerprint)
|
|
if fingerprint == failed {
|
|
return nil, errors.New("personal event: failed fingerprint cannot also be successful")
|
|
}
|
|
if !containsFingerprint(claim.Fingerprints, fingerprint) {
|
|
return nil, errors.New("personal event: successful fingerprint is not part of the attempt claim")
|
|
}
|
|
out[fingerprint] = true
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func validateAttemptClaim(claim *AttemptClaim) error {
|
|
if claim == nil || strings.TrimSpace(claim.AttemptID) == "" ||
|
|
len(claim.Fingerprints) == 0 || claim.previous == nil {
|
|
return errors.New("personal event: invalid subscription attempt claim")
|
|
}
|
|
for _, fingerprint := range claim.Fingerprints {
|
|
if !validAttemptFingerprint(fingerprint) {
|
|
return errors.New("personal event: subscription attempt claim contains an invalid fingerprint")
|
|
}
|
|
if _, ok := claim.previous[fingerprint]; !ok {
|
|
return errors.New("personal event: subscription attempt claim is incomplete")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func containsFingerprint(fingerprints []string, target string) bool {
|
|
for _, fingerprint := range fingerprints {
|
|
if fingerprint == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func validAttemptFingerprint(value string) bool {
|
|
if len(value) != sha256.Size*2 {
|
|
return false
|
|
}
|
|
decoded, err := hex.DecodeString(value)
|
|
return err == nil && len(decoded) == sha256.Size
|
|
}
|
|
|
|
func validRetryability(value Retryability) bool {
|
|
switch value {
|
|
case RetryabilityUnknown, RetryabilityRetryable, RetryabilityNonRetryable:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func attemptFailureDelay(
|
|
fingerprint string,
|
|
count int,
|
|
failure AttemptFailure,
|
|
) (time.Duration, AttemptState) {
|
|
if failure.Retryability == RetryabilityNonRetryable {
|
|
return attemptTerminalHold, AttemptStateTerminalHold
|
|
}
|
|
base := attemptBackoff(count)
|
|
delay := base + deterministicAttemptJitter(fingerprint, count, base)
|
|
if failure.RetryAfter > delay {
|
|
delay = failure.RetryAfter
|
|
}
|
|
return delay, AttemptStateCooldown
|
|
}
|
|
|
|
func attemptBackoff(count int) time.Duration {
|
|
switch count {
|
|
case 1:
|
|
return 30 * time.Second
|
|
case 2:
|
|
return 2 * time.Minute
|
|
case 3:
|
|
return 10 * time.Minute
|
|
default:
|
|
return 30 * time.Minute
|
|
}
|
|
}
|
|
|
|
func deterministicAttemptJitter(fingerprint string, count int, base time.Duration) time.Duration {
|
|
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d", fingerprint, count)))
|
|
// 0..2000 basis points, inclusive (0%..20%).
|
|
basisPoints := int(binary.BigEndian.Uint16(sum[:2])) % 2001
|
|
return time.Duration(int64(base) * int64(basisPoints) / 10000)
|
|
}
|
|
|
|
func boundedAttemptField(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if len(value) > attemptMaxFieldLength {
|
|
return value[:attemptMaxFieldLength]
|
|
}
|
|
return value
|
|
}
|
|
|
|
func restoreAttemptPrevious(state *attemptStateFile, fingerprint string, previous attemptPrevious) {
|
|
if previous.present {
|
|
state.Records[fingerprint] = previous.record
|
|
return
|
|
}
|
|
delete(state.Records, fingerprint)
|
|
}
|
|
|
|
func claimOwnsAll(state *attemptStateFile, claim *AttemptClaim) bool {
|
|
for _, fingerprint := range claim.Fingerprints {
|
|
record, ok := state.Records[fingerprint]
|
|
if !ok || record.State != AttemptStateInFlight || record.AttemptID != claim.AttemptID {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func blockedAttempt(record attemptRecord, now time.Time) *AttemptBlockedError {
|
|
var next time.Time
|
|
retryability := record.Retryability
|
|
switch record.State {
|
|
case AttemptStateInFlight:
|
|
if !record.LeaseUntil.After(now) {
|
|
return nil
|
|
}
|
|
next = record.LeaseUntil
|
|
retryability = RetryabilityUnknown
|
|
case AttemptStateCooldown, AttemptStateTerminalHold:
|
|
if !record.NextAllowedAt.After(now) {
|
|
return nil
|
|
}
|
|
next = record.NextAllowedAt
|
|
default:
|
|
return nil
|
|
}
|
|
return &AttemptBlockedError{
|
|
Fingerprint: record.Fingerprint,
|
|
EventKey: record.EventKey,
|
|
State: record.State,
|
|
Retryability: retryability,
|
|
RetryAfter: next.Sub(now),
|
|
NextAllowedAt: next,
|
|
FailureCount: record.FailureCount,
|
|
ErrorCode: record.ErrorCode,
|
|
TraceID: record.TraceID,
|
|
}
|
|
}
|
|
|
|
type attemptStateFile struct {
|
|
Version int `json:"version"`
|
|
Records map[string]attemptRecord `json:"records"`
|
|
}
|
|
|
|
type attemptRecord struct {
|
|
Fingerprint string `json:"fingerprint"`
|
|
EventKey string `json:"event_key,omitempty"`
|
|
State AttemptState `json:"state"`
|
|
AttemptID string `json:"attempt_id,omitempty"`
|
|
LeaseUntil time.Time `json:"lease_until,omitempty"`
|
|
FailureCount int `json:"failure_count,omitempty"`
|
|
LastFailureAt time.Time `json:"last_failure_at,omitempty"`
|
|
NextAllowedAt time.Time `json:"next_allowed_at,omitempty"`
|
|
Retryability Retryability `json:"retryability,omitempty"`
|
|
ErrorCode string `json:"error_code,omitempty"`
|
|
TraceID string `json:"trace_id,omitempty"`
|
|
}
|
|
|
|
func (s *AttemptStore) load() (*attemptStateFile, error) {
|
|
path := filepath.Join(s.workDir, AttemptStateFileName)
|
|
data, err := s.readFile(path)
|
|
if os.IsNotExist(err) {
|
|
return newAttemptStateFile(), nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("personal event: read subscription attempt state: %w", err)
|
|
}
|
|
var state attemptStateFile
|
|
if err := json.Unmarshal(data, &state); err != nil {
|
|
return nil, fmt.Errorf("personal event: decode subscription attempt state: %w", err)
|
|
}
|
|
if state.Version != attemptStateVersion {
|
|
return nil, fmt.Errorf(
|
|
"personal event: unsupported subscription attempt state version %d",
|
|
state.Version,
|
|
)
|
|
}
|
|
if state.Records == nil {
|
|
return nil, errors.New("personal event: subscription attempt state has no records map")
|
|
}
|
|
for fingerprint, record := range state.Records {
|
|
if err := validateAttemptRecord(fingerprint, record); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return &state, nil
|
|
}
|
|
|
|
func newAttemptStateFile() *attemptStateFile {
|
|
return &attemptStateFile{
|
|
Version: attemptStateVersion,
|
|
Records: make(map[string]attemptRecord),
|
|
}
|
|
}
|
|
|
|
func validateAttemptRecord(fingerprint string, record attemptRecord) error {
|
|
if !validAttemptFingerprint(fingerprint) || fingerprint != record.Fingerprint {
|
|
return errors.New("personal event: subscription attempt state contains an invalid fingerprint")
|
|
}
|
|
if len(record.EventKey) > attemptMaxFieldLength ||
|
|
len(record.AttemptID) > attemptMaxFieldLength ||
|
|
len(record.ErrorCode) > attemptMaxFieldLength ||
|
|
len(record.TraceID) > attemptMaxFieldLength {
|
|
return errors.New("personal event: subscription attempt state contains an oversized field")
|
|
}
|
|
if record.FailureCount < 0 {
|
|
return errors.New("personal event: subscription attempt state contains a negative failure count")
|
|
}
|
|
if record.Retryability != "" && !validRetryability(record.Retryability) {
|
|
return errors.New("personal event: subscription attempt state contains invalid retryability")
|
|
}
|
|
switch record.State {
|
|
case AttemptStateInFlight:
|
|
if record.AttemptID == "" || record.LeaseUntil.IsZero() {
|
|
return errors.New("personal event: in-flight subscription attempt state is incomplete")
|
|
}
|
|
case AttemptStateCooldown:
|
|
if record.NextAllowedAt.IsZero() ||
|
|
(record.Retryability != RetryabilityUnknown &&
|
|
record.Retryability != RetryabilityRetryable) {
|
|
return errors.New("personal event: cooldown subscription attempt state is invalid")
|
|
}
|
|
case AttemptStateTerminalHold:
|
|
if record.NextAllowedAt.IsZero() ||
|
|
record.Retryability != RetryabilityNonRetryable {
|
|
return errors.New("personal event: terminal subscription attempt state is invalid")
|
|
}
|
|
default:
|
|
return errors.New("personal event: subscription attempt state contains an invalid state")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func pruneAttemptRecords(state *attemptStateFile, now time.Time) {
|
|
for fingerprint, record := range state.Records {
|
|
switch {
|
|
case !record.LastFailureAt.IsZero() &&
|
|
!now.Before(record.LastFailureAt) &&
|
|
now.Sub(record.LastFailureAt) >= attemptFailureReset &&
|
|
((record.State == AttemptStateInFlight && !record.LeaseUntil.After(now)) ||
|
|
((record.State == AttemptStateCooldown ||
|
|
record.State == AttemptStateTerminalHold) &&
|
|
!record.NextAllowedAt.After(now))):
|
|
delete(state.Records, fingerprint)
|
|
case record.State == AttemptStateInFlight &&
|
|
record.LastFailureAt.IsZero() &&
|
|
!record.LeaseUntil.IsZero() &&
|
|
!now.Before(record.LeaseUntil) &&
|
|
now.Sub(record.LeaseUntil) >= attemptFailureReset:
|
|
delete(state.Records, fingerprint)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *AttemptStore) write(state *attemptStateFile) error {
|
|
if state == nil {
|
|
return errors.New("personal event: nil subscription attempt state")
|
|
}
|
|
if err := s.mkdirAll(s.workDir, config.DirPerm); err != nil {
|
|
return fmt.Errorf("personal event: create subscription attempt directory: %w", err)
|
|
}
|
|
path := filepath.Join(s.workDir, AttemptStateFileName)
|
|
if len(state.Records) == 0 {
|
|
if err := s.remove(path); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("personal event: remove empty subscription attempt state: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
keys := make([]string, 0, len(state.Records))
|
|
for fingerprint := range state.Records {
|
|
keys = append(keys, fingerprint)
|
|
}
|
|
sort.Strings(keys)
|
|
ordered := make(map[string]attemptRecord, len(keys))
|
|
for _, fingerprint := range keys {
|
|
ordered[fingerprint] = state.Records[fingerprint]
|
|
}
|
|
payload := attemptStateFile{Version: attemptStateVersion, Records: ordered}
|
|
data, err := s.marshal(payload, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("personal event: encode subscription attempt state: %w", err)
|
|
}
|
|
data = append(data, '\n')
|
|
tmp := path + ".tmp"
|
|
if err := s.writeFile(tmp, data, config.FilePerm); err != nil {
|
|
return fmt.Errorf("personal event: write subscription attempt state: %w", err)
|
|
}
|
|
if err := s.chmod(tmp, config.FilePerm); err != nil {
|
|
_ = s.remove(tmp)
|
|
return fmt.Errorf("personal event: secure subscription attempt state: %w", err)
|
|
}
|
|
if err := s.rename(tmp, path); err != nil {
|
|
_ = s.remove(tmp)
|
|
return fmt.Errorf("personal event: replace subscription attempt state: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *AttemptStore) withLock(fn func() error) error {
|
|
if err := s.mkdirAll(s.workDir, config.DirPerm); err != nil {
|
|
return fmt.Errorf("personal event: create subscription attempt directory: %w", err)
|
|
}
|
|
lockPath := filepath.Join(s.workDir, AttemptStateLockFileName)
|
|
deadline := s.now().Add(attemptLockWaitTimeout)
|
|
for {
|
|
held, err := s.tryAcquire(lockPath)
|
|
if err == nil {
|
|
if err := s.chmod(lockPath, config.FilePerm); err != nil {
|
|
_ = held.Close()
|
|
return fmt.Errorf("personal event: secure subscription attempt lock: %w", err)
|
|
}
|
|
defer held.Close()
|
|
return fn()
|
|
}
|
|
if !errors.Is(err, eventlock.ErrBusy) {
|
|
return fmt.Errorf("personal event: acquire subscription attempt lock: %w", err)
|
|
}
|
|
remaining := deadline.Sub(s.now())
|
|
if remaining <= 0 {
|
|
return fmt.Errorf(
|
|
"personal event: timed out waiting for subscription attempt lock after %s",
|
|
attemptLockWaitTimeout,
|
|
)
|
|
}
|
|
delay := attemptLockRetryDelay
|
|
if remaining < delay {
|
|
delay = remaining
|
|
}
|
|
s.sleep(delay)
|
|
}
|
|
}
|
|
|
|
func newAttemptID() (string, error) {
|
|
return rand.Text(), nil
|
|
}
|