Skip to content
edgesync Artifact [4d019d0eb6]
Login

Artifact [4d019d0eb6]

Artifact 4d019d0eb63057f9c5a3fe76e7f8243e04f09946c389e34c26b40e8e87134a1a:


package agent

import (
	"context"
	"errors"
	"log/slog"

	libfossil "github.com/danmestas/libfossil"
	"github.com/nats-io/nats.go"
)

// syncOutcome carries the result of a single per-target sync attempt
// inside one poll cycle. Either result or err is set (never both).
type syncOutcome struct {
	target syncTarget
	result *libfossil.SyncResult
	err    error
}

// emitSyncOutcomes logs the per-target outcomes from one poll cycle
// and invokes PostSyncHook on every successful outcome.
//
// Failed outcomes log at ERROR when no target in this cycle succeeded
// (sync as a whole failed; operator action likely needed). When at
// least one target succeeded, failed outcomes log at DEBUG instead —
// sync as a whole succeeded, isolated transport-level noise shouldn't
// trip alerting.
//
// Independently, errors classified as benign by isBenignSyncError
// (today: nats.ErrNoResponders, context.Canceled) log at DEBUG
// regardless of whether other targets succeeded. ErrNoResponders is
// the round-0 subject-interest race, expected at every cold-start;
// context.Canceled is the agent-shutdown teardown artifact. Neither
// is user-actionable.
//
// The legacy `a.logf("sync error [%s]: %v", ...)` callback fires
// regardless of slog level so harness-supplied loggers stay
// informed without slog depending on them.
func (a *Agent) emitSyncOutcomes(ctx context.Context, outcomes []syncOutcome) {
	anySuccess := false
	for _, o := range outcomes {
		if o.err == nil {
			anySuccess = true
			break
		}
	}

	for _, o := range outcomes {
		if o.err != nil {
			a.logf("sync error [%s]: %v", o.target.label, o.err)
			level := slog.LevelError
			if anySuccess || isBenignSyncError(o.err) {
				level = slog.LevelDebug
			}
			slog.LogAttrs(ctx, level, "sync error",
				slog.String("target", o.target.label),
				slog.Any("error", o.err),
			)
			continue
		}
		a.logf("sync done [%s]: ↑%d ↓%d rounds=%d",
			o.target.label, o.result.FilesSent, o.result.FilesRecvd, o.result.Rounds)
		slog.DebugContext(ctx, "sync details",
			"target", o.target.label,
			"rounds", o.result.Rounds,
			"files_sent", o.result.FilesSent,
			"files_recv", o.result.FilesRecvd,
			"bytes_sent", o.result.BytesSent,
			"bytes_recv", o.result.BytesRecvd,
			"uv_sent", o.result.UVFilesSent,
			"uv_recv", o.result.UVFilesRecvd,
			"errors", len(o.result.Errors),
		)
		for _, e := range o.result.Errors {
			a.logf("sync warning [%s]: %s", o.target.label, e)
		}
		if a.config.PostSyncHook != nil {
			a.config.PostSyncHook(o.result)
		}
	}
}

// isBenignSyncError reports whether a sync error is known to be
// non-actionable and should always log at DEBUG, even if it is the
// only error in the cycle. Today's classifications:
//
//   - nats.ErrNoResponders: round-0 NATS request fired before the
//     hub's serve-nats subscriber finished propagating its
//     subject-interest through the leaf-node mesh. Expected on every
//     cold-start; the next round resolves.
//   - context.Canceled: agent shutdown interrupted an in-flight sync.
//     The operator chose to stop; the in-flight result is moot.
//
// Both classifications are matched via errors.Is so wrapped errors
// from libfossil's transport layer are caught.
func isBenignSyncError(err error) bool {
	if err == nil {
		return false
	}
	if errors.Is(err, nats.ErrNoResponders) {
		return true
	}
	if errors.Is(err, context.Canceled) {
		return true
	}
	return false
}