79d20534b5
Both defects came from the first real run of slice B against live data, and neither was visible in a unit test written from the design. The ring is a bounded history, not a set of live conditions. A quiet timeout closed an incident, its entry stayed in the ring because nothing evicts it, and every later heartbeat opened the same incident again: three signatures, four incidents each, from failures that never happened twice. An incident now opens only when the entry actually advances past what was already accounted, and the high-water mark survives the close. The ring also outlives the work it describes, so attributing its entries to whatever the worker is running now invented an association. The task is read out of the message, and only a failure that names no task belongs to the current lease. An incident that names an older task has no live lease to bound it, so it closes on quiet timeout rather than on the next epoch change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
161 lines
6.6 KiB
Go
161 lines
6.6 KiB
Go
package domain
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// A worker's observation ring is bounded, lossy, and local: it holds distinct
|
|
// failure messages with repeat counts and nothing else, and it disappears when
|
|
// the process does. The debt ledger reported that gap about itself, because no
|
|
// event carried any of it.
|
|
//
|
|
// These two events make it durable as incidents rather than as symptoms. Run
|
|
// 11 saw the same 409 refusal 301 times; that is one incident with an
|
|
// intensity of 301, not 301 pieces of evidence. Recurrence has to mean "this
|
|
// happened on four independent leases", or one stuck loop makes everything
|
|
// look chronic.
|
|
const (
|
|
EventObservationIncidentOpened = "ObservationIncidentOpened"
|
|
EventObservationIncidentClosed = "ObservationIncidentClosed"
|
|
)
|
|
|
|
// ObservationCloseReason is why Orchestra finalized an incident. None of them
|
|
// is "the message stopped appearing in the ring": the ring is a bounded
|
|
// history, so absence proves eviction as easily as recovery.
|
|
type ObservationCloseReason string
|
|
|
|
const (
|
|
// ObservationCloseLeaseEnd and ObservationCloseEpochChange are the natural
|
|
// boundaries of a lease-scoped incident. The work it was about is over.
|
|
ObservationCloseLeaseEnd ObservationCloseReason = "lease_end"
|
|
ObservationCloseEpochChange ObservationCloseReason = "epoch_change"
|
|
// ObservationCloseWorkerRestart ends every incident of an incarnation. A
|
|
// new process cannot continue the old one's symptom.
|
|
ObservationCloseWorkerRestart ObservationCloseReason = "worker_restart"
|
|
// ObservationCloseQuietTimeout is the only closer for an observation with
|
|
// no lease to bound it, and it fires on last_seen going stale rather than
|
|
// on the entry vanishing.
|
|
ObservationCloseQuietTimeout ObservationCloseReason = "quiet_timeout"
|
|
)
|
|
|
|
func (r ObservationCloseReason) Valid() bool {
|
|
switch r {
|
|
case ObservationCloseLeaseEnd, ObservationCloseEpochChange, ObservationCloseWorkerRestart, ObservationCloseQuietTimeout:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// WorkerObservation is one entry of a worker's ring as reported on a
|
|
// heartbeat. It is the input to the incident projection, never a stored event.
|
|
type WorkerObservation struct {
|
|
Message string `json:"message"`
|
|
Count int `json:"count"`
|
|
First time.Time `json:"first"`
|
|
Last time.Time `json:"last"`
|
|
}
|
|
|
|
// ObservationIncident is one durable incident: a signature seen by one worker,
|
|
// on one lease when there is one, from its first occurrence to the boundary
|
|
// that ended it.
|
|
type ObservationIncident struct {
|
|
ID string `json:"observation_id"`
|
|
WorkerID string `json:"worker_id"`
|
|
Incarnation string `json:"incarnation,omitempty"`
|
|
TaskID string `json:"task_id,omitempty"`
|
|
LeaseEpoch string `json:"lease_epoch,omitempty"`
|
|
// Signature is the message with its task ids, commit shas, paths and
|
|
// durations replaced, so the same failure on two tasks shares it. Grouping
|
|
// on the raw message would make every task its own kind of problem.
|
|
Signature string `json:"signature"`
|
|
Detail string `json:"detail,omitempty"`
|
|
FirstSeen time.Time `json:"first_seen"`
|
|
// LastSeen is the last actual occurrence. ClosedAt is when Orchestra
|
|
// finalized the incident, which is later and often much later: an incident
|
|
// stays open until its lease ends, and open means "not yet final evidence"
|
|
// rather than "happening right now".
|
|
LastSeen time.Time `json:"last_seen,omitempty"`
|
|
ClosedAt time.Time `json:"closed_at,omitempty"`
|
|
RepeatCount int `json:"repeat_count,omitempty"`
|
|
CloseReason ObservationCloseReason `json:"close_reason,omitempty"`
|
|
}
|
|
|
|
// Key identifies an incident. Two workers reporting the same failure are two
|
|
// incidents, and so are two leases of one task.
|
|
func (i ObservationIncident) Key() string {
|
|
return strings.Join([]string{i.WorkerID, i.TaskID, i.LeaseEpoch, i.Signature}, "\x00")
|
|
}
|
|
|
|
var (
|
|
// Case-insensitive: a task id appears upper-case in a message and
|
|
// lower-case inside a pane name, and the live ledger's first run showed
|
|
// pane names keeping their task, which would give the same failure a
|
|
// different signature on every task.
|
|
observationID = regexp.MustCompile(`(?i)\b[0-9A-HJKMNP-TV-Z]{26}\b`)
|
|
observationSHA = regexp.MustCompile(`\b[0-9a-f]{7,64}\b`)
|
|
observationDuration = regexp.MustCompile(`\b\d+(\.\d+)?(ns|us|µs|ms|s|m|h)(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))*\b`)
|
|
observationNumber = regexp.MustCompile(`\b\d+\b`)
|
|
observationPath = regexp.MustCompile(`(/[\w.-]+){2,}`)
|
|
)
|
|
|
|
// ObservationSignature collapses one message to the kind of failure it is.
|
|
// "lease A not renewed" and "lease B not renewed" are the same problem seen
|
|
// twice, which is the whole basis of counting recurrence across tasks.
|
|
func ObservationSignature(message string) string {
|
|
s := strings.TrimSpace(message)
|
|
s = observationID.ReplaceAllString(s, "<id>")
|
|
s = observationPath.ReplaceAllString(s, "<path>")
|
|
s = observationDuration.ReplaceAllString(s, "<dur>")
|
|
s = observationSHA.ReplaceAllString(s, "<sha>")
|
|
s = observationNumber.ReplaceAllString(s, "<n>")
|
|
s = strings.Join(strings.Fields(s), " ")
|
|
if len(s) > 200 {
|
|
s = s[:200]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ObservationTaskID reads the task a failure was about out of the message
|
|
// itself. The ring is a history: it holds entries from tasks that ended long
|
|
// ago, so the worker's currently active task is the wrong answer for most of
|
|
// them, and attributing an old failure to whatever is running now would be a
|
|
// fabricated association.
|
|
func ObservationTaskID(message string) string {
|
|
if m := observationTaskID.FindString(message); m != "" {
|
|
return strings.ToUpper(m)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
var observationTaskID = regexp.MustCompile(`(?i)\b[0-9A-HJKMNP-TV-Z]{26}\b`)
|
|
|
|
func ValidateObservationIncidentOpened(p map[string]any) error {
|
|
if id, _ := p["observation_id"].(string); strings.TrimSpace(id) == "" {
|
|
return fmt.Errorf("%w: observation_id required", ErrInvalid)
|
|
}
|
|
if w, _ := p["worker_id"].(string); strings.TrimSpace(w) == "" {
|
|
return fmt.Errorf("%w: worker_id required", ErrInvalid)
|
|
}
|
|
if sig, _ := p["signature"].(string); strings.TrimSpace(sig) == "" {
|
|
return fmt.Errorf("%w: signature required", ErrInvalid)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ValidateObservationIncidentClosed(p map[string]any) error {
|
|
if id, _ := p["observation_id"].(string); strings.TrimSpace(id) == "" {
|
|
return fmt.Errorf("%w: observation_id required", ErrInvalid)
|
|
}
|
|
reason, _ := p["close_reason"].(string)
|
|
if !ObservationCloseReason(reason).Valid() {
|
|
return fmt.Errorf("%w: close_reason %q is not a close reason", ErrInvalid, reason)
|
|
}
|
|
if c, ok := p["repeat_count"].(float64); ok && c < 0 {
|
|
return fmt.Errorf("%w: repeat_count cannot be negative", ErrInvalid)
|
|
}
|
|
return nil
|
|
}
|