Skip to content
quarry Artifact [bfd1ff0955]
Login

Artifact [bfd1ff0955]

Artifact bfd1ff0955475ac1422c496e56f4ebbcf2d4fc600119513115944132a6637eda:


package events

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"strconv"
	"time"

	"github.com/nats-io/nats.go"
	"github.com/nats-io/nats.go/jetstream"
)

// CounterStream holds monotone tallies — clones, fetches, anything the
// forge counts rather than records.
//
// It is a JetStream counter stream, which is a PN-counter CRDT: the
// server keeps the running total and a per-source slot, so two nodes
// incrementing concurrently converge instead of clobbering, and a retry
// after a timeout cannot double-count. That last property is the reason
// this is not a SELECT/UPDATE in the OLTP — a sync round that is retried
// by a flaky client must not add two.
//
// What it deliberately does NOT do is allocate: a counter converges on a
// TOTAL, never on "which number is mine". Anything needing a unique
// value wants KV's revision-checked update instead.
const (
	CounterStream  = "QUARRYCOUNT"
	counterSubject = "quarry.count.>"
	incrHeader     = "Nats-Incr"
)

// counterConfig. Counter streams are Limits-retention only, and the
// server refuses to turn AllowMsgCounter off once on, so this shape is
// permanent for the stream's life. MaxMsgsPerSubject 1 keeps only the
// running total per counter: the history is the event stream's job
// (RFC 0005), and a tally that kept every increment would grow without
// bound to say a number it already knows.
//
// Declared against the newer jetstream API because AllowMsgCounter
// exists only there; everything else in this package speaks the legacy
// JetStreamContext, and both ride the same connection.
var counterConfig = jetstream.StreamConfig{
	Name:              CounterStream,
	Subjects:          []string{counterSubject},
	Retention:         jetstream.LimitsPolicy,
	AllowMsgCounter:   true,
	AllowDirect:       true,
	MaxMsgsPerSubject: 1,
}

// CounterSubject is where a repo's tally of one kind lives.
func CounterSubject(repo, name string) string {
	return "quarry.count." + repo + "." + name
}

// Count adds delta to a tally.
//
// Best-effort like Raise: a lost increment costs one off a statistic,
// where failing the operation that triggered it would cost a clone. The
// new total is deliberately not returned — reading it back is Total's
// job, and a caller that only increments should not be made to care.
func (s *Server) Count(repo, name string, delta int64) error {
	if s == nil {
		return nil
	}
	if repo == "" || name == "" {
		return fmt.Errorf("events: a counter needs a repo and a name")
	}
	m := nats.NewMsg(CounterSubject(repo, name))
	// The sign is required by the server's own regex: +1, never 1.
	m.Header.Set(incrHeader, fmt.Sprintf("%+d", delta))
	if _, err := s.js.PublishMsg(m); err != nil {
		return fmt.Errorf("events: count %s/%s: %w", repo, name, err)
	}
	return nil
}

// Total reads one tally. A counter that has never been incremented is
// zero rather than an error: "nobody has cloned this yet" is an answer,
// not a failure.
func (s *Server) Total(repo, name string) (int64, error) {
	if s == nil {
		return 0, nil
	}
	msg, err := s.js.GetLastMsg(CounterStream, CounterSubject(repo, name))
	if err != nil {
		if errors.Is(err, nats.ErrMsgNotFound) || errors.Is(err, nats.ErrStreamNotFound) {
			return 0, nil
		}
		return 0, fmt.Errorf("events: total %s/%s: %w", repo, name, err)
	}
	return parseCounter(msg.Data)
}

// parseCounter reads the server's body form, {"val":"<total>"}. The
// total is a STRING in the JSON because the server uses big.Int and a
// JSON number would lose precision past 2^53 — so it is parsed as one
// rather than as a number that happens to look right for a while.
func parseCounter(body []byte) (int64, error) {
	var v struct {
		Val string `json:"val"`
	}
	if err := json.Unmarshal(body, &v); err != nil {
		return 0, fmt.Errorf("events: counter body %q: %w", body, err)
	}
	if v.Val == "" {
		return 0, nil
	}
	n, err := strconv.ParseInt(v.Val, 10, 64)
	if err != nil {
		return 0, fmt.Errorf("events: counter value %q: %w", v.Val, err)
	}
	return n, nil
}

// addCounterStream creates the tally stream through the newer API. An
// existing stream is left exactly as it is: AllowMsgCounter cannot be
// turned off once on, so a config update here could only ever fail or
// be a no-op, and failing would take down a server whose counters were
// working fine.
func addCounterStream(nc *nats.Conn) error {
	js, err := jetstream.New(nc)
	if err != nil {
		return fmt.Errorf("events: jetstream: %w", err)
	}
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if _, err := js.CreateStream(ctx, counterConfig); err != nil {
		if errors.Is(err, jetstream.ErrStreamNameAlreadyInUse) {
			return nil
		}
		return fmt.Errorf("events: counter stream: %w", err)
	}
	return nil
}