Files
go-sip/internal/agent/result_journal.go
T

270 lines
8.7 KiB
Go

package agent
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
agentpb "git.ipao.vip/rogee/go-sip/gen/agent"
"google.golang.org/protobuf/proto"
)
// ResultEntry never includes audio, OSS credentials or a signed URL. Prepared
// results must not be reported: a PUT could have been interrupted or rejected.
type ResultEntry struct {
SourceEventID string `json:"source_event_id"`
DispatcherID string `json:"dispatcher_id"`
TenantID int64 `json:"tenant_id"`
Payload []byte `json:"payload"`
Upload *agentpb.UploadObservation `json:"upload,omitempty"`
State string `json:"state"`
}
type ResultJournal struct{ Root string }
func (j ResultJournal) path(id string) (string, error) {
if j.Root == "" || id == "" || strings.ContainsAny(id, "/\\\x00") {
return "", errors.New("private result journal and original call identity are required")
}
sum := sha256.Sum256([]byte(id))
return filepath.Join(j.Root, ".results", hex.EncodeToString(sum[:])+".json"), nil
}
func (j ResultJournal) prepare() (string, error) {
if j.Root == "" {
return "", errors.New("private result journal root is required")
}
if err := checkRecoveryDirectory(j.Root); err != nil {
return "", err
}
dir := filepath.Join(j.Root, ".results")
if err := os.Mkdir(dir, 0700); err != nil && !errors.Is(err, os.ErrExist) {
return "", err
}
if err := checkRecoveryDirectory(dir); err != nil {
return "", err
}
return dir, nil
}
func (j ResultJournal) save(entry ResultEntry, state string) error {
if entry.DispatcherID == "" || entry.TenantID <= 0 || !json.Valid(entry.Payload) || len(entry.Payload) == 0 || entry.Upload != nil || entry.State != "" {
return errors.New("original result identity or payload is invalid")
}
path, err := j.path(entry.SourceEventID)
if err != nil {
return err
}
dir, err := j.prepare()
if err != nil {
return err
}
entry.State = state
data, err := json.Marshal(entry)
if err != nil {
return err
}
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return fmt.Errorf("save original result: %w", err)
}
written, writeErr := file.Write(data)
if writeErr == nil && written != len(data) {
writeErr = io.ErrShortWrite
}
syncErr := file.Sync()
closeErr := file.Close()
if err := firstError(writeErr, syncErr, closeErr); err != nil {
return fmt.Errorf("persist original result: %w", err)
}
return syncDirectory(dir)
}
func (j ResultJournal) SaveReady(entry ResultEntry) error { return j.save(entry, "ready") }
func (j ResultJournal) SavePrepared(entry ResultEntry) error { return j.save(entry, "prepared") }
func (j ResultJournal) SaveEndPending(entry ResultEntry) error { return j.save(entry, "end_pending") }
func (j ResultJournal) load(id string) (ResultEntry, string, error) {
path, err := j.path(id)
if err != nil {
return ResultEntry{}, "", err
}
info, err := os.Lstat(path)
if err != nil {
return ResultEntry{}, "", err
}
if !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Size() > 1<<20 {
return ResultEntry{}, "", errors.New("original result file is not private or exceeds the supported size")
}
data, err := os.ReadFile(path)
if err != nil {
return ResultEntry{}, "", err
}
var entry ResultEntry
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&entry); err != nil {
return ResultEntry{}, "", err
}
var trailing any
if err := decoder.Decode(&trailing); err == nil {
return ResultEntry{}, "", errors.New("original result has trailing data")
} else if !errors.Is(err, io.EOF) {
return ResultEntry{}, "", err
}
if entry.SourceEventID != id || entry.DispatcherID == "" || entry.TenantID <= 0 || !json.Valid(entry.Payload) || len(entry.Payload) == 0 || (entry.State != "prepared" && entry.State != "ready" && entry.State != "end_pending") || (entry.State != "ready" && entry.Upload != nil) {
return ResultEntry{}, "", errors.New("original result journal identity or state is invalid")
}
return entry, path, nil
}
func (j ResultJournal) MarkEnded(id string) error {
entry, path, err := j.load(id)
if err != nil {
return err
}
if entry.State != "end_pending" {
return errors.New("original end is not awaiting confirmation")
}
entry.State = "ready"
return writeJSONAtomic(path, entry)
}
func (j ResultJournal) MarkUploaded(id string, observation *agentpb.UploadObservation) error {
entry, path, err := j.load(id)
if err != nil {
return err
}
if entry.State != "prepared" || observation == nil || observation.GetUploadId() == "" || observation.GetRecordingId() == "" || observation.GetPutStatusCode() < 200 || observation.GetPutStatusCode() >= 300 || observation.GetSizeBytes() <= 0 || observation.GetChecksumSha256() == "" {
return errors.New("confirmed original upload observation is required")
}
entry.State = "ready"
entry.Upload = proto.Clone(observation).(*agentpb.UploadObservation)
return writeJSONAtomic(path, entry)
}
func (j ResultJournal) Ack(id string) error {
entry, path, err := j.load(id)
if err != nil {
return err
}
if entry.State != "ready" {
return errors.New("cannot remove a result whose upload outcome is unknown")
}
if err := os.Remove(path); err != nil {
return err
}
return syncDirectory(filepath.Dir(path))
}
// DiscardPrepared is only called after the durable audio+result recovery entry
// has itself reported the same final result. Old entries have no journal.
func (j ResultJournal) DiscardPrepared(id string) error {
entry, path, err := j.load(id)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
if entry.State != "prepared" {
return errors.New("cannot discard a confirmed result")
}
if err := os.Remove(path); err != nil {
return err
}
return syncDirectory(filepath.Dir(path))
}
// Recover only re-reports ready results under their original identity. A
// prepared upload is left untouched for inspection, never retried or assumed
// uploaded. Independent entries continue when one is blocked.
func (j ResultJournal) Recover(ctx context.Context, report func(context.Context, ResultEntry) error) error {
return j.RecoverWithEnd(ctx, nil, report)
}
func (j ResultJournal) RecoverWithEnd(ctx context.Context, confirmEnd, report func(context.Context, ResultEntry) error) error {
dir, err := j.prepare()
if err != nil {
return err
}
items, err := os.ReadDir(dir)
if err != nil {
return err
}
var blocked []error
for _, item := range items {
if err := ctx.Err(); err != nil {
return errors.Join(append(blocked, err)...)
}
path := filepath.Join(dir, item.Name())
if item.IsDir() || !strings.HasSuffix(item.Name(), ".json") || len(item.Name()) != 69 {
blocked = append(blocked, errors.New("unexpected private result journal entry"))
continue
}
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Size() > 1<<20 {
blocked = append(blocked, errors.New("private result file is invalid"))
continue
}
data, err := os.ReadFile(path)
if err != nil {
blocked = append(blocked, err)
continue
}
var id struct {
SourceEventID string `json:"source_event_id"`
}
if err := json.Unmarshal(data, &id); err != nil {
blocked = append(blocked, errors.New("invalid private result journal"))
continue
}
entry, originalPath, err := j.load(id.SourceEventID)
if err != nil || path != originalPath {
blocked = append(blocked, errors.New("result journal identity mismatch"))
continue
}
if entry.State == "end_pending" {
if confirmEnd == nil {
blocked = append(blocked, errors.New("original call end requires Dispatcher confirmation"))
continue
}
if err := confirmEnd(ctx, entry); err != nil {
blocked = append(blocked, fmt.Errorf("confirm original call end: %w", err))
continue
}
if err := j.MarkEnded(entry.SourceEventID); err != nil {
blocked = append(blocked, fmt.Errorf("persist original call end: %w", err))
continue
}
entry.State = "ready"
}
if entry.State != "ready" {
log.Printf("Agent recovery blocked stage=original_upload_outcome event_digest=%s", item.Name()[:12])
blocked = append(blocked, errors.New("original upload outcome requires inspection"))
continue
}
if report == nil {
blocked = append(blocked, errors.New("result reporter is required"))
continue
}
if err := report(ctx, entry); err != nil {
blocked = append(blocked, fmt.Errorf("report original result: %w", err))
continue
}
if err := j.Ack(entry.SourceEventID); err != nil {
blocked = append(blocked, fmt.Errorf("confirm original result: %w", err))
}
}
return errors.Join(blocked...)
}