diff --git a/.github/workflows/golang.yml b/.github/workflows/golang.yml index 1591479..33a756d 100644 --- a/.github/workflows/golang.yml +++ b/.github/workflows/golang.yml @@ -24,6 +24,8 @@ jobs: run: make build - name: Test run: make test + - name: Test (race detector) + run: make test-race lint: runs-on: ubuntu-latest steps: diff --git a/Makefile b/Makefile index dca6d02..5c382b4 100644 --- a/Makefile +++ b/Makefile @@ -11,13 +11,20 @@ help: ## Print info about all commands .PHONY: build build: ## Build all executables go build ./cmd/plcli + go build -o plc-replica ./cmd/replica .PHONY: all all: build .PHONY: test test: ## Run tests - go test -short ./... + go test -v -short ./... + ./extra/pg/with-test-db.sh go test -v -short -run TestGormOpStore ./replica/... + +.PHONY: test-race +test-race: ## Run tests with race detector + go test -v -short -race ./... + ./extra/pg/with-test-db.sh go test -v -short -race -run TestGormOpStore ./replica/... .PHONY: coverage-html coverage-html: ## Generate test coverage report and open in browser diff --git a/README.md b/README.md index 6e2b7f0..3f6a1da 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ `go-didplc`: Go implementation of DID PLC method ================================================ -**NOTE:** this codebase is work-in-progress: has not been reviewed and will have rapid API breaking changes. +**NOTE:** This codebase is pre-v1.0, there may be breaking API changes. DID PLC is a self-authenticating [DID](https://www.w3.org/TR/did-core/) which is strongly-consistent, recoverable, and allows for key rotation. See for details. diff --git a/cmd/plcli/main.go b/cmd/plcli/main.go index 4ee2a6f..76b7961 100644 --- a/cmd/plcli/main.go +++ b/cmd/plcli/main.go @@ -10,7 +10,7 @@ import ( "github.com/bluesky-social/indigo/atproto/atcrypto" "github.com/bluesky-social/indigo/atproto/syntax" - "github.com/did-method-plc/go-didplc" + "github.com/did-method-plc/go-didplc/didplc" "github.com/urfave/cli/v3" ) diff --git a/cmd/replica/Dockerfile b/cmd/replica/Dockerfile new file mode 100644 index 0000000..898ea36 --- /dev/null +++ b/cmd/replica/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.25 AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go build -o /plc-replica ./cmd/replica + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=builder /plc-replica /usr/local/bin/plc-replica +WORKDIR /data +ENTRYPOINT ["plc-replica"] diff --git a/cmd/replica/README.md b/cmd/replica/README.md new file mode 100644 index 0000000..46b8a69 --- /dev/null +++ b/cmd/replica/README.md @@ -0,0 +1,64 @@ +# PLC Replica Service + +The `replica` command implements a `did:plc` read-replica service that syncs operations from an upstream PLC directory service, and exposes the standard HTTP APIs for resolving and auditing DID documents. + +It performs full cryptographic validation of all inbound PLC operations, including enforcing constraints around operation nullification. + +``` +NAME: + plc-replica - PLC directory replica server + +USAGE: + plc-replica [global options] + +GLOBAL OPTIONS: + --db-url string Database URL (e.g. sqlite://replica.db?_journal_mode=WAL, postgres://user:pass@host/db) (default: "sqlite://replica.db?mode=rwc&cache=shared&_journal_mode=WAL") [$DATABASE_URL] + --bind string HTTP server listen address (default: ":6780") [$REPLICA_BIND] + --metrics-addr string Metrics HTTP server listen address (default: ":9464") [$METRICS_ADDR] + --no-ingest Disable ingestion from upstream directory [$NO_INGEST] + --upstream-directory-url string Upstream PLC directory base URL (default: "https://plc.directory") [$UPSTREAM_DIRECTORY_URL] + --cursor-override int Initial cursor value used to sync from the upstream host. May be useful when switching the upstream host (default: -1) [$CURSOR_OVERRIDE] + --num-workers int Number of validation worker threads (0 = auto) (default: 0) [$NUM_WORKERS] + --log-level string Log level (debug, info, warn, error) (default: "info") [$LOG_LEVEL] + --log-json Output logs in JSON format [$LOG_JSON] + --help, -h show help +``` + +## HTTP API + +It exposes the following endpoints, as described in the `did:plc` [spec](https://web.plc.directory/spec/v0.1/did-plc) + +- `GET /{did}` (see Format Differences below) +- `GET /{did}/data` +- `GET /{did}/log` +- `GET /{did}/log/audit` +- `GET /{did}/log/last` + +Actually, some of these aren't mentioned in the spec, but they are in the [API docs](https://web.plc.directory/api/redoc) and implemented by the [reference implementation](https://github.com/did-method-plc/did-method-plc/tree/main/packages/server). + +It does not support POSTing DID updates to `/{did}` - it only discovers new operations by importing from the upstream instance. + +It does not currently implement the `/export` and `/export/stream` endpoints, although it may in the future. + +### DID Document Format Differences + +The reference implementation returns DID documents in `application/did+ld+json` format, whereas this replica returns them in `application/did+json` format. Both are described in the [DID specification](https://www.w3.org/TR/did-1.0/), but in practical terms the difference is that the `@context` field is missing. + +Secondarily, service identifiers include the DID ([relevant issue](https://github.com/did-method-plc/did-method-plc/issues/90)) + +Although these differences are spec-compliant, some PLC client libraries may have trouble with these differences. + + +## Databases + +The service supports either PostgreSQL or SQLite. Postgres has more horizontal scaling headroom on the read path, but SQLite performs better when backfilling. + +When using PostgresSQL, you may wish to set `synchronous_commit` to `off`. This can improve ingest performance, at the cost potentially losing some recently-committed data after e.g. a power failure. Since this is a replica service, it should be able to quickly re-sync from the upstream host if that happens, so no data is truly lost. + +## Backfilling + +When the service is started for the first time, it has to "backfill" the entire PLC operation history from the upstream instance. Until it "catches up", it will not provide up-to-date responses to queries. Depending on your hardware, it should take less than 24h to complete a backfill (at time of writing). Backfilling tends to be bottlenecked by database throughput. + +## Metrics and Tracing + +In addition to the `--metrics-addr` CLI flag, the [`OTEL_EXPORTER_OTLP_ENDPOINT`](https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/#otel_exporter_otlp_endpoint) env var may be set to configure trace reporting. diff --git a/cmd/replica/main.go b/cmd/replica/main.go new file mode 100644 index 0000000..b4195ac --- /dev/null +++ b/cmd/replica/main.go @@ -0,0 +1,160 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "runtime" + + "github.com/did-method-plc/go-didplc/replica" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/urfave/cli/v3" + "golang.org/x/sync/errgroup" +) + +func main() { + cmd := &cli.Command{ + Name: "plc-replica", + Usage: "PLC directory replica server", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "db-url", + Usage: "Database URL (e.g. sqlite://replica.db?_journal_mode=WAL, postgres://user:pass@host/db)", + Value: "sqlite://replica.db?mode=rwc&cache=shared&_journal_mode=WAL", + Sources: cli.EnvVars("DATABASE_URL"), + }, + &cli.StringFlag{ + Name: "bind", + Usage: "HTTP server listen address", + Value: ":6780", + Sources: cli.EnvVars("REPLICA_BIND"), + }, + &cli.StringFlag{ + Name: "metrics-addr", + Usage: "Metrics HTTP server listen address", + Value: ":9464", + Sources: cli.EnvVars("METRICS_ADDR"), + }, + &cli.BoolFlag{ + Name: "no-ingest", + Usage: "Disable ingestion from upstream directory", + Sources: cli.EnvVars("NO_INGEST"), + }, + &cli.StringFlag{ + Name: "upstream-directory-url", + Usage: "Upstream PLC directory base URL", + Value: "https://plc.directory", + Sources: cli.EnvVars("UPSTREAM_DIRECTORY_URL"), + }, + &cli.Int64Flag{ + Name: "cursor-override", + Usage: "Initial cursor value used to sync from the upstream host. May be useful when switching the upstream host", + Value: -1, + Sources: cli.EnvVars("CURSOR_OVERRIDE"), + }, + &cli.IntFlag{ + Name: "num-workers", + Usage: "Number of validation worker threads (0 = auto)", + Value: 0, + Sources: cli.EnvVars("NUM_WORKERS"), + }, + &cli.StringFlag{ + Name: "log-level", + Usage: "Log level (debug, info, warn, error)", + Value: "info", + Sources: cli.EnvVars("LOG_LEVEL"), + }, + &cli.BoolFlag{ + Name: "log-json", + Usage: "Output logs in JSON format", + Sources: cli.EnvVars("LOG_JSON"), + }, + }, + Action: run, + } + + if err := cmd.Run(context.Background(), os.Args); err != nil { + slog.Error("fatal error", "error", err) + os.Exit(1) + } +} + +func run(ctx context.Context, cmd *cli.Command) error { + // Parse configuration + dbURL := cmd.String("db-url") + httpAddr := cmd.String("bind") + metricsAddr := cmd.String("metrics-addr") + noIngest := cmd.Bool("no-ingest") + directoryURL := cmd.String("upstream-directory-url") + cursorOverride := cmd.Int64("cursor-override") + numWorkers := cmd.Int("num-workers") + logLevel := cmd.String("log-level") + logJSON := cmd.Bool("log-json") + + // Initialize logger + var level slog.Level + switch logLevel { + case "debug": + level = slog.LevelDebug + case "info": + level = slog.LevelInfo + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + default: + level = slog.LevelInfo + } + + var handler slog.Handler + opts := &slog.HandlerOptions{Level: level} + if logJSON { + handler = slog.NewJSONHandler(os.Stdout, opts) + } else { + handler = slog.NewTextHandler(os.Stdout, opts) + } + logger := slog.New(handler) + slog.SetDefault(logger) + + if numWorkers <= 0 { + numWorkers = runtime.NumCPU() + } + + otelShutdown, err := setupOTel(ctx) + if err != nil { + return fmt.Errorf("otel setup: %w", err) + } + defer otelShutdown(context.Background()) + + store, err := replica.NewGormOpStore(dbURL, logger) + if err != nil { + return fmt.Errorf("failed to create store: %w", err) + } + + state := replica.NewReplicaState() + server := replica.NewServer(store, state, httpAddr, logger) + g, gctx := errgroup.WithContext(ctx) + + g.Go(server.Run) + + g.Go(func() error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + slog.Info("metrics server listening", "addr", metricsAddr) + return http.ListenAndServe(metricsAddr, mux) + }) + + if !noIngest { + ingestor, err := replica.NewIngestor(store, state, directoryURL, cursorOverride, numWorkers, logger) + if err != nil { + return err + } + g.Go(func() error { + return ingestor.Run(gctx) + }) + } + + return g.Wait() +} diff --git a/cmd/replica/otel.go b/cmd/replica/otel.go new file mode 100644 index 0000000..fd0287a --- /dev/null +++ b/cmd/replica/otel.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "errors" + "os" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/exporters/prometheus" + "go.opentelemetry.io/otel/propagation" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" +) + +func setupOTel(ctx context.Context) (shutdown func(context.Context) error, err error) { + res, err := resource.New(ctx, + resource.WithAttributes( + semconv.ServiceName("plc-replica"), + ), + ) + if err != nil { + return nil, err + } + + var shutdowns []func(context.Context) error + + // Traces: OTLP HTTP exporter, only enabled if an endpoint is configured. + if os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") != "" || os.Getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") != "" { + traceExporter, err := otlptracehttp.New(ctx) + if err != nil { + return nil, err + } + + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(traceExporter), + sdktrace.WithResource(res), + ) + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + shutdowns = append(shutdowns, tp.Shutdown) + } + + // Metrics: Prometheus exporter, served via /metrics HTTP endpoint. + promExporter, err := prometheus.New() + if err != nil { + return nil, err + } + + mp := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(promExporter), + sdkmetric.WithResource(res), + ) + otel.SetMeterProvider(mp) + shutdowns = append(shutdowns, mp.Shutdown) + + shutdown = func(ctx context.Context) error { + var errs []error + for _, fn := range shutdowns { + errs = append(errs, fn(ctx)) + } + return errors.Join(errs...) + } + return shutdown, nil +} diff --git a/client.go b/didplc/client.go similarity index 100% rename from client.go rename to didplc/client.go diff --git a/diddoc.go b/didplc/diddoc.go similarity index 100% rename from diddoc.go rename to didplc/diddoc.go diff --git a/doc.go b/didplc/doc.go similarity index 100% rename from doc.go rename to didplc/doc.go diff --git a/log.go b/didplc/log.go similarity index 100% rename from log.go rename to didplc/log.go diff --git a/manual_test.go b/didplc/manual_test.go similarity index 100% rename from manual_test.go rename to didplc/manual_test.go diff --git a/operation.go b/didplc/operation.go similarity index 100% rename from operation.go rename to didplc/operation.go diff --git a/operation_export_test.go b/didplc/operation_export_test.go similarity index 93% rename from operation_export_test.go rename to didplc/operation_export_test.go index e0cf558..6150f54 100644 --- a/operation_export_test.go +++ b/didplc/operation_export_test.go @@ -47,7 +47,7 @@ func TestExportLogEntryValidate(t *testing.T) { assert := assert.New(t) - knownBadCIDsList, err := loadJSONStringArray("testdata/known_bad_cids.json") + knownBadCIDsList, err := loadJSONStringArray("../testdata/known_bad_cids.json") if err != nil { t.Fatal(err) } @@ -57,7 +57,7 @@ func TestExportLogEntryValidate(t *testing.T) { } // "out.jsonlines" is data from `plc.directory/export` - f, err := os.Open("../plc_scrape/out.jsonlines") + f, err := os.Open("../../plc_scrape/out.jsonlines") if err != nil { t.Fatal(err) } @@ -132,7 +132,7 @@ func TestExportAuditLogEntryValidate(t *testing.T) { assert := assert.New(t) - knownBadDIDsList, err := loadJSONStringArray("testdata/known_bad_dids.json") + knownBadDIDsList, err := loadJSONStringArray("../testdata/known_bad_dids.json") if err != nil { t.Fatal(err) } @@ -141,7 +141,7 @@ func TestExportAuditLogEntryValidate(t *testing.T) { isKnownBadDID[did] = true } - f, err := os.Open("../plc_scrape/plc_audit_log.jsonlines") + f, err := os.Open("../../plc_scrape/plc_audit_log.jsonlines") if err != nil { t.Fatal(err) } diff --git a/operation_test.go b/didplc/operation_test.go similarity index 59% rename from operation_test.go rename to didplc/operation_test.go index 5fd00ac..fe7b5aa 100644 --- a/operation_test.go +++ b/didplc/operation_test.go @@ -14,29 +14,29 @@ import ( ) var VALID_LOG_PATHS = [...]string{ - "testdata/log_bskyapp.json", - "testdata/log_legacy_dholms.json", - "testdata/log_bnewbold_robocracy.json", - "testdata/log_empty_rotation_keys.json", - "testdata/log_duplicate_rotation_keys.json", // XXX: invalid according to spec, valid according to TS reference impl - "testdata/log_nullification.json", - "testdata/log_nullification_nontrivial.json", - "testdata/log_nullification_at_exactly_72h.json", - "testdata/log_nullified_tombstone.json", - "testdata/log_tombstone.json", + "../testdata/log_bskyapp.json", + "../testdata/log_legacy_dholms.json", + "../testdata/log_bnewbold_robocracy.json", + "../testdata/log_empty_rotation_keys.json", + "../testdata/log_duplicate_rotation_keys.json", // XXX: invalid according to spec, valid according to TS reference impl + "../testdata/log_nullification.json", + "../testdata/log_nullification_nontrivial.json", + "../testdata/log_nullification_at_exactly_72h.json", + "../testdata/log_nullified_tombstone.json", + "../testdata/log_tombstone.json", } var INVALID_LOG_PATHS = [...]string{ - "testdata/log_invalid_sig_b64_padding_chars.json", - "testdata/log_invalid_sig_b64_padding_bits.json", - "testdata/log_invalid_sig_b64_newline.json", - "testdata/log_invalid_sig_der.json", - "testdata/log_invalid_sig_p256_high_s.json", - "testdata/log_invalid_sig_k256_high_s.json", - "testdata/log_invalid_nullification_reused_key.json", - "testdata/log_invalid_nullification_too_slow.json", - "testdata/log_invalid_update_nullified.json", - "testdata/log_invalid_update_tombstoned.json", + "../testdata/log_invalid_sig_b64_padding_chars.json", + "../testdata/log_invalid_sig_b64_padding_bits.json", + "../testdata/log_invalid_sig_b64_newline.json", + "../testdata/log_invalid_sig_der.json", + "../testdata/log_invalid_sig_p256_high_s.json", + "../testdata/log_invalid_sig_k256_high_s.json", + "../testdata/log_invalid_nullification_reused_key.json", + "../testdata/log_invalid_nullification_too_slow.json", + "../testdata/log_invalid_update_nullified.json", + "../testdata/log_invalid_update_tombstoned.json", } func loadTestLogEntries(t *testing.T, p string) []LogEntry { @@ -100,43 +100,43 @@ func TestLogEntryInvalid(t *testing.T) { func TestAuditLogInvalidSigEncoding(t *testing.T) { assert := assert.New(t) - entries := loadTestLogEntries(t, "testdata/log_invalid_sig_b64_padding_chars.json") + entries := loadTestLogEntries(t, "../testdata/log_invalid_sig_b64_padding_chars.json") assert.ErrorContains(VerifyOpLog(entries), "illegal base64") - entries = loadTestLogEntries(t, "testdata/log_invalid_sig_b64_padding_bits.json") + entries = loadTestLogEntries(t, "../testdata/log_invalid_sig_b64_padding_bits.json") assert.ErrorContains(VerifyOpLog(entries), "illegal base64") - entries = loadTestLogEntries(t, "testdata/log_invalid_sig_b64_newline.json") + entries = loadTestLogEntries(t, "../testdata/log_invalid_sig_b64_newline.json") assert.ErrorContains(VerifyOpLog(entries), "CRLF") - entries = loadTestLogEntries(t, "testdata/log_invalid_sig_der.json") - assert.ErrorContains(VerifyOpLog(entries), "crytographic signature invalid") // Note: there is no reliable way to detect DER-encoded signatures syntactically, so a generic invalid signature error is expected + entries = loadTestLogEntries(t, "../testdata/log_invalid_sig_der.json") + assert.ErrorContains(VerifyOpLog(entries), "cryptographic signature invalid") // Note: there is no reliable way to detect DER-encoded signatures syntactically, so a generic invalid signature error is expected - entries = loadTestLogEntries(t, "testdata/log_invalid_sig_p256_high_s.json") - assert.ErrorContains(VerifyOpLog(entries), "crytographic signature invalid") + entries = loadTestLogEntries(t, "../testdata/log_invalid_sig_p256_high_s.json") + assert.ErrorContains(VerifyOpLog(entries), "cryptographic signature invalid") - entries = loadTestLogEntries(t, "testdata/log_invalid_sig_k256_high_s.json") - assert.ErrorContains(VerifyOpLog(entries), "crytographic signature invalid") + entries = loadTestLogEntries(t, "../testdata/log_invalid_sig_k256_high_s.json") + assert.ErrorContains(VerifyOpLog(entries), "cryptographic signature invalid") } func TestAuditLogInvalidNullification(t *testing.T) { assert := assert.New(t) - entries := loadTestLogEntries(t, "testdata/log_invalid_nullification_reused_key.json") - assert.ErrorContains(VerifyOpLog(entries), "crytographic signature invalid") // TODO: This is the expected error message for the current impl logic. This could be improved. + entries := loadTestLogEntries(t, "../testdata/log_invalid_nullification_reused_key.json") + assert.ErrorContains(VerifyOpLog(entries), "cryptographic signature invalid") // TODO: This is the expected error message for the current impl logic. This could be improved. - entries = loadTestLogEntries(t, "testdata/log_invalid_nullification_too_slow.json") + entries = loadTestLogEntries(t, "../testdata/log_invalid_nullification_too_slow.json") assert.ErrorContains(VerifyOpLog(entries), "cannot nullify op after 72h") - entries = loadTestLogEntries(t, "testdata/log_invalid_update_nullified.json") + entries = loadTestLogEntries(t, "../testdata/log_invalid_update_nullified.json") assert.ErrorContains(VerifyOpLog(entries), "prev CID is nullified") } func TestAuditLogInvalidTombstoneUpdate(t *testing.T) { assert := assert.New(t) - entries := loadTestLogEntries(t, "testdata/log_invalid_update_tombstoned.json") + entries := loadTestLogEntries(t, "../testdata/log_invalid_update_tombstoned.json") assert.ErrorContains(VerifyOpLog(entries), "no keys to verify against") // TODO: This is the expected error message for the current impl logic. This could be improved. } diff --git a/opstore.go b/didplc/opstore.go similarity index 100% rename from opstore.go rename to didplc/opstore.go diff --git a/extra/pg/README.md b/extra/pg/README.md new file mode 100644 index 0000000..2c0ce3c --- /dev/null +++ b/extra/pg/README.md @@ -0,0 +1,65 @@ +# pg + +Helpers for working with postgres (borrowed from https://github.com/did-method-plc/did-method-plc/tree/main/packages/server/pg ) + +## Usage + +### `with-test-db.sh` + +This script allows you to run any command with a fresh, ephemeral/single-use postgres database available. When the script starts a Dockerized postgres container starts-up, and when the script completes that container is removed. + +The environment variable `DATABASE_URL` will be set with a connection string that can be used to connect to the database. The [`PG*` environment variables](https://www.postgresql.org/docs/current/libpq-envars.html) that are recognized by libpq (i.e. used by the `psql` client) are also set. + +**Example** + +``` +$ ./with-test-db.sh psql -c 'select 1;' +[+] Running 1/1 + ⠿ Container pg-db_test-1 Healthy 1.8s + + ?column? +---------- + 1 +(1 row) + + +[+] Running 1/1 + ⠿ Container pg-db_test-1 Stopped 0.1s +Going to remove pg-db_test-1 +[+] Running 1/0 + ⠿ Container pg-db_test-1 Removed +``` + +### `docker-compose.yaml` + +The Docker compose file can be used to run containerized versions of postgres either for single use (as is used by `with-test-db.sh`), or for longer-term use. These are setup as separate services named `test_db` and `db` respectively. In both cases the database is available on the host machine's `localhost` and credentials are: + +- Username: pg +- Password: password + +However, each service uses a different port, documented below, to avoid conflicts. + +#### `test_db` service for single use + +The single-use `test_db` service does not have any persistent storage. When the container is removed, data in the database disappears with it. + +This service runs on port `5433`. + +``` +$ docker compose up test_db # start container +$ docker compose stop test_db # stop container +$ docker compose rm test_db # remove container +``` + +#### `db` service for persistent use + +The `db` service has persistent storage on the host machine managed by Docker under a volume named `pg_plc_db`. When the container is removed, data in the database will remain on the host machine. In order to start fresh, you would need to remove the volume. + +This service runs on port `5432`. + +``` +$ docker compose up db -d # start container +$ docker compose stop db # stop container +$ docker compose rm db # remove container +$ docker volume rm pg_plc_db # remove volume +``` diff --git a/extra/pg/docker-compose.yaml b/extra/pg/docker-compose.yaml new file mode 100644 index 0000000..5c32837 --- /dev/null +++ b/extra/pg/docker-compose.yaml @@ -0,0 +1,27 @@ +version: '3.8' +services: + # An ephermerally-stored postgres database for single-use test runs + db_test: &db_test + image: postgres:14.4-alpine + environment: + - POSTGRES_USER=pg + - POSTGRES_PASSWORD=password + ports: + - '5433:5432' + # Healthcheck ensures db is queryable when `docker-compose up --wait` completes + healthcheck: + test: 'pg_isready -U pg' + interval: 500ms + timeout: 10s + retries: 20 + # A persistently-stored postgres database + db: + <<: *db_test + ports: + - '5432:5432' + healthcheck: + disable: true + volumes: + - plc_db:/var/lib/postgresql/data +volumes: + plc_db: diff --git a/extra/pg/with-test-db.sh b/extra/pg/with-test-db.sh new file mode 100755 index 0000000..60d9729 --- /dev/null +++ b/extra/pg/with-test-db.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env sh + +# Example usage: +# ./with-test-db.sh psql postgresql://pg:password@localhost:5433/postgres -c 'select 1;' + +dir=$(dirname $0) +compose_file="$dir/docker-compose.yaml" + +docker compose -f $compose_file up --wait --force-recreate db_test +echo # newline + +trap on_sigint INT +on_sigint() { + echo # newline + docker compose -f $compose_file rm -f --stop --volumes db_test + exit $? +} + +# Based on creds in compose.yaml +export PGPORT=5433 +export PGHOST=localhost +export PGUSER=pg +export PGPASSWORD=password +export PGDATABASE=postgres +export DATABASE_URL="postgresql://pg:password@localhost:5433/postgres" +until pg_isready -q; do sleep 0.1; done +"$@" +code=$? + +echo # newline +docker compose -f $compose_file rm -f --stop --volumes db_test + +exit $code diff --git a/go.mod b/go.mod index e176848..29cedca 100644 --- a/go.mod +++ b/go.mod @@ -5,35 +5,82 @@ go 1.25 toolchain go1.25.1 require ( - github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe - github.com/ipfs/go-cid v0.4.1 - github.com/ipfs/go-ipld-cbor v0.1.0 - github.com/stretchr/testify v1.10.0 - github.com/urfave/cli/v3 v3.4.1 + github.com/bluesky-social/indigo v0.0.0-20260211004331-05cbfdd42d8f + github.com/carlmjohnson/versioninfo v0.22.5 + github.com/emirpasic/gods v1.18.1 + github.com/gorilla/websocket v1.5.3 + github.com/ipfs/go-cid v0.6.0 + github.com/ipfs/go-ipld-cbor v0.2.1 + github.com/orandin/slog-gorm v1.4.0 + github.com/prometheus/client_golang v1.23.2 + github.com/stretchr/testify v1.11.1 + github.com/urfave/cli/v3 v3.6.2 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 + go.opentelemetry.io/otel v1.40.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 + go.opentelemetry.io/otel/exporters/prometheus v0.62.0 + go.opentelemetry.io/otel/metric v1.40.0 + go.opentelemetry.io/otel/sdk v1.40.0 + go.opentelemetry.io/otel/sdk/metric v1.40.0 + golang.org/x/sync v0.19.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 ) require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/ipfs/go-block-format v0.2.0 // indirect - github.com/ipfs/go-ipfs-util v0.0.3 // indirect - github.com/ipfs/go-ipld-format v0.6.0 // indirect - github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8 // indirect + github.com/ipfs/boxo v0.36.0 // indirect + github.com/ipfs/go-block-format v0.2.3 // indirect + github.com/ipfs/go-ipld-format v0.6.3 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.8.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/mattn/go-sqlite3 v1.14.34 // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/mr-tron/base58 v1.2.0 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect github.com/multiformats/go-multibase v0.2.0 // indirect github.com/multiformats/go-multihash v0.2.3 // indirect - github.com/multiformats/go-varint v0.0.7 // indirect + github.com/multiformats/go-varint v0.1.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/otlptranslator v1.0.0 // indirect + github.com/prometheus/procfs v0.19.2 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect - github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect + github.com/whyrusleeping/cbor-gen v0.3.1 // indirect gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect - golang.org/x/crypto v0.21.0 // indirect - golang.org/x/sys v0.22.0 // indirect - golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - lukechampine.com/blake3 v1.2.1 // indirect + lukechampine.com/blake3 v1.4.1 // indirect ) diff --git a/go.sum b/go.sum index 4d21060..bc0a134 100644 --- a/go.sum +++ b/go.sum @@ -1,26 +1,77 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe h1:VBhaqE5ewQgXbY5SfSWFZC/AwHFo7cHxZKFYi2ce9Yo= -github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe/go.mod h1:RuQVrCGm42QNsgumKaR6se+XkFKfCPNwdCiTvqKRUck= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bluesky-social/indigo v0.0.0-20260211004331-05cbfdd42d8f h1:PNZ4+hIUGB/D+xyTT4Hlkmd6gJr1vDrXpaDlG5j8Lss= +github.com/bluesky-social/indigo v0.0.0-20260211004331-05cbfdd42d8f/go.mod h1:VG/LeqLGNI3Ew7lsYixajnZGFfWPv144qbUddh+Oyag= +github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= +github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= -github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM= -github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= -github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= -github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= -github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= -github.com/ipfs/go-ipld-cbor v0.1.0 h1:dx0nS0kILVivGhfWuB6dUpMa/LAwElHPw1yOGYopoYs= -github.com/ipfs/go-ipld-cbor v0.1.0/go.mod h1:U2aYlmVrJr2wsUBU67K4KgepApSZddGRDWBYR0H4sCk= -github.com/ipfs/go-ipld-format v0.6.0 h1:VEJlA2kQ3LqFSIm5Vu6eIlSxD/Ze90xtc4Meten1F5U= -github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg= +github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c h1:7lF+Vz0LqiRidnzC1Oq86fpX1q/iEv2KJdrCtttYjT4= +github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8 h1:NpbJl/eVbvrGE0MJ6X16X9SAifesl6Fwxg/YmCvubRI= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8/go.mod h1:mi7YA+gCzVem12exXy46ZespvGtX/lZmD/RLnQhVW7U= +github.com/ipfs/boxo v0.36.0 h1:DarrMBM46xCs6GU6Vz+AL8VUyXykqHAqZYx8mR0Oics= +github.com/ipfs/boxo v0.36.0/go.mod h1:92hnRXfP5ScKEIqlq9Ns7LR1dFXEVADKWVGH0fjk83k= +github.com/ipfs/go-block-format v0.2.3 h1:mpCuDaNXJ4wrBJLrtEaGFGXkferrw5eqVvzaHhtFKQk= +github.com/ipfs/go-block-format v0.2.3/go.mod h1:WJaQmPAKhD3LspLixqlqNFxiZ3BZ3xgqxxoSR/76pnA= +github.com/ipfs/go-cid v0.6.0 h1:DlOReBV1xhHBhhfy/gBNNTSyfOM6rLiIx9J7A4DGf30= +github.com/ipfs/go-cid v0.6.0/go.mod h1:NC4kS1LZjzfhK40UGmpXv5/qD2kcMzACYJNntCUiDhQ= +github.com/ipfs/go-ipld-cbor v0.2.1 h1:H05yEJbK/hxg0uf2AJhyerBDbjOuHX4yi+1U/ogRa7E= +github.com/ipfs/go-ipld-cbor v0.2.1/go.mod h1:x9Zbeq8CoE5R2WicYgBMcr/9mnkQ0lHddYWJP2sMV3A= +github.com/ipfs/go-ipld-format v0.6.3 h1:9/lurLDTotJpZSuL++gh3sTdmcFhVkCwsgx2+rAh4j8= +github.com/ipfs/go-ipld-format v0.6.3/go.mod h1:74ilVN12NXVMIV+SrBAyC05UJRk0jVvGqdmrcYZvCBk= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= -github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= @@ -33,12 +84,28 @@ github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivnc github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= -github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= -github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= +github.com/multiformats/go-varint v0.1.0 h1:i2wqFp4sdl3IcIxfAonHQV9qU5OsZ4Ts9IOoETFs5dI= +github.com/multiformats/go-varint v0.1.0/go.mod h1:5KVAVXegtfmNQQm/lCY+ATvDzvJJhSkUlGQV9wgObdI= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsTUU= +github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= @@ -47,35 +114,87 @@ github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hg github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= -github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM= -github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= +github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8= +github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= -github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= -github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= +github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= +github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= +go.opentelemetry.io/otel/exporters/prometheus v0.62.0 h1:krvC4JMfIOVdEuNPTtQ0ZjCiXrybhv+uOHMfHRmnvVo= +go.opentelemetry.io/otel/exporters/prometheus v0.62.0/go.mod h1:fgOE6FM/swEnsVQCqCnbOfRV4tOnWPg7bVeo4izBuhQ= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= -golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= +google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= -lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= +lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= diff --git a/replica/database.go b/replica/database.go new file mode 100644 index 0000000..b449532 --- /dev/null +++ b/replica/database.go @@ -0,0 +1,311 @@ +package replica + +import ( + "context" + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/url" + "time" + + "github.com/did-method-plc/go-didplc/didplc" + slogGorm "github.com/orandin/slog-gorm" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// storedOp wraps didplc.OpEnum for SQL storage +type storedOp didplc.OpEnum + +func newStoredOp(op didplc.Operation) storedOp { + return storedOp(*op.AsOpEnum()) +} + +func (o *storedOp) Operation() didplc.Operation { + return (*didplc.OpEnum)(o).AsOperation() +} + +func (o storedOp) Value() (driver.Value, error) { + return json.Marshal((*didplc.OpEnum)(&o)) +} + +func (o *storedOp) Scan(value any) error { + var bytes []byte + switch v := value.(type) { + case string: + bytes = []byte(v) + case []byte: + bytes = v + default: + return fmt.Errorf("unsupported type for storedOp: %T", value) + } + return json.Unmarshal(bytes, (*didplc.OpEnum)(o)) +} + +// Head represents the current head CID for a DID +type Head struct { + DID string `gorm:"column:did;primaryKey"` + CID string `gorm:"column:cid;not null"` +} + +// OperationRecord represents a stored operation with its status in the database +type OperationRecord struct { + DID string `gorm:"column:did;primaryKey;index:idx_operations_did_created_at,priority:1"` + CID string `gorm:"column:cid;primaryKey"` + CreatedAt time.Time `gorm:"column:created_at;not null;index:idx_operations_did_created_at,priority:2"` + Nullified bool `gorm:"column:nullified;not null;default:0"` + LastChild string `gorm:"column:last_child"` + AllowedKeysCount int `gorm:"column:allowed_keys_count;not null"` + OpData storedOp `gorm:"column:op_data;not null"` + Seq *int64 `gorm:"column:seq;index:idx_seq"` // currently unused +} + +// Note: couldn't call the type Operation because that'd get confusing with didplc.Operation +func (OperationRecord) TableName() string { + return "operations" +} + +// for tracking the ingest cursor +type HostCursor struct { + Host string `gorm:"primaryKey"` + Seq int64 `gorm:"not null"` +} + +func opRecToEntry(opRec *OperationRecord, cid string) *didplc.OpEntry { + op := opRec.OpData.Operation() + rotationKeys := op.EquivalentRotationKeys() + allowedKeys := rotationKeys[:opRec.AllowedKeysCount] + return &didplc.OpEntry{ + DID: opRec.DID, + CreatedAt: opRec.CreatedAt, + Nullified: opRec.Nullified, + LastChild: opRec.LastChild, + AllowedKeys: allowedKeys, + Op: op, + OpCid: cid, + } +} + +// GormOpStore implements didplc.OpStore using a database backend +type GormOpStore struct { + db *gorm.DB +} + +var _ didplc.OpStore = (*GormOpStore)(nil) + +// NewGormOpStoreWithDialector creates a new database-backed operation store with a custom dialector +func NewGormOpStoreWithDialector(dialector gorm.Dialector, logger *slog.Logger) (*GormOpStore, error) { + db, err := gorm.Open(dialector, &gorm.Config{ + //PrepareStmt: true, // Doesn't seem to work well with postgres + TranslateError: true, + Logger: slogGorm.New( + slogGorm.WithHandler(logger.With("component", "opstore").Handler()), + slogGorm.WithTraceAll(), + slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelDebug), + slogGorm.SetLogLevel(slogGorm.SlowQueryLogType, slog.LevelWarn), + slogGorm.SetLogLevel(slogGorm.ErrorLogType, slog.LevelError), + ), + }) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + // Configure connection pool + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("failed to get database handle: %w", err) + } + + sqlDB.SetMaxOpenConns(40) // with postgres, seems like less can be more... + sqlDB.SetMaxIdleConns(10) + sqlDB.SetConnMaxLifetime(time.Hour) + + // Auto-migrate the schema + if err := db.AutoMigrate(&Head{}, &OperationRecord{}, &HostCursor{}); err != nil { + return nil, fmt.Errorf("failed to migrate schema: %w", err) + } + + return &GormOpStore{ + db: db, + }, nil +} + +// NewGormOpStore creates a new database-backed operation store from a URL. +// The URL scheme determines the database type: +// - sqlite://path/to/db +// - postgres://user:pass@host/db (or postgresql://) +func NewGormOpStore(dbURL string, logger *slog.Logger) (*GormOpStore, error) { + u, err := url.Parse(dbURL) + if err != nil { + return nil, fmt.Errorf("failed to parse database URL: %w", err) + } + + var dialector gorm.Dialector + switch u.Scheme { + case "sqlite": + dbPath := u.Host + u.Path + logger.Info("using database", "type", "sqlite", "path", dbPath) + dialector = sqlite.Open(dbPath) + case "postgres", "postgresql": + logger.Info("using database", "type", "postgres") + dialector = postgres.Open(dbURL) + default: + return nil, fmt.Errorf("unsupported database URL scheme: %q (expected sqlite://, postgres://, or postgresql://)", u.Scheme) + } + + return NewGormOpStoreWithDialector(dialector, logger) +} + +// GetLatest implements didplc.OpStore +func (db *GormOpStore) GetLatest(ctx context.Context, did string) (*didplc.OpEntry, error) { + var opRec OperationRecord + result := db.db.WithContext(ctx). + Joins("JOIN heads ON heads.did = operations.did AND heads.cid = operations.cid"). + Where("operations.did = ?", did). + Take(&opRec) + if result.Error != nil { + if result.Error == gorm.ErrRecordNotFound { + return nil, nil // DID not found + } + return nil, fmt.Errorf("database error: %w", result.Error) + } + + return opRecToEntry(&opRec, opRec.CID), nil +} + +// GetEntry implements didplc.OpStore +func (db *GormOpStore) GetEntry(ctx context.Context, did string, cid string) (*didplc.OpEntry, error) { + var opRec OperationRecord + result := db.db.WithContext(ctx).Where("did = ? AND cid = ?", did, cid).Take(&opRec) + if result.Error != nil { + if result.Error == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, fmt.Errorf("database error: %w", result.Error) + } + + return opRecToEntry(&opRec, cid), nil +} + +// GetAllEntries implements didplc.OpStore +func (db *GormOpStore) GetAllEntries(ctx context.Context, did string) ([]*didplc.OpEntry, error) { + var opRecs []OperationRecord + result := db.db.WithContext(ctx).Where("did = ?", did).Order("created_at ASC").Find(&opRecs) + if result.Error != nil { + return nil, fmt.Errorf("database error: %w", result.Error) + } + + entries := make([]*didplc.OpEntry, 0, len(opRecs)) + for _, opRec := range opRecs { + entries = append(entries, opRecToEntry(&opRec, opRec.CID)) + } + + return entries, nil +} + +// CommitOperations implements didplc.OpStore +func (db *GormOpStore) CommitOperations(ctx context.Context, ops []*didplc.PreparedOperation) error { + // Begin transaction + return db.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for _, prepOp := range ops { + opData := newStoredOp(prepOp.Op) + + if prepOp.PrevHead == "" { + // Genesis operation + // Insert new operation + newOp := OperationRecord{ + DID: prepOp.DID, + CID: prepOp.OpCid, + CreatedAt: prepOp.CreatedAt, + Nullified: false, + LastChild: "", + AllowedKeysCount: len(prepOp.Op.EquivalentRotationKeys()), + OpData: opData, + } + if err := tx.Create(&newOp).Error; err != nil { + return fmt.Errorf("failed to create operation: %w", err) + } + + // Insert new head + newHead := Head{ + DID: prepOp.DID, + CID: prepOp.OpCid, + } + if err := tx.Create(&newHead).Error; err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return didplc.ErrHeadMismatch + } + return fmt.Errorf("failed to create head: %w", err) + } + } else { + // Non-genesis operation + // Mark nullified operations + for _, nullifiedCid := range prepOp.NullifiedOps { + if err := tx.Model(&OperationRecord{}).Where("did = ? AND cid = ?", prepOp.DID, nullifiedCid).Update("nullified", true).Error; err != nil { + return fmt.Errorf("failed to mark operation as nullified: %w", err) + } + } + + // Update previous operation's last_child and allowed_keys_count + if err := tx.Model(&OperationRecord{}).Where("did = ? AND cid = ?", prepOp.DID, prepOp.Op.PrevCIDStr()).Updates(map[string]interface{}{ + "last_child": prepOp.OpCid, + "allowed_keys_count": prepOp.KeyIndex, + }).Error; err != nil { + return fmt.Errorf("failed to update previous operation: %w", err) + } + + // Insert new operation + newOp := OperationRecord{ + DID: prepOp.DID, + CID: prepOp.OpCid, + CreatedAt: prepOp.CreatedAt, + Nullified: false, + LastChild: "", + AllowedKeysCount: len(prepOp.Op.EquivalentRotationKeys()), + OpData: opData, + } + if err := tx.Create(&newOp).Error; err != nil { + return fmt.Errorf("failed to create operation: %w", err) + } + + // Update head with optimistic locking check + result := tx.Model(&Head{}).Where("did = ? AND cid = ?", prepOp.DID, prepOp.PrevHead).Update("cid", prepOp.OpCid) + if result.Error != nil { + return fmt.Errorf("failed to update head: %w", result.Error) + } else if result.RowsAffected != 1 { + return didplc.ErrHeadMismatch + } + } + } + + return nil + }) +} + +func (db *GormOpStore) PutCursor(ctx context.Context, host string, seq int64) error { + // upsert + result := db.db.WithContext(ctx).Clauses(clause.OnConflict{ + UpdateAll: true, + }).Create(&HostCursor{ + Host: host, + Seq: seq, + }) + return result.Error +} + +// returns 0 if not found (since new hosts should start from 0) +func (db *GormOpStore) GetCursor(ctx context.Context, host string) (int64, error) { + var hostCursor HostCursor + result := db.db.WithContext(ctx).Where("host = ?", host).Take(&hostCursor) + if result.Error == gorm.ErrRecordNotFound { + return 0, nil + } + if result.Error != nil { + return 0, result.Error + } + return hostCursor.Seq, nil +} diff --git a/replica/database_test.go b/replica/database_test.go new file mode 100644 index 0000000..afedc40 --- /dev/null +++ b/replica/database_test.go @@ -0,0 +1,486 @@ +package replica + +import ( + "context" + "io" + "log/slog" + "os" + "sync" + "testing" + "time" + + "github.com/did-method-plc/go-didplc/didplc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" +) + +func newTestStore(t *testing.T) *GormOpStore { + t.Helper() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + if dbURL := os.Getenv("DATABASE_URL"); dbURL != "" { + store, err := NewGormOpStore(dbURL, logger) + require.NoError(t, err) + // Truncate tables for test isolation + require.NoError(t, store.db.Exec("TRUNCATE operations, heads, host_cursors").Error) + t.Cleanup(func() { + store.db.Exec("TRUNCATE operations, heads, host_cursors") + sqlDB, _ := store.db.DB() + sqlDB.Close() + }) + return store + } + + store, err := NewGormOpStoreWithDialector(sqlite.Open(":memory:"), logger) + require.NoError(t, err) + sqlDB, err := store.db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + t.Cleanup(func() { sqlDB.Close() }) + return store +} + +func TestGormOpStore_GetLatest_Empty(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + entry, err := store.GetLatest(ctx, "did:plc:nonexistent") + assert.NoError(t, err) + assert.Nil(t, entry) +} + +func TestGormOpStore_GetLatest_AfterGenesis(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + entry, err := store.GetLatest(ctx, did) + require.NoError(t, err) + require.NotNil(t, entry) + assert.Equal(t, did, entry.DID) + assert.Equal(t, genesisCID, entry.OpCid) + assert.False(t, entry.Nullified) +} + +func TestGormOpStore_GetLatest_AfterUpdate(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + update := createUpdate(t, priv, []string{pubKey}, genesisCID) + t1 := t0.Add(time.Hour) + prepOp, err := didplc.VerifyOperation(ctx, store, did, update, t1) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp})) + + entry, err := store.GetLatest(ctx, did) + require.NoError(t, err) + require.NotNil(t, entry) + assert.Equal(t, update.CID().String(), entry.OpCid) +} + +func TestGormOpStore_GetEntry_Found(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + entry, err := store.GetEntry(ctx, did, genesisCID) + require.NoError(t, err) + require.NotNil(t, entry) + assert.Equal(t, did, entry.DID) + assert.Equal(t, genesisCID, entry.OpCid) +} + +func TestGormOpStore_GetEntry_NotFound(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + entry, err := store.GetEntry(ctx, "did:plc:nonexistent", "bafyreifakecid") + assert.NoError(t, err) + assert.Nil(t, entry) +} + +func TestGormOpStore_GetAllEntries_Ordered(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + update := createUpdate(t, priv, []string{pubKey}, genesisCID) + t1 := t0.Add(time.Hour) + prepOp, err := didplc.VerifyOperation(ctx, store, did, update, t1) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp})) + + entries, err := store.GetAllEntries(ctx, did) + require.NoError(t, err) + require.Len(t, entries, 2) + assert.Equal(t, genesisCID, entries[0].OpCid, "first entry should be genesis (earlier created_at)") + assert.Equal(t, update.CID().String(), entries[1].OpCid, "second entry should be update") +} + +func TestGormOpStore_GetAllEntries_Empty(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + entries, err := store.GetAllEntries(ctx, "did:plc:nonexistent") + assert.NoError(t, err) + assert.Empty(t, entries) +} + +func TestGormOpStore_CommitGenesis_DuplicateDID(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + + // Commit genesis the first time + prepOp, err := didplc.VerifyOperation(ctx, store, did, genesis, t0) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp})) + + // Create a second, different genesis op for the same DID. + // It must have a different CID to avoid the operations UNIQUE constraint, + // so the Head UNIQUE constraint (ErrHeadMismatch) is the one that fires. + genesis2, _ := createGenesis(t, priv, []string{pubKey}) + prepOp2 := &didplc.PreparedOperation{ + DID: did, + PrevHead: "", + CreatedAt: t0.Add(time.Second), + Op: genesis2, + OpCid: genesis2.CID().String(), + } + err = store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp2}) + assert.ErrorIs(t, err, didplc.ErrHeadMismatch) +} + +func TestGormOpStore_CommitUpdate_HeadMismatch(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + // Create a valid update + update := createUpdate(t, priv, []string{pubKey}, genesisCID) + t1 := t0.Add(time.Hour) + prepOp, err := didplc.VerifyOperation(ctx, store, did, update, t1) + require.NoError(t, err) + + // Tamper with PrevHead to simulate a concurrent modification + prepOp.PrevHead = "bafyreiwrongprevhead" + err = store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp}) + assert.ErrorIs(t, err, didplc.ErrHeadMismatch) +} + +func TestGormOpStore_CommitUpdate_InterleavedHeadMismatch(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + // Both updates chain off genesis (same PrevHead) + updateA := createUpdate(t, priv, []string{pubKey}, genesisCID) + updateB := createUpdate(t, priv, []string{pubKey}, genesisCID) + + // Verify both while head is still genesis + prepA, err := didplc.VerifyOperation(ctx, store, did, updateA, t0.Add(1*time.Hour)) + require.NoError(t, err) + prepB, err := didplc.VerifyOperation(ctx, store, did, updateB, t0.Add(2*time.Hour)) + require.NoError(t, err) + + // Commit A — succeeds, advances head past genesis + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepA})) + + // Commit B — should fail: its PrevHead is genesis but head is now A + err = store.CommitOperations(ctx, []*didplc.PreparedOperation{prepB}) + assert.ErrorIs(t, err, didplc.ErrHeadMismatch) +} + +func TestGormOpStore_CommitNullification(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + privRecovery, pubKeyRecovery := generateKey(t) + priv, pubKey := generateKey(t) + rotationKeys := []string{pubKeyRecovery, pubKey} + + genesis, did := createGenesis(t, privRecovery, rotationKeys) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + // Regular update signed by key at index 1 + update := createUpdate(t, priv, rotationKeys, genesisCID) + t1 := t0.Add(time.Hour) + prepOp1, err := didplc.VerifyOperation(ctx, store, did, update, t1) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp1})) + + // Nullification signed by recovery key (prev = genesis) + nullify := createUpdate(t, privRecovery, rotationKeys, genesisCID) + t2 := t1.Add(time.Hour) + prepOp2, err := didplc.VerifyOperation(ctx, store, did, nullify, t2) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp2})) + + // Verify nullification state via GetAllEntries + entries, err := store.GetAllEntries(ctx, did) + require.NoError(t, err) + require.Len(t, entries, 3) + + // The update (index 1) should be nullified + assert.False(t, entries[0].Nullified, "genesis should not be nullified") + assert.True(t, entries[1].Nullified, "update should be nullified") + assert.False(t, entries[2].Nullified, "nullification op should not be nullified") +} + +func TestGormOpStore_CommitBatch_MultipleDIDs(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + // Create two independent DIDs + priv1, pubKey1 := generateKey(t) + genesis1, did1 := createGenesis(t, priv1, []string{pubKey1}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + + priv2, pubKey2 := generateKey(t) + genesis2, did2 := createGenesis(t, priv2, []string{pubKey2}) + + prepOp1, err := didplc.VerifyOperation(ctx, store, did1, genesis1, t0) + require.NoError(t, err) + prepOp2, err := didplc.VerifyOperation(ctx, store, did2, genesis2, t0) + require.NoError(t, err) + + // Commit both in a single batch + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp1, prepOp2})) + + // Verify both DIDs exist + entry1, err := store.GetLatest(ctx, did1) + require.NoError(t, err) + assert.NotNil(t, entry1) + assert.Equal(t, did1, entry1.DID) + + entry2, err := store.GetLatest(ctx, did2) + require.NoError(t, err) + assert.NotNil(t, entry2) + assert.Equal(t, did2, entry2.DID) +} + +func TestGormOpStore_CursorRoundTrip(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + // Default cursor for unknown host + seq, err := store.GetCursor(ctx, "plc.directory") + assert.NoError(t, err) + assert.Equal(t, int64(0), seq) + + // Put and get + require.NoError(t, store.PutCursor(ctx, "plc.directory", 42)) + seq, err = store.GetCursor(ctx, "plc.directory") + assert.NoError(t, err) + assert.Equal(t, int64(42), seq) + + // Upsert (update existing) + require.NoError(t, store.PutCursor(ctx, "plc.directory", 100)) + seq, err = store.GetCursor(ctx, "plc.directory") + assert.NoError(t, err) + assert.Equal(t, int64(100), seq) +} + +func TestGormOpStore_CursorMultipleHosts(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + require.NoError(t, store.PutCursor(ctx, "host-a", 10)) + require.NoError(t, store.PutCursor(ctx, "host-b", 20)) + + seqA, err := store.GetCursor(ctx, "host-a") + assert.NoError(t, err) + assert.Equal(t, int64(10), seqA) + + seqB, err := store.GetCursor(ctx, "host-b") + assert.NoError(t, err) + assert.Equal(t, int64(20), seqB) +} + +func TestGormOpStore_AllowedKeysCount_AfterUpdate(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + priv1, pubKey1 := generateKey(t) + _, pubKey2 := generateKey(t) + rotationKeys := []string{pubKey1, pubKey2} + + genesis, did := createGenesis(t, priv1, rotationKeys) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + // Update signed by key at index 1 (priv1 is at index 0 in rotationKeys, + // but we sign with priv1 which matches pubKey1 at index 0) + update := createUpdate(t, priv1, rotationKeys, genesisCID) + t1 := t0.Add(time.Hour) + prepOp, err := didplc.VerifyOperation(ctx, store, did, update, t1) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp})) + + // The previous (genesis) op should have allowed_keys_count set to KeyIndex + var genesisRec OperationRecord + require.NoError(t, store.db.Where("did = ? AND cid = ?", did, genesisCID).Take(&genesisRec).Error) + assert.Equal(t, prepOp.KeyIndex, genesisRec.AllowedKeysCount, + "genesis op's allowed_keys_count should be updated to the signing key index") +} + +func TestGormOpStore_CommitBatch_PartialFailureRollback(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + + // DID-A: fresh genesis, will succeed on its own + privA, pubKeyA := generateKey(t) + genesisA, didA := createGenesis(t, privA, []string{pubKeyA}) + prepA, err := didplc.VerifyOperation(ctx, store, didA, genesisA, t0) + require.NoError(t, err) + + // DID-B: commit genesis, then prepare two competing updates while head is still genesis + privB, pubKeyB := generateKey(t) + genesisB, didB := createGenesis(t, privB, []string{pubKeyB}) + genesisBCID := commitGenesis(t, ctx, store, genesisB, didB, t0) + + updateB1 := createUpdate(t, privB, []string{pubKeyB}, genesisBCID) + updateB2 := createUpdate(t, privB, []string{pubKeyB}, genesisBCID) + prepB1, err := didplc.VerifyOperation(ctx, store, didB, updateB1, t0.Add(1*time.Hour)) + require.NoError(t, err) + prepB2, err := didplc.VerifyOperation(ctx, store, didB, updateB2, t0.Add(2*time.Hour)) + require.NoError(t, err) + + // Advance DID-B's head — prepB2's PrevHead is now stale + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepB1})) + + // Batch: A (would succeed) then B2 (will fail with head mismatch) + err = store.CommitOperations(ctx, []*didplc.PreparedOperation{prepA, prepB2}) + assert.Error(t, err, "batch should fail due to DID-B head mismatch") + + // DID-A must NOT have been committed — transaction should have rolled back + entryA, err := store.GetLatest(ctx, didA) + assert.NoError(t, err) + assert.Nil(t, entryA, "DID-A should not exist: batch rollback must be atomic") +} + +func TestGormOpStore_ConcurrentUpdateRace(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + // Prepare two updates both chaining off genesis + updateA := createUpdate(t, priv, []string{pubKey}, genesisCID) + prepA, err := didplc.VerifyOperation(ctx, store, did, updateA, t0.Add(1*time.Hour)) + require.NoError(t, err) + + updateB := createUpdate(t, priv, []string{pubKey}, genesisCID) + prepB, err := didplc.VerifyOperation(ctx, store, did, updateB, t0.Add(2*time.Hour)) + require.NoError(t, err) + + // Race them + var wg sync.WaitGroup + errs := make([]error, 2) + wg.Add(2) + go func() { + defer wg.Done() + errs[0] = store.CommitOperations(ctx, []*didplc.PreparedOperation{prepA}) + }() + go func() { + defer wg.Done() + errs[1] = store.CommitOperations(ctx, []*didplc.PreparedOperation{prepB}) + }() + wg.Wait() + + // Exactly one should succeed, the other should fail + succeeded := 0 + for _, err := range errs { + if err == nil { + succeeded++ + } + } + assert.Equal(t, 1, succeeded, "exactly one concurrent commit should win") + + // Head should be consistent — points to whichever update won + head, err := store.GetLatest(ctx, did) + require.NoError(t, err) + require.NotNil(t, head) + assert.True(t, + head.OpCid == updateA.CID().String() || head.OpCid == updateB.CID().String(), + "head should be one of the two updates") +} + +func TestGormOpStore_ConcurrentGenesisRace(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + + // The same signed genesis op submitted twice concurrently. + // The PLC DID is derived from the signed genesis bytes, so two different + // signatures produce two different DIDs — the only realistic duplicate + // genesis scenario is the exact same op arriving twice. + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + prep, err := didplc.VerifyOperation(ctx, store, did, genesis, t0) + require.NoError(t, err) + + // Race the same PreparedOperation from two goroutines + var wg sync.WaitGroup + errs := make([]error, 2) + wg.Add(2) + go func() { + defer wg.Done() + errs[0] = store.CommitOperations(ctx, []*didplc.PreparedOperation{prep}) + }() + go func() { + defer wg.Done() + errs[1] = store.CommitOperations(ctx, []*didplc.PreparedOperation{prep}) + }() + wg.Wait() + + // Exactly one should succeed + succeeded := 0 + for _, err := range errs { + if err == nil { + succeeded++ + } + } + assert.Equal(t, 1, succeeded, "exactly one concurrent genesis should win") + + // DID should exist with exactly one entry + entries, err := store.GetAllEntries(ctx, did) + require.NoError(t, err) + assert.Len(t, entries, 1, "should have exactly one genesis op") +} diff --git a/replica/inflight.go b/replica/inflight.go new file mode 100644 index 0000000..96f37ee --- /dev/null +++ b/replica/inflight.go @@ -0,0 +1,108 @@ +package replica + +import ( + "sync" + + "github.com/emirpasic/gods/sets/hashset" + "github.com/emirpasic/gods/sets/treeset" + "github.com/emirpasic/gods/utils" +) + +// The ingestor validates operations concurrently and commits them in batches. +// +// It is important that: +// +// - All operations for a particular DID are processed in upstream-seq order. +// +// - No two operations for the same DID are "in flight" concurrently, +// where "in flight" means they're present somewhere in the validation->commit pipeline. +// +// - When the replica process is shut down and restarted, it should resume ingest from a cursor +// value that is definitely lower than any as-yet-uncommitted operations, guaranteeing that each operation +// is processed at-least-once. This means that after a restart, some operations may be processed for +// a second time - this is ok because they will not pass validation the second time, and will be ignored. +// +// The InFlight struct is central to enforcing the above constraints. +// +// [InFlight.AddInFlight] should be called before inserting an operation into the to-be-validated queue. +// If [InFlight.AddInFlight] fails (returns false, indicating that there was an already an op in-flight for the same DID), +// it is expected that the caller will retry until it succeeds (eventually, the work queue will drain). +// +// [InFlight.RemoveInFlight] should be called *after* an operation has been processed (whether it was rejected as an invalid operation, or successfully committed to the db) +// +// The implementation assumes that [InFlight.AddInFlight] will always be called with monotonically increasing seq values, and gracefully handles any "gaps" in the sequence. +// It does *not* assume any particular order to [InFlight.RemoveInFlight] calls. +type InFlight struct { + resumeCursor int64 // all seqs <= this value have already been processed and committed to db (or rejected as invalid) + dids *hashset.Set + seqs *treeset.Set // treeset means we can find the minimum efficiently + removed *treeset.Set // seqs that have been removed but are ahead of the resumeCursor + lock sync.RWMutex +} + +func NewInFlight(resumeCursor int64) *InFlight { + return &InFlight{ + resumeCursor: resumeCursor, + dids: hashset.New(), + seqs: treeset.NewWith(utils.Int64Comparator), + removed: treeset.NewWith(utils.Int64Comparator), + } +} + +func (infl *InFlight) GetResumeCursor() int64 { + infl.lock.RLock() + defer infl.lock.RUnlock() + return infl.resumeCursor +} + +// returns true on success, does nothing and returns false if the DID was already in-flight +func (infl *InFlight) AddInFlight(did string, seq int64) bool { + infl.lock.Lock() + defer infl.lock.Unlock() + + if infl.dids.Contains(did) { + return false + } + + infl.dids.Add(did) + infl.seqs.Add(seq) + + return true +} + +// always succeeds, and updates resumeCursor if appropriate +func (infl *InFlight) RemoveInFlight(did string, seq int64) { + infl.lock.Lock() + defer infl.lock.Unlock() + + // just for extra safety, do nothing if it's already been removed + if !infl.dids.Contains(did) { + // if you reached here you're using the API wrong + return + } + + infl.dids.Remove(did) + infl.seqs.Remove(seq) + infl.removed.Add(seq) + + // drain: advance cursor past completed seqs below the lowest inflight + for { + it := infl.removed.Iterator() + if !it.First() { + break + } + minRemoved := it.Value().(int64) + + inflIt := infl.seqs.Iterator() + if inflIt.First() { + minInflight := inflIt.Value().(int64) + if minRemoved >= minInflight { + break + } + } + + // minRemoved is below all inflight items (or inflight is empty), advance cursor + infl.resumeCursor = minRemoved + infl.removed.Remove(minRemoved) + } +} diff --git a/replica/inflight_test.go b/replica/inflight_test.go new file mode 100644 index 0000000..33dca28 --- /dev/null +++ b/replica/inflight_test.go @@ -0,0 +1,169 @@ +package replica + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAddInFlight_Success(t *testing.T) { + assert := assert.New(t) + + infl := NewInFlight(-1) + + added := infl.AddInFlight("did:plc:test123", 100) + assert.True(added, "first add should succeed") +} + +func TestAddInFlight_DuplicateDID(t *testing.T) { + assert := assert.New(t) + + infl := NewInFlight(-1) + + added1 := infl.AddInFlight("did:plc:test123", 100) + assert.True(added1, "first add should succeed") + + added2 := infl.AddInFlight("did:plc:test123", 200) + assert.False(added2, "adding same DID twice should return false") +} + +func TestAddInFlight_MultipleDIDs(t *testing.T) { + assert := assert.New(t) + + infl := NewInFlight(-1) + + added1 := infl.AddInFlight("did:plc:test1", 100) + assert.True(added1) + + added2 := infl.AddInFlight("did:plc:test2", 200) + assert.True(added2) + + added3 := infl.AddInFlight("did:plc:test3", 150) + assert.True(added3) +} + +func TestRemoveInFlight_TracksResumeCursor(t *testing.T) { + assert := assert.New(t) + + infl := NewInFlight(-1) + + infl.AddInFlight("did:plc:test1", 100) + infl.AddInFlight("did:plc:test2", 200) + infl.AddInFlight("did:plc:test3", 300) + infl.AddInFlight("did:plc:test4", 400) + infl.AddInFlight("did:plc:test5", 500) + + assert.Equal(infl.GetResumeCursor(), int64(-1)) + + infl.RemoveInFlight("did:plc:test2", 200) + + assert.Equal(infl.GetResumeCursor(), int64(-1)) + + infl.RemoveInFlight("did:plc:test4", 400) + + assert.Equal(infl.GetResumeCursor(), int64(-1)) + + infl.RemoveInFlight("did:plc:test1", 100) + + assert.Equal(infl.GetResumeCursor(), int64(200)) + + infl.RemoveInFlight("did:plc:test3", 300) + + assert.Equal(infl.GetResumeCursor(), int64(400)) +} + +func TestRemoveInFlight_RemoveAll(t *testing.T) { + assert := assert.New(t) + + infl := NewInFlight(-1) + + infl.AddInFlight("did:plc:test1", 100) + infl.AddInFlight("did:plc:test2", 200) + infl.AddInFlight("did:plc:test3", 300) + + infl.RemoveInFlight("did:plc:test2", 200) + infl.RemoveInFlight("did:plc:test1", 100) + assert.Equal(int64(200), infl.GetResumeCursor()) + + infl.RemoveInFlight("did:plc:test3", 300) + assert.Equal(int64(300), infl.GetResumeCursor()) +} + +func TestRemoveInFlight_ReverseOrder(t *testing.T) { + assert := assert.New(t) + + infl := NewInFlight(-1) + + infl.AddInFlight("did:plc:test1", 100) + infl.AddInFlight("did:plc:test2", 200) + infl.AddInFlight("did:plc:test3", 300) + infl.AddInFlight("did:plc:test4", 400) + + infl.RemoveInFlight("did:plc:test4", 400) + assert.Equal(int64(-1), infl.GetResumeCursor()) + + infl.RemoveInFlight("did:plc:test3", 300) + assert.Equal(int64(-1), infl.GetResumeCursor()) + + infl.RemoveInFlight("did:plc:test2", 200) + assert.Equal(int64(-1), infl.GetResumeCursor()) + + infl.RemoveInFlight("did:plc:test1", 100) + assert.Equal(int64(400), infl.GetResumeCursor()) +} + +func TestRemoveInFlight_DoubleRemove(t *testing.T) { + assert := assert.New(t) + + infl := NewInFlight(-1) + + infl.AddInFlight("did:plc:test1", 100) + infl.AddInFlight("did:plc:test2", 200) + infl.AddInFlight("did:plc:test3", 300) + + infl.RemoveInFlight("did:plc:test1", 100) + infl.RemoveInFlight("did:plc:test2", 200) + assert.Equal(int64(200), infl.GetResumeCursor()) + + // double remove should not regress cursor + infl.RemoveInFlight("did:plc:test1", 100) + assert.Equal(int64(200), infl.GetResumeCursor()) +} + +func TestRemoveInFlight_InterleavedAddsAndRemoves(t *testing.T) { + assert := assert.New(t) + + infl := NewInFlight(-1) + + infl.AddInFlight("did:plc:test1", 100) + infl.AddInFlight("did:plc:test2", 200) + + infl.RemoveInFlight("did:plc:test1", 100) + assert.Equal(int64(100), infl.GetResumeCursor()) + + infl.AddInFlight("did:plc:test3", 300) + infl.AddInFlight("did:plc:test4", 400) + + infl.RemoveInFlight("did:plc:test4", 400) + assert.Equal(int64(100), infl.GetResumeCursor()) + + infl.RemoveInFlight("did:plc:test2", 200) + assert.Equal(int64(200), infl.GetResumeCursor()) + + infl.RemoveInFlight("did:plc:test3", 300) + assert.Equal(int64(400), infl.GetResumeCursor()) +} + +func TestRemoveInFlight_AllowsReAdd(t *testing.T) { + assert := assert.New(t) + + infl := NewInFlight(-1) + + added1 := infl.AddInFlight("did:plc:test1", 100) + assert.True(added1) + + infl.RemoveInFlight("did:plc:test1", 100) + + added2 := infl.AddInFlight("did:plc:test1", 200) + assert.True(added2, "should allow re-adding DID after removal") +} diff --git a/replica/ingest.go b/replica/ingest.go new file mode 100644 index 0000000..9572ac9 --- /dev/null +++ b/replica/ingest.go @@ -0,0 +1,469 @@ +package replica + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "time" + + "github.com/carlmjohnson/versioninfo" + "github.com/did-method-plc/go-didplc/didplc" + "github.com/gorilla/websocket" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +// ExportEntry represents a single entry from the /export endpoint, which includes +// the LogEntry fields plus a sequence number. +type ExportEntry struct { + DID string `json:"did"` + CID string `json:"cid"` + Seq int64 `json:"seq"` + CreatedAt string `json:"createdAt"` + Operation didplc.OpEnum `json:"operation"` + Type string `json:"type"` + Nullified bool `json:"nullified,omitempty"` +} + +// toSequencedOp converts an ExportEntry into a SequencedOp, parsing the +// timestamp and resolving the concrete operation type. Returns nil if the +// entry should be skipped (non-sequenced_op type or invalid operation). +func (e *ExportEntry) toSequencedOp(logger *slog.Logger) (*SequencedOp, error) { + if e.Type != "sequenced_op" { + logger.Warn("skipping entry with unexpected type", "type", e.Type) + return nil, nil + } + + op := e.Operation.AsOperation() + if op == nil { + return nil, nil + } + + createdAt, err := time.Parse(time.RFC3339, e.CreatedAt) + if err != nil { + return nil, fmt.Errorf("failed to parse timestamp for %s: %w", e.DID, err) + } + + return &SequencedOp{ + DID: e.DID, + CID: e.CID, + Operation: op, + CreatedAt: createdAt, + Seq: e.Seq, + }, nil +} + +const ( + // caughtUpThreshold is how close to real-time the latest entry must be + // before ingestPaginated switches to streaming. + caughtUpThreshold = 1 * time.Hour + + // retryDelay is the delay before retrying after an ingestion error. + retryDelay = 1 * time.Second + + // cursorPersistInterval is how often the resume cursor is persisted. + cursorPersistInterval = 1 * time.Second + + // If this timeout is reached, we'll retry the request. + // Also used as the timeout for websocket reads, triggering a reconnect. + httpClientTimeout = 30 * time.Second +) + +var ( + // errOutdatedCursor is returned by ingestStream when the server sends an + // OutdatedCursor close reason, indicating the cursor is too old for the + // streaming endpoint and paginated catch-up is needed. + errOutdatedCursor = errors.New("outdated cursor") + + // errCaughtUp is returned by ingestPaginated when the latest entry + // timestamp is within 1 hour of now, indicating we're close enough to + // real-time to switch to streaming. + errCaughtUp = errors.New("caught up to near real-time") +) + +// Ingestor streams operations from a PLC directory export endpoint, +// validates them, and commits them to the local store. +type Ingestor struct { + store *GormOpStore + state *ReplicaState + directoryURL string + parsedDirectoryURL *url.URL + cursorHost string + numWorkers int + startCursor int64 + userAgent string + httpClient *http.Client + wsDialer *websocket.Dialer + logger *slog.Logger +} + +// NewIngestor creates a new Ingestor. Pass startCursor == -1 to resume from +// the cursor stored in the database. +func NewIngestor(store *GormOpStore, state *ReplicaState, directoryURL string, startCursor int64, numWorkers int, logger *slog.Logger) (*Ingestor, error) { + parsedDirectoryURL, err := url.Parse(directoryURL) + if err != nil { + return nil, err + } + return &Ingestor{ + store: store, + state: state, + directoryURL: directoryURL, + parsedDirectoryURL: parsedDirectoryURL, + cursorHost: parsedDirectoryURL.Host, // "host" or "host:port" + numWorkers: numWorkers, + startCursor: startCursor, + userAgent: fmt.Sprintf("go-didplc-replica/%s", versioninfo.Short()), + httpClient: &http.Client{ + Timeout: httpClientTimeout, + Transport: otelhttp.NewTransport(http.DefaultTransport), + }, + wsDialer: websocket.DefaultDialer, + logger: logger.With("component", "ingestor"), + }, nil +} + +// Run executes the full ingestion pipeline: resolving the cursor, spawning +// validate/commit workers, streaming operations from the directory, and +// dispatching them through the pipeline. +func (i *Ingestor) Run(ctx context.Context) error { + cursor := i.startCursor + if cursor == -1 { + var err error + cursor, err = i.store.GetCursor(ctx, i.cursorHost) + if err != nil { + return err + } + } + + infl := NewInFlight(cursor) + + /* + + ingest reads operations from the upstream PLC directory, and puts them into + the ingestedOps channel (in seq order). + + one of the loops below reads from ingestedOps and forwards them into seqops, *but*, importantly, + it ensures that there are never two operations for the same DID in-flight at once. + + ValidateWorker threads each sit in a loop reading from seqops, validating operations, and + writing the validated ops into the validatedOps channel. + + Finally, the CommitWorker loop reads from validatedOps and commits them to the db in batches. + + */ + + ingestedOps := make(chan *SequencedOp, 10000) + seqops := make(chan *SequencedOp, 100) + validatedOps := make(chan ValidatedOp, 1000) + + // Start multiple validateWorker goroutines + for range i.numWorkers { + go ValidateWorker(ctx, seqops, validatedOps, infl, i.store) + } + + // Start single commit worker + flushCh := make(chan chan struct{}) + go CommitWorker(ctx, validatedOps, infl, i.store, flushCh, i.state) + + // Periodically persist the resume cursor and record queue metrics + go func() { + ticker := time.NewTicker(cursorPersistInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + resumeCursor := infl.GetResumeCursor() + if err := i.store.PutCursor(ctx, i.cursorHost, resumeCursor); err != nil { + i.logger.Error("failed to persist cursor", "error", err) + } else { + i.logger.Info("persisted cursor", "cursor", resumeCursor, "host", i.cursorHost) + } + IngestCursorGauge.Record(ctx, resumeCursor) + IngestedOpsQueueGauge.Record(ctx, int64(len(ingestedOps))) + SeqOpsQueueGauge.Record(ctx, int64(len(seqops))) + ValidatedOpsQueueGauge.Record(ctx, int64(len(validatedOps))) + } + } + }() + + // Start ingestion state machine in a goroutine + go i.ingestLoop(ctx, &cursor, ingestedOps) + + // Process operations from ingestion channel and add to InFlight before sending to workers + for seqop := range ingestedOps { + + // If the DID is already in-flight, ask the committer to flush its + // batch so the previous op for this DID hopefully gets committed and removed + // from in-flight tracking. (it might still be in a queue but we'll get there eventually) + for !infl.AddInFlight(seqop.DID, seqop.Seq) { + done := make(chan struct{}) + flushCh <- done + <-done + } + + seqops <- seqop + + // Note: we're recording this timestamp when the op is in-flight, not yet validated/committed + LastIngestedOpTsGauge.Record(ctx, seqop.CreatedAt.Unix()) + } + + return nil +} + +// ingestLoop is the state machine that orchestrates ingestion, switching between +// websocket streaming (/export/stream) and paginated HTTP (/export) as needed. +// +// It starts by attempting a websocket stream. If the server reports an outdated +// cursor, it falls back to paginated ingestion until caught up, then switches +// back to streaming. Other errors trigger a retry after a fixed delay. +func (i *Ingestor) ingestLoop(ctx context.Context, cursor *int64, ops chan<- *SequencedOp) { + recordState := func(attr attribute.KeyValue) { + // Record 1 for the active state, 0 for the other + if attr == IngestStateStream { + IngestStateGauge.Record(ctx, 1, metric.WithAttributes(IngestStateStream)) + IngestStateGauge.Record(ctx, 0, metric.WithAttributes(IngestStatePaginated)) + } else { + IngestStateGauge.Record(ctx, 1, metric.WithAttributes(IngestStatePaginated)) + IngestStateGauge.Record(ctx, 0, metric.WithAttributes(IngestStateStream)) + } + } + + for { + recordState(IngestStateStream) + i.logger.Info("starting stream ingestion", "cursor", *cursor) + err := i.ingestStream(ctx, cursor, ops) + if err == nil { + continue + } + + if errors.Is(err, errOutdatedCursor) { + i.logger.Info("cursor outdated for stream, falling back to paginated", "cursor", *cursor) + recordState(IngestStatePaginated) + for { + i.logger.Info("starting paginated ingestion", "cursor", *cursor) + perr := i.ingestPaginated(ctx, cursor, ops) + if perr == nil { + continue + } + if errors.Is(perr, errCaughtUp) { + i.logger.Info("caught up, switching to stream", "cursor", *cursor) + break // back to outer loop -> try stream again + } + i.logger.Error("paginated ingestion error, retrying", "error", perr) + if !sleepCtx(ctx, retryDelay) { + return + } + } + continue + } + + if ctx.Err() != nil { + return + } + + i.logger.Error("stream ingestion error, retrying", "error", err) + if !sleepCtx(ctx, retryDelay) { + return + } + } +} + +// ingestStream connects to the /export/stream websocket endpoint and reads +// operations until an error occurs. Returns errOutdatedCursor if the server +// closes the connection with an OutdatedCursor reason. +func (i *Ingestor) ingestStream(ctx context.Context, cursor *int64, ops chan<- *SequencedOp) error { + wsURL := buildStreamURL(i.parsedDirectoryURL, *cursor) + i.logger.Debug("websocket connecting", "url", wsURL) + + header := http.Header{} + header.Set("User-Agent", i.userAgent) + + conn, _, err := i.wsDialer.Dial(wsURL, header) + if err != nil { + return fmt.Errorf("websocket dial failed: %w", err) + } + + // Close the connection when ctx is cancelled. ReadMessage doesn't accept + // a context, so we need this goroutine to interrupt it. + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + conn.Close() + case <-done: + } + }() + defer close(done) + defer conn.Close() + + i.logger.Info("websocket connected", "url", wsURL) + + for { + conn.SetReadDeadline(time.Now().Add(httpClientTimeout)) + _, msg, err := conn.ReadMessage() + if err != nil { + // Check for OutdatedCursor close reason + var closeErr *websocket.CloseError + if errors.As(err, &closeErr) && closeErr.Text == "OutdatedCursor" { + return errOutdatedCursor + } + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("websocket read error: %w", err) + } + + var entry ExportEntry + if err := json.Unmarshal(msg, &entry); err != nil { + return fmt.Errorf("failed to parse websocket message: %w", err) + } + + seqop, err := entry.toSequencedOp(i.logger) + if err != nil { + return err + } + if seqop == nil { + continue + } + + select { + case ops <- seqop: + case <-ctx.Done(): + return ctx.Err() + } + + if entry.Seq > *cursor { + *cursor = entry.Seq + } + } +} + +// ingestPaginated fetches operations from the paginated /export HTTP endpoint. +// It loops through pages until it encounters an error or determines the cursor +// is within 1 hour of real-time (returns errCaughtUp). +func (i *Ingestor) ingestPaginated(ctx context.Context, cursor *int64, ops chan<- *SequencedOp) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + reqURL := fmt.Sprintf("%s/export?after=%d", i.directoryURL, *cursor) + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", i.userAgent) + + i.logger.Debug("http request starting", "method", "GET", "url", reqURL) + resp, err := i.httpClient.Do(req) + if err != nil { + i.logger.Error("http request failed", "error", err) + return fmt.Errorf("failed to fetch export: %w", err) + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + i.logger.Error("http request failed", "status", resp.StatusCode, "body", body) + return fmt.Errorf("export endpoint returned status %d", resp.StatusCode) + } + + var latestCreatedAt time.Time + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(nil, 10000000) // Set a reasonable max size for large operations + + for scanner.Scan() { + select { + case <-ctx.Done(): + resp.Body.Close() + return ctx.Err() + default: + } + + var entry ExportEntry + if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { + resp.Body.Close() + i.logger.Error("http request failed", "error", "JSON parse error", "details", err) + return fmt.Errorf("failed to parse export entry: %w", err) + } + + seqop, err := entry.toSequencedOp(i.logger) + if err != nil { + resp.Body.Close() + return err + } + if seqop == nil { + continue + } + + select { + case ops <- seqop: + case <-ctx.Done(): + resp.Body.Close() + return ctx.Err() + } + + if entry.Seq > *cursor { + *cursor = entry.Seq + } + if seqop.CreatedAt.After(latestCreatedAt) { + latestCreatedAt = seqop.CreatedAt + } + } + + if err := scanner.Err(); err != nil { + resp.Body.Close() + i.logger.Error("http request failed", "error", "stream read error", "details", err) + return fmt.Errorf("error reading export stream: %w", err) + } + + resp.Body.Close() + + // Check if we're close enough to real-time to switch to streaming + if !latestCreatedAt.IsZero() && time.Since(latestCreatedAt) < caughtUpThreshold { + return errCaughtUp + } + } +} + +// buildStreamURL converts an HTTP directory URL to a websocket /export/stream URL. +// e.g. "https://host" -> "wss://host/export/stream?cursor=N" +func buildStreamURL(u *url.URL, cursor int64) string { + copy := *u + + switch copy.Scheme { + case "https": + copy.Scheme = "wss" + case "http": + copy.Scheme = "ws" + } + + copy.Path = "/export/stream" + q := copy.Query() + q.Set("cursor", fmt.Sprintf("%d", cursor)) + copy.RawQuery = q.Encode() + return copy.String() +} + +// sleepCtx sleeps for the given duration or until the context is cancelled. +// Returns true if the sleep completed, false if the context was cancelled. +func sleepCtx(ctx context.Context, d time.Duration) bool { + select { + case <-time.After(d): + return true + case <-ctx.Done(): + return false + } +} diff --git a/replica/ingest_test.go b/replica/ingest_test.go new file mode 100644 index 0000000..2f7c085 --- /dev/null +++ b/replica/ingest_test.go @@ -0,0 +1,361 @@ +package replica + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/did-method-plc/go-didplc/didplc" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Pure function tests --- + +func TestBuildStreamURL_HTTPS(t *testing.T) { + u, err := url.Parse("https://plc.directory") + require.NoError(t, err) + got := buildStreamURL(u, 42) + assert.Equal(t, "wss://plc.directory/export/stream?cursor=42", got) +} + +func TestBuildStreamURL_HTTP(t *testing.T) { + u, err := url.Parse("http://localhost:8080") + require.NoError(t, err) + got := buildStreamURL(u, 0) + assert.Equal(t, "ws://localhost:8080/export/stream?cursor=0", got) +} + +func TestSleepCtx_Completes(t *testing.T) { + ctx := context.Background() + ok := sleepCtx(ctx, 1*time.Millisecond) + assert.True(t, ok) +} + +func TestSleepCtx_Cancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + ok := sleepCtx(ctx, 10*time.Second) + assert.False(t, ok) +} + +func TestExportEntry_ToSequencedOp_Valid(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + + entry := &ExportEntry{ + DID: did, + CID: genesis.CID().String(), + Seq: 1, + CreatedAt: "2024-01-01T00:00:00Z", + Operation: *genesis.AsOpEnum(), + Type: "sequenced_op", + } + + seqop, err := entry.toSequencedOp(logger) + require.NoError(t, err) + require.NotNil(t, seqop) + assert.Equal(t, did, seqop.DID) + assert.Equal(t, genesis.CID().String(), seqop.CID) + assert.Equal(t, int64(1), seqop.Seq) + assert.Equal(t, time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), seqop.CreatedAt) +} + +func TestExportEntry_ToSequencedOp_WrongType(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + entry := &ExportEntry{ + DID: "did:plc:test", + CID: "bafyreifakecid", + Seq: 1, + CreatedAt: "2024-01-01T00:00:00Z", + Type: "identity", + } + + seqop, err := entry.toSequencedOp(logger) + assert.NoError(t, err) + assert.Nil(t, seqop) +} + +func TestExportEntry_ToSequencedOp_BadTimestamp(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + + entry := &ExportEntry{ + DID: did, + CID: genesis.CID().String(), + Seq: 1, + CreatedAt: "not-a-timestamp", + Operation: *genesis.AsOpEnum(), + Type: "sequenced_op", + } + + seqop, err := entry.toSequencedOp(logger) + assert.Error(t, err) + assert.Nil(t, seqop) + assert.Contains(t, err.Error(), "failed to parse timestamp") +} + +func TestExportEntry_ToSequencedOp_NilOperation(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + entry := &ExportEntry{ + DID: "did:plc:test", + CID: "bafyreifakecid", + Seq: 1, + CreatedAt: "2024-01-01T00:00:00Z", + Operation: didplc.OpEnum{}, // empty — AsOperation() returns nil + Type: "sequenced_op", + } + + seqop, err := entry.toSequencedOp(logger) + assert.NoError(t, err) + assert.Nil(t, seqop) +} + +// --- HTTP/WebSocket ingestion tests --- + +// makeExportEntryJSON creates a JSON-encoded ExportEntry line for NDJSON responses. +// We build the JSON manually because OpEnum.MarshalJSON has a pointer receiver, +// which doesn't get invoked correctly when OpEnum is a value field in a struct. +func makeExportEntryJSON(t *testing.T, did, cid string, seq int64, createdAt time.Time, op didplc.Operation, typ string) []byte { + t.Helper() + opJSON, err := op.AsOpEnum().MarshalJSON() + require.NoError(t, err) + + wrapper := struct { + DID string `json:"did"` + CID string `json:"cid"` + Seq int64 `json:"seq"` + CreatedAt string `json:"createdAt"` + Operation json.RawMessage `json:"operation"` + Type string `json:"type"` + }{ + DID: did, + CID: cid, + Seq: seq, + CreatedAt: createdAt.Format(time.RFC3339), + Operation: opJSON, + Type: typ, + } + data, err := json.Marshal(wrapper) + require.NoError(t, err) + return data +} + +func TestIngestPaginated_BasicFlow(t *testing.T) { + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + + // Create a recent timestamp so ingestPaginated returns errCaughtUp + recentTime := time.Now().Add(-30 * time.Minute) + line := makeExportEntryJSON(t, did, genesis.CID().String(), 1, recentTime, genesis, "sequenced_op") + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/export", r.URL.Path) + w.Header().Set("Content-Type", "application/x-ndjson") + fmt.Fprintf(w, "%s\n", line) + })) + defer ts.Close() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + ingestor, err := NewIngestor(newTestStore(t), NewReplicaState(), ts.URL, 0, 1, logger) + require.NoError(t, err) + + ctx := context.Background() + ops := make(chan *SequencedOp, 10) + cursor := int64(0) + + err = ingestor.ingestPaginated(ctx, &cursor, ops) + assert.ErrorIs(t, err, errCaughtUp) + assert.Equal(t, int64(1), cursor) + + // Should have received the op + require.Len(t, ops, 1) + seqop := <-ops + assert.Equal(t, did, seqop.DID) +} + +func TestIngestPaginated_HTTPError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintln(w, "internal error") + })) + defer ts.Close() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + ingestor, err := NewIngestor(newTestStore(t), NewReplicaState(), ts.URL, 0, 1, logger) + require.NoError(t, err) + + ctx := context.Background() + ops := make(chan *SequencedOp, 10) + cursor := int64(0) + + err = ingestor.ingestPaginated(ctx, &cursor, ops) + assert.Error(t, err) + assert.Contains(t, err.Error(), "status 500") +} + +func TestIngestPaginated_SkipsNonSequencedOp(t *testing.T) { + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + + recentTime := time.Now().Add(-30 * time.Minute) + + // One "identity" entry (should be skipped) and one "sequenced_op" entry + identityLine, _ := json.Marshal(map[string]any{ + "did": did, + "cid": "bafyreifakecid", + "seq": 1, + "createdAt": recentTime.Format(time.RFC3339), + "type": "identity", + }) + seqopLine := makeExportEntryJSON(t, did, genesis.CID().String(), 2, recentTime, genesis, "sequenced_op") + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-ndjson") + fmt.Fprintln(w, string(identityLine)) + fmt.Fprintf(w, "%s\n", seqopLine) + })) + defer ts.Close() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + ingestor, err := NewIngestor(newTestStore(t), NewReplicaState(), ts.URL, 0, 1, logger) + require.NoError(t, err) + + ctx := context.Background() + ops := make(chan *SequencedOp, 10) + cursor := int64(0) + + err = ingestor.ingestPaginated(ctx, &cursor, ops) + assert.ErrorIs(t, err, errCaughtUp) + + // Only the sequenced_op should have come through + require.Len(t, ops, 1) + seqop := <-ops + assert.Equal(t, int64(2), seqop.Seq) +} + +func TestIngestStream_BasicFlow(t *testing.T) { + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + + createdAt := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC) + entryBytes := makeExportEntryJSON(t, did, genesis.CID().String(), 5, createdAt, genesis, "sequenced_op") + + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + conn.WriteMessage(websocket.TextMessage, entryBytes) + // Close after sending one message to end the stream + conn.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, "done")) + })) + defer ts.Close() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + ingestor, err := NewIngestor(newTestStore(t), NewReplicaState(), ts.URL, 0, 1, logger) + require.NoError(t, err) + + // Override the parsed URL to use ws:// scheme for the test server + wsURL := strings.Replace(ts.URL, "http://", "ws://", 1) + ingestor.parsedDirectoryURL, _ = url.Parse(wsURL) + + ctx := context.Background() + ops := make(chan *SequencedOp, 10) + cursor := int64(0) + + // ingestStream will return an error when the WS closes + err = ingestor.ingestStream(ctx, &cursor, ops) + assert.Error(t, err) // normal close is still an error from ReadMessage perspective + + assert.Equal(t, int64(5), cursor) + require.Len(t, ops, 1) + seqop := <-ops + assert.Equal(t, did, seqop.DID) + assert.Equal(t, int64(5), seqop.Seq) +} + +func TestIngestStream_OutdatedCursor(t *testing.T) { + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + conn.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, "OutdatedCursor")) + })) + defer ts.Close() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + ingestor, err := NewIngestor(newTestStore(t), NewReplicaState(), ts.URL, 0, 1, logger) + require.NoError(t, err) + + wsURL := strings.Replace(ts.URL, "http://", "ws://", 1) + ingestor.parsedDirectoryURL, _ = url.Parse(wsURL) + + ctx := context.Background() + ops := make(chan *SequencedOp, 10) + cursor := int64(0) + + err = ingestor.ingestStream(ctx, &cursor, ops) + assert.ErrorIs(t, err, errOutdatedCursor) +} + +func TestIngestStream_ContextCancellation(t *testing.T) { + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + // Hold the connection open — don't send anything + select {} + })) + defer ts.Close() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + ingestor, err := NewIngestor(newTestStore(t), NewReplicaState(), ts.URL, 0, 1, logger) + require.NoError(t, err) + + wsURL := strings.Replace(ts.URL, "http://", "ws://", 1) + ingestor.parsedDirectoryURL, _ = url.Parse(wsURL) + + ctx, cancel := context.WithCancel(context.Background()) + ops := make(chan *SequencedOp, 10) + cursor := int64(0) + + done := make(chan error, 1) + go func() { + done <- ingestor.ingestStream(ctx, &cursor, ops) + }() + + cancel() + + select { + case err := <-done: + assert.ErrorIs(t, err, context.Canceled) + case <-time.After(5 * time.Second): + t.Fatal("ingestStream did not return promptly after context cancellation") + } +} diff --git a/replica/metrics.go b/replica/metrics.go new file mode 100644 index 0000000..71f3e34 --- /dev/null +++ b/replica/metrics.go @@ -0,0 +1,64 @@ +package replica + +import ( + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +var meter = otel.Meter("github.com/did-method-plc/go-didplc/replica") + +var ( + IngestCursorGauge metric.Int64Gauge + IngestedOpsQueueGauge metric.Int64Gauge + SeqOpsQueueGauge metric.Int64Gauge + ValidatedOpsQueueGauge metric.Int64Gauge + IngestStateGauge metric.Int64Gauge + LastIngestedOpTsGauge metric.Int64Gauge +) + +var ( + IngestStateStream = attribute.String("state", "stream") + IngestStatePaginated = attribute.String("state", "paginated") +) + +func init() { + var err error + IngestCursorGauge, err = meter.Int64Gauge("plc_replica_ingest_cursor", + metric.WithDescription("The most recently committed seq value"), + ) + if err != nil { + panic(err) + } + IngestedOpsQueueGauge, err = meter.Int64Gauge("plc_replica_ingested_ops_queue", + metric.WithDescription("Number of items in the ingested ops channel"), + ) + if err != nil { + panic(err) + } + SeqOpsQueueGauge, err = meter.Int64Gauge("plc_replica_seq_ops_queue", + metric.WithDescription("Number of items in the sequenced ops channel"), + ) + if err != nil { + panic(err) + } + ValidatedOpsQueueGauge, err = meter.Int64Gauge("plc_replica_validated_ops_queue", + metric.WithDescription("Number of items in the validated ops channel"), + ) + if err != nil { + panic(err) + } + IngestStateGauge, err = meter.Int64Gauge("plc_replica_ingest_state", + metric.WithDescription("Current ingest mode: 1 with state attribute (stream or paginated)"), + ) + if err != nil { + panic(err) + } + LastIngestedOpTsGauge, err = meter.Int64Gauge("plc_replica_last_ingested_op_ts", + metric.WithDescription("Unix timestamp of the most recently ingested operation"), + metric.WithUnit("s"), + ) + if err != nil { + panic(err) + } +} diff --git a/replica/server.go b/replica/server.go new file mode 100644 index 0000000..01e2b16 --- /dev/null +++ b/replica/server.go @@ -0,0 +1,278 @@ +package replica + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/carlmjohnson/versioninfo" + "github.com/did-method-plc/go-didplc/didplc" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" +) + +const indexBanner string = ` + .__ .__ .__ +______ | | ____ _______ ____ ______ | | |__| ____ _____ +\____ \| | _/ ___\ ______ \_ __ \_/ __ \\____ \| | | |/ ___\\__ \ +| |_> > |_\ \___ /_____/ | | \/\ ___/| |_> > |_| \ \___ / __ \_ +| __/|____/\___ > |__| \___ > __/|____/__|\___ >____ / +|__| \/ \/|__| \/ \/ + + +This is a did:plc read-replica service. + + Source: https://github.com/did-method-plc/go-didplc/tree/main/cmd/replica + Version: %s +` + +// DIDDataResponse is the response for GET /{did}/data +type DIDDataResponse struct { + DID string `json:"did"` + VerificationMethods map[string]string `json:"verificationMethods"` + RotationKeys []string `json:"rotationKeys"` + AlsoKnownAs []string `json:"alsoKnownAs"` + Services map[string]didplc.OpService `json:"services"` +} + +// Server holds the HTTP server and its dependencies +type Server struct { + store *GormOpStore + state *ReplicaState + addr string + logger *slog.Logger +} + +// NewServer creates a new HTTP server +func NewServer(store *GormOpStore, state *ReplicaState, addr string, logger *slog.Logger) *Server { + return &Server{ + store: store, + state: state, + addr: addr, + logger: logger.With("component", "server"), + } +} + +// Run starts the HTTP server (blocking) +func (s *Server) Run() error { + mux := http.NewServeMux() + mux.HandleFunc("GET /_health", s.handleHealth) + mux.HandleFunc("GET /{did}/log/audit", s.handleDIDLogAudit) + mux.HandleFunc("GET /{did}/log/last", s.handleDIDLogLast) + mux.HandleFunc("GET /{did}/log", s.handleDIDLog) + mux.HandleFunc("GET /{did}/data", s.handleDIDData) + mux.HandleFunc("GET /{did}", s.handleDIDDoc) + mux.HandleFunc("GET /{$}", s.handleIndex) + + handler := otelhttp.NewHandler(mux, "") + + s.logger.Info("http server listening", "addr", s.addr) + return http.ListenAndServe(s.addr, handler) +} + +// handleIndex serves the index page +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + fmt.Fprintf(w, indexBanner, versioninfo.Short()) +} + +// handleHealth handles GET /_health - returns version information +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + resp := map[string]string{ + "version": versioninfo.Short(), + } + if s.state != nil { + if t := s.state.GetLastCommittedOpTime(); !t.IsZero() { + resp["lastCommittedOpTime"] = formatTimestamp(t) + } + } + s.writeJSON(w, "application/json", resp) +} + +// formatTimestamp formats a time.Time as a JS-style ISO 8601 timestamp. +func formatTimestamp(t time.Time) string { + return t.UTC().Format("2006-01-02T15:04:05.000Z") +} + +// writeJSON marshals v to JSON and writes it to w with the given content type. +// If marshaling fails, it sends a 500 error. If writing fails, it logs the error. +// Optional extra headers are set before writing the response. +func (s *Server) writeJSON(w http.ResponseWriter, contentType string, v any, extraHeaders ...http.Header) { + data, err := json.Marshal(v) + if err != nil { + s.writeJSONError(w, fmt.Sprintf("error encoding response: %v", err), http.StatusInternalServerError) + return + } + for _, h := range extraHeaders { + for k, vs := range h { + for _, val := range vs { + w.Header().Set(k, val) + } + } + } + w.Header().Set("Content-Type", contentType) + if _, err := w.Write(data); err != nil { + s.logger.Error("failed to write response", "err", err) + } +} + +// writeJSONError writes a JSON error response +func (s *Server) writeJSONError(w http.ResponseWriter, message string, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(map[string]string{"message": message}); err != nil { + s.logger.Error("failed to encode error response", "err", err) + } +} + +// handleDIDDoc handles GET /{did} - returns the DID document +func (s *Server) handleDIDDoc(w http.ResponseWriter, r *http.Request) { + did := r.PathValue("did") + ctx := r.Context() + + head, err := s.store.GetLatest(ctx, did) + if err != nil { + s.writeJSONError(w, fmt.Sprintf("error fetching from store: %v", err), http.StatusInternalServerError) + return + } + if head == nil { + s.writeJSONError(w, fmt.Sprintf("DID not registered: %s", did), http.StatusNotFound) + return + } + + if _, ok := head.Op.(*didplc.TombstoneOp); ok { + s.writeJSONError(w, fmt.Sprintf("DID not available: %s", did), http.StatusGone) + return + } + + // Generate DID document + doc, err := head.Op.Doc(did) + if err != nil { + s.writeJSONError(w, fmt.Sprintf("error generating DID document: %v", err), http.StatusInternalServerError) + return + } + + s.writeJSON(w, "application/did+json", doc, http.Header{ + "Last-Modified": {formatTimestamp(head.CreatedAt)}, + }) +} + +// handleDIDData handles GET /{did}/data - returns the latest operation data +func (s *Server) handleDIDData(w http.ResponseWriter, r *http.Request) { + did := r.PathValue("did") + ctx := r.Context() + + head, err := s.store.GetLatest(ctx, did) + if err != nil { + s.writeJSONError(w, fmt.Sprintf("error fetching from store: %v", err), http.StatusInternalServerError) + return + } + if head == nil { + s.writeJSONError(w, fmt.Sprintf("DID not registered: %s", did), http.StatusNotFound) + return + } + + // Build response based on operation type + var resp DIDDataResponse + resp.DID = did + + switch v := head.Op.(type) { + case *didplc.RegularOp: + resp.RotationKeys = v.RotationKeys + resp.VerificationMethods = v.VerificationMethods + resp.AlsoKnownAs = v.AlsoKnownAs + resp.Services = v.Services + case *didplc.LegacyOp: + // Convert legacy op to regular op format + regular := v.RegularOp() + resp.RotationKeys = regular.RotationKeys + resp.VerificationMethods = regular.VerificationMethods + resp.AlsoKnownAs = regular.AlsoKnownAs + resp.Services = regular.Services + case *didplc.TombstoneOp: + s.writeJSONError(w, fmt.Sprintf("DID not available: %s", did), http.StatusGone) + return + default: + s.writeJSONError(w, "unknown operation type", http.StatusInternalServerError) + return + } + + s.writeJSON(w, "application/json", resp) +} + +// handleDIDLogAudit handles GET /{did}/log/audit - returns the full audit log with metadata +func (s *Server) handleDIDLogAudit(w http.ResponseWriter, r *http.Request) { + did := r.PathValue("did") + ctx := r.Context() + + allEntries, err := s.store.GetAllEntries(ctx, did) + if err != nil { + s.writeJSONError(w, fmt.Sprintf("error fetching audit log: %v", err), http.StatusInternalServerError) + return + } + + if len(allEntries) == 0 { + s.writeJSONError(w, fmt.Sprintf("DID not registered: %s", did), http.StatusNotFound) + return + } + + entries := make([]*didplc.LogEntry, 0, len(allEntries)) + for _, entry := range allEntries { + entries = append(entries, &didplc.LogEntry{ + DID: entry.DID, + Operation: *entry.Op.AsOpEnum(), + CID: entry.OpCid, + Nullified: entry.Nullified, + CreatedAt: formatTimestamp(entry.CreatedAt), + }) + } + + s.writeJSON(w, "application/json", entries) +} + +// handleDIDLog handles GET /{did}/log - returns the full operation log +func (s *Server) handleDIDLog(w http.ResponseWriter, r *http.Request) { + did := r.PathValue("did") + ctx := r.Context() + + allEntries, err := s.store.GetAllEntries(ctx, did) + if err != nil { + s.writeJSONError(w, fmt.Sprintf("error fetching operation log: %v", err), http.StatusInternalServerError) + return + } + + // Filter out nullified operations + operations := make([]*didplc.OpEnum, 0, len(allEntries)) + for _, entry := range allEntries { + if !entry.Nullified { + operations = append(operations, entry.Op.AsOpEnum()) + } + } + + if len(operations) == 0 { + s.writeJSONError(w, fmt.Sprintf("DID not registered: %s", did), http.StatusNotFound) + return + } + + s.writeJSON(w, "application/json", operations) +} + +// handleDIDLogLast handles GET /{did}/log/last - returns the raw last operation +func (s *Server) handleDIDLogLast(w http.ResponseWriter, r *http.Request) { + did := r.PathValue("did") + ctx := r.Context() + + // Get the head CID for this DID + head, err := s.store.GetLatest(ctx, did) + if err != nil { + s.writeJSONError(w, fmt.Sprintf("error fetching from store: %v", err), http.StatusInternalServerError) + return + } + if head == nil { + s.writeJSONError(w, fmt.Sprintf("DID not registered: %s", did), http.StatusNotFound) + return + } + + s.writeJSON(w, "application/json", head.Op.AsOpEnum()) +} diff --git a/replica/server_test.go b/replica/server_test.go new file mode 100644 index 0000000..89668e3 --- /dev/null +++ b/replica/server_test.go @@ -0,0 +1,459 @@ +package replica + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/did-method-plc/go-didplc/didplc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" +) + +func newTestServerWithState(t *testing.T, state *ReplicaState) (http.Handler, *GormOpStore) { + t.Helper() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + store, err := NewGormOpStoreWithDialector(sqlite.Open(":memory:"), logger) + require.NoError(t, err) + sqlDB, err := store.db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + t.Cleanup(func() { sqlDB.Close() }) + + s := NewServer(store, state, ":0", logger) + mux := http.NewServeMux() + mux.HandleFunc("GET /_health", s.handleHealth) + mux.HandleFunc("GET /{did}/log/audit", s.handleDIDLogAudit) + mux.HandleFunc("GET /{did}/log/last", s.handleDIDLogLast) + mux.HandleFunc("GET /{did}/log", s.handleDIDLog) + mux.HandleFunc("GET /{did}/data", s.handleDIDData) + mux.HandleFunc("GET /{did}", s.handleDIDDoc) + mux.HandleFunc("GET /{$}", s.handleIndex) + return mux, store +} + +func newTestServer(t *testing.T) (http.Handler, *GormOpStore) { + t.Helper() + return newTestServerWithState(t, nil) +} + +func TestHandleIndex(t *testing.T) { + handler, _ := newTestServer(t) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/", nil)) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "text/plain", w.Header().Get("Content-Type")) + assert.NotEmpty(t, w.Body.String()) +} + +func TestHandleDIDDoc(t *testing.T) { + handler, store := newTestServer(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + commitGenesis(t, ctx, store, genesis, did, time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/"+did, nil)) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/did+json", w.Header().Get("Content-Type")) + + var doc didplc.Doc + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &doc)) + assert.Equal(t, did, doc.ID) + assert.Contains(t, doc.AlsoKnownAs, "at://test.example.com") + assert.NotEmpty(t, doc.VerificationMethod) + assert.NotEmpty(t, doc.Service) +} + +func TestHandleDIDDoc_NotFound(t *testing.T) { + handler, _ := newTestServer(t) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/did:plc:nonexistent", nil)) + + assert.Equal(t, http.StatusNotFound, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) +} + +func TestHandleDIDData(t *testing.T) { + handler, store := newTestServer(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + commitGenesis(t, ctx, store, genesis, did, time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/"+did+"/data", nil)) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + + var resp DIDDataResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, did, resp.DID) + assert.Equal(t, []string{pubKey}, resp.RotationKeys) + assert.Contains(t, resp.AlsoKnownAs, "at://test.example.com") + assert.Contains(t, resp.Services, "atproto_pds") +} + +func TestHandleDIDData_NotFound(t *testing.T) { + handler, _ := newTestServer(t) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/did:plc:nonexistent/data", nil)) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestHandleDIDData_Tombstone(t *testing.T) { + handler, store := newTestServer(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + tombstone := &didplc.TombstoneOp{Type: "plc_tombstone", Prev: genesisCID} + require.NoError(t, tombstone.Sign(priv)) + prepOp, err := didplc.VerifyOperation(ctx, store, did, tombstone, t0.Add(time.Hour)) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp})) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/"+did+"/data", nil)) + + assert.Equal(t, http.StatusGone, w.Code) +} + +func TestHandleDIDData_AfterUpdate(t *testing.T) { + handler, store := newTestServer(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + update := createUpdate(t, priv, []string{pubKey}, genesisCID) + prepOp, err := didplc.VerifyOperation(ctx, store, did, update, t0.Add(time.Hour)) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp})) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/"+did+"/data", nil)) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp DIDDataResponse + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, did, resp.DID) + assert.Contains(t, resp.AlsoKnownAs, "at://updated.example.com") +} + +func TestHandleDIDLog(t *testing.T) { + handler, store := newTestServer(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + update := createUpdate(t, priv, []string{pubKey}, genesisCID) + prepOp, err := didplc.VerifyOperation(ctx, store, did, update, t0.Add(time.Hour)) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp})) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/"+did+"/log", nil)) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + + var ops []didplc.OpEnum + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &ops)) + assert.Len(t, ops, 2) +} + +func TestHandleDIDLog_NotFound(t *testing.T) { + handler, _ := newTestServer(t) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/did:plc:nonexistent/log", nil)) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestHandleDIDLog_ExcludesNullified(t *testing.T) { + handler, store := newTestServer(t) + ctx := context.Background() + + privRecovery, pubKeyRecovery := generateKey(t) + priv, pubKey := generateKey(t) + rotationKeys := []string{pubKeyRecovery, pubKey} + + genesis, did := createGenesis(t, privRecovery, rotationKeys) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + // Regular update signed by key at index 1 + update := createUpdate(t, priv, rotationKeys, genesisCID) + t1 := t0.Add(time.Hour) + prepOp1, err := didplc.VerifyOperation(ctx, store, did, update, t1) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp1})) + + // Nullification signed by recovery key (prev = genesis, not update) + nullify := createUpdate(t, privRecovery, rotationKeys, genesisCID) + t2 := t1.Add(time.Hour) + prepOp2, err := didplc.VerifyOperation(ctx, store, did, nullify, t2) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp2})) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/"+did+"/log", nil)) + + assert.Equal(t, http.StatusOK, w.Code) + + var ops []didplc.OpEnum + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &ops)) + assert.Len(t, ops, 2, "should have genesis + nullification, excluding the nullified update") +} + +func TestHandleDIDLogAudit(t *testing.T) { + handler, store := newTestServer(t) + ctx := context.Background() + + privRecovery, pubKeyRecovery := generateKey(t) + priv, pubKey := generateKey(t) + rotationKeys := []string{pubKeyRecovery, pubKey} + + genesis, did := createGenesis(t, privRecovery, rotationKeys) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + // Regular update signed by key at index 1 + update := createUpdate(t, priv, rotationKeys, genesisCID) + t1 := t0.Add(time.Hour) + prepOp1, err := didplc.VerifyOperation(ctx, store, did, update, t1) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp1})) + + // Nullification signed by recovery key + nullify := createUpdate(t, privRecovery, rotationKeys, genesisCID) + t2 := t1.Add(time.Hour) + prepOp2, err := didplc.VerifyOperation(ctx, store, did, nullify, t2) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp2})) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/"+did+"/log/audit", nil)) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + + var entries []didplc.LogEntry + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &entries)) + assert.Len(t, entries, 3, "audit log includes all ops including nullified") + + nullifiedCount := 0 + for _, e := range entries { + if e.Nullified { + nullifiedCount++ + } + } + assert.Equal(t, 1, nullifiedCount) +} + +func TestHandleDIDLogAudit_NotFound(t *testing.T) { + handler, _ := newTestServer(t) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/did:plc:nonexistent/log/audit", nil)) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestHandleDIDLogLast(t *testing.T) { + handler, store := newTestServer(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + update := createUpdate(t, priv, []string{pubKey}, genesisCID) + prepOp, err := didplc.VerifyOperation(ctx, store, did, update, t0.Add(time.Hour)) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp})) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/"+did+"/log/last", nil)) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + + var opEnum didplc.OpEnum + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &opEnum)) + assert.NotNil(t, opEnum.Regular) + assert.NotNil(t, opEnum.Regular.Prev, "last op should be the update, not genesis") +} + +func TestHandleDIDLogLast_GenesisOnly(t *testing.T) { + handler, store := newTestServer(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + commitGenesis(t, ctx, store, genesis, did, time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/"+did+"/log/last", nil)) + + assert.Equal(t, http.StatusOK, w.Code) + + var opEnum didplc.OpEnum + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &opEnum)) + assert.NotNil(t, opEnum.Regular) + assert.Nil(t, opEnum.Regular.Prev, "genesis op has no prev") +} + +func TestHandleDIDLogLast_NotFound(t *testing.T) { + handler, _ := newTestServer(t) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/did:plc:nonexistent/log/last", nil)) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestHandleHealth_NoState(t *testing.T) { + handler, _ := newTestServerWithState(t, nil) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/_health", nil)) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/json", w.Header().Get("Content-Type")) + + var resp map[string]string + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Contains(t, resp, "version") + assert.NotContains(t, resp, "lastCommittedOpTime") +} + +func TestHandleHealth_WithState(t *testing.T) { + state := NewReplicaState() + ts := time.Date(2024, 6, 15, 12, 30, 45, 123000000, time.UTC) + state.SetLastCommittedOpTime(ts) + + handler, _ := newTestServerWithState(t, state) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/_health", nil)) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]string + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Contains(t, resp, "version") + assert.Equal(t, "2024-06-15T12:30:45.123Z", resp["lastCommittedOpTime"]) +} + +func TestHandleHealth_WithState_ZeroTime(t *testing.T) { + state := NewReplicaState() + // Don't set any time — it's the zero value + + handler, _ := newTestServerWithState(t, state) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/_health", nil)) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp map[string]string + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Contains(t, resp, "version") + assert.NotContains(t, resp, "lastCommittedOpTime", "zero time should be omitted") +} + +func TestHandleDIDDoc_Tombstone(t *testing.T) { + handler, store := newTestServer(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + tombstone := &didplc.TombstoneOp{Type: "plc_tombstone", Prev: genesisCID} + require.NoError(t, tombstone.Sign(priv)) + prepOp, err := didplc.VerifyOperation(ctx, store, did, tombstone, t0.Add(time.Hour)) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp})) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/"+did, nil)) + + assert.Equal(t, http.StatusGone, w.Code) +} + +func TestHandleDIDDoc_LastModifiedHeader(t *testing.T) { + handler, store := newTestServer(t) + ctx := context.Background() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 6, 15, 12, 30, 45, 0, time.UTC) + commitGenesis(t, ctx, store, genesis, did, t0) + + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest("GET", "/"+did, nil)) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "2024-06-15T12:30:45.000Z", w.Header().Get("Last-Modified")) +} + +func TestFormatTimestamp(t *testing.T) { + tests := []struct { + name string + input time.Time + expected string + }{ + { + name: "UTC", + input: time.Date(2024, 6, 15, 12, 30, 45, 123000000, time.UTC), + expected: "2024-06-15T12:30:45.123Z", + }, + { + name: "non-UTC converted", + input: time.Date(2024, 6, 15, 14, 30, 45, 0, time.FixedZone("EST+2", 2*60*60)), + expected: "2024-06-15T12:30:45.000Z", + }, + { + name: "zero milliseconds", + input: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + expected: "2024-01-01T00:00:00.000Z", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, formatTimestamp(tt.input)) + }) + } +} diff --git a/replica/state.go b/replica/state.go new file mode 100644 index 0000000..6ec03e2 --- /dev/null +++ b/replica/state.go @@ -0,0 +1,28 @@ +package replica + +import ( + "sync" + "time" +) + +// ReplicaState holds shared state between the Ingestor and Server components. +type ReplicaState struct { + mu sync.RWMutex + lastCommittedOpTime time.Time +} + +func NewReplicaState() *ReplicaState { + return &ReplicaState{} +} + +func (s *ReplicaState) SetLastCommittedOpTime(t time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastCommittedOpTime = t +} + +func (s *ReplicaState) GetLastCommittedOpTime() time.Time { + s.mu.RLock() + defer s.mu.RUnlock() + return s.lastCommittedOpTime +} diff --git a/replica/validate.go b/replica/validate.go new file mode 100644 index 0000000..ab3c867 --- /dev/null +++ b/replica/validate.go @@ -0,0 +1,182 @@ +package replica + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/did-method-plc/go-didplc/didplc" +) + +type SequencedOp struct { + DID string + CID string + Operation didplc.Operation + CreatedAt time.Time + Seq int64 +} + +const batchSize = 1000 + +type ValidatedOp struct { + Seq int64 + PrepOp *didplc.PreparedOperation +} + +// ValidateWorker validates operations from seqops channel and sends validated +// operations to validatedOps channel. Multiple workers can run in parallel. +// Note: caller is responsible for inserting into inflight, but we are responsible for removal on validation failure +func ValidateWorker(ctx context.Context, seqops chan *SequencedOp, validatedOps chan<- ValidatedOp, infl *InFlight, store didplc.OpStore) { + for { + select { + case <-ctx.Done(): + return + case seqop, ok := <-seqops: + if !ok { + return + } + + prepOp, err := validateInner(ctx, seqop, store) + if err != nil { + // Validation failed - remove from InFlight and skip + infl.RemoveInFlight(seqop.DID, seqop.Seq) + + // Check if this op was already committed (i.e. a replay after reconnect) + existing, lookupErr := store.GetEntry(ctx, seqop.DID, seqop.CID) + if lookupErr == nil && existing != nil { + // This is normal, immediately after a reconnect + slog.Info("ignoring replayed op", "did", seqop.DID, "seq", seqop.Seq, "cid", seqop.CID) + } else { + // This is a more significant event + slog.Warn("validation failed", "did", seqop.DID, "seq", seqop.Seq, "cid", seqop.CID, "error", err) + } + continue + } + + // Send validated operation to commit worker + validatedOps <- ValidatedOp{ + Seq: seqop.Seq, + PrepOp: prepOp, + } + } + } +} + +// CommitWorker receives validated operations and commits them to the database in batches. +// Only a single commit worker should run to avoid database contention. +// Note: responsible for removing from InFlight after commit +func CommitWorker(ctx context.Context, validatedOps <-chan ValidatedOp, infl *InFlight, store didplc.OpStore, flushCh <-chan chan struct{}, state *ReplicaState) { + batch := make([]ValidatedOp, 0, batchSize) + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + commitBatch := func() { + if len(batch) == 0 { + return + } + + // Extract PreparedOperations for commit + prepOps := make([]*didplc.PreparedOperation, len(batch)) + for i, vop := range batch { + prepOps[i] = vop.PrepOp + } + + // Commit the batch + for { + err := store.CommitOperations(ctx, prepOps) + if err == nil { + break + } + slog.Error("failed to commit batch", "batch_size", len(batch), "error", err) + + // This is pretty bad. If it's a transient db issue, hopefully we can retry. + // If it's some other kind of failure... we're stuck here forever. But at least the server can stay up. + + // TODO: try committing each element of the batch individually, to limit the blast radius. + + if !sleepCtx(ctx, 1*time.Second) { + return + } + } + + // Update last committed op time + maxTime := state.GetLastCommittedOpTime() + for _, vop := range batch { + if vop.PrepOp.CreatedAt.After(maxTime) { + maxTime = vop.PrepOp.CreatedAt + } + } + state.SetLastCommittedOpTime(maxTime) + + // Remove all from InFlight + for _, vop := range batch { + infl.RemoveInFlight(vop.PrepOp.DID, vop.Seq) + } + + // Clear the batch + batch = batch[:0] + } + + for { + select { + case <-ctx.Done(): + commitBatch() + return + case vop, ok := <-validatedOps: + if !ok { + // Channel closed, commit remaining and exit + commitBatch() + return + } + + // Add to batch + batch = append(batch, vop) + + // Commit if batch is full + if len(batch) >= batchSize { + commitBatch() + } + + case <-ticker.C: + // Periodically flush partial batches to prevent deadlock + commitBatch() + + case done := <-flushCh: + commitBatch() + close(done) + } + } +} + +func validateInner(ctx context.Context, seqop *SequencedOp, store didplc.OpStore) (*didplc.PreparedOperation, error) { + var prepOp *didplc.PreparedOperation + var err error + + for { + prepOp, err = didplc.VerifyOperation(ctx, store, seqop.DID, seqop.Operation, seqop.CreatedAt) + if err != nil { + if errors.Is(err, didplc.ErrInvalidOperation) { + // Operation is definitely invalid - don't retry + return nil, fmt.Errorf("failed verifying op %s, %s: %w", seqop.DID, seqop.CID, err) + } + + // Transient error (hopefully) - retry with sleep. + // If the db is down then waiting for it to come back is all we can do. + slog.Warn("failed verifying op, retrying", "did", seqop.DID, "cid", seqop.CID, "error", err) + if !sleepCtx(ctx, 1*time.Second) { + return nil, fmt.Errorf("context cancelled while retrying verification: %w", err) + } + continue + } + + break // success + } + + if prepOp.OpCid != seqop.CID { + return nil, fmt.Errorf("inconsistent CID for %s %s", seqop.DID, seqop.CID) + } + + return prepOp, nil +} diff --git a/replica/validate_test.go b/replica/validate_test.go new file mode 100644 index 0000000..8f03fa1 --- /dev/null +++ b/replica/validate_test.go @@ -0,0 +1,729 @@ +package replica + +import ( + "context" + "testing" + "time" + + "github.com/bluesky-social/indigo/atproto/atcrypto" + "github.com/did-method-plc/go-didplc/didplc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// helper: generate a key pair and return the private key and its did:key string +func generateKey(t *testing.T) (atcrypto.PrivateKey, string) { + t.Helper() + priv, err := atcrypto.GeneratePrivateKeyP256() + require.NoError(t, err) + pub, err := priv.PublicKey() + require.NoError(t, err) + return priv, pub.DIDKey() +} + +// helper: create a signed genesis RegularOp and return it along with the computed DID +func createGenesis(t *testing.T, priv atcrypto.PrivateKey, rotationKeys []string) (*didplc.RegularOp, string) { + t.Helper() + pub, err := priv.PublicKey() + require.NoError(t, err) + op := &didplc.RegularOp{ + Type: "plc_operation", + RotationKeys: rotationKeys, + VerificationMethods: map[string]string{ + "atproto": pub.DIDKey(), + }, + AlsoKnownAs: []string{"at://test.example.com"}, + Services: map[string]didplc.OpService{ + "atproto_pds": { + Type: "AtprotoPersonalDataServer", + Endpoint: "https://pds.example.com", + }, + }, + Prev: nil, + } + require.NoError(t, op.Sign(priv)) + did, err := op.DID() + require.NoError(t, err) + return op, did +} + +// helper: create a signed update RegularOp that chains after prevCID +func createUpdate(t *testing.T, priv atcrypto.PrivateKey, rotationKeys []string, prevCID string) *didplc.RegularOp { + t.Helper() + pub, err := priv.PublicKey() + require.NoError(t, err) + op := &didplc.RegularOp{ + Type: "plc_operation", + RotationKeys: rotationKeys, + VerificationMethods: map[string]string{ + "atproto": pub.DIDKey(), + }, + AlsoKnownAs: []string{"at://updated.example.com"}, + Services: map[string]didplc.OpService{ + "atproto_pds": { + Type: "AtprotoPersonalDataServer", + Endpoint: "https://pds2.example.com", + }, + }, + Prev: &prevCID, + } + require.NoError(t, op.Sign(priv)) + return op +} + +// helper: commit a genesis op to the store and return its CID +func commitGenesis(t *testing.T, ctx context.Context, store didplc.OpStore, op *didplc.RegularOp, did string, createdAt time.Time) string { + t.Helper() + prepOp, err := didplc.VerifyOperation(ctx, store, did, op, createdAt) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp})) + return prepOp.OpCid +} + +func TestValidateInner_GenesisValid(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + priv, pubKey := generateKey(t) + op, did := createGenesis(t, priv, []string{pubKey}) + + seqop := &SequencedOp{ + DID: did, + CID: op.CID().String(), + Operation: op, + CreatedAt: time.Now(), + Seq: 1, + } + + prepOp, err := validateInner(ctx, seqop, store) + assert.NoError(err) + assert.Equal(did, prepOp.DID) + assert.Equal(op.CID().String(), prepOp.OpCid) + assert.Empty(prepOp.PrevHead) + assert.Nil(prepOp.NullifiedOps) +} + +func TestValidateInner_GenesisDIDMismatch(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + priv, pubKey := generateKey(t) + op, _ := createGenesis(t, priv, []string{pubKey}) + + seqop := &SequencedOp{ + DID: "did:plc:wrong", + CID: op.CID().String(), + Operation: op, + CreatedAt: time.Now(), + Seq: 1, + } + + _, err := validateInner(ctx, seqop, store) + assert.Error(err) + assert.Contains(err.Error(), "genesis DID does not match") +} + +func TestValidateInner_GenesisCIDMismatch(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + priv, pubKey := generateKey(t) + op, did := createGenesis(t, priv, []string{pubKey}) + + seqop := &SequencedOp{ + DID: did, + CID: "bafyreiwrongcidvalue", + Operation: op, + CreatedAt: time.Now(), + Seq: 1, + } + + _, err := validateInner(ctx, seqop, store) + assert.Error(err) + assert.Contains(err.Error(), "inconsistent CID") +} + +func TestValidateInner_GenesisDuplicateDID(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + priv, pubKey := generateKey(t) + op, did := createGenesis(t, priv, []string{pubKey}) + now := time.Now() + + // commit the genesis first + commitGenesis(t, ctx, store, op, did, now) + + // try to validate the same genesis again + seqop := &SequencedOp{ + DID: did, + CID: op.CID().String(), + Operation: op, + CreatedAt: now, + Seq: 2, + } + + _, err := validateInner(ctx, seqop, store) + assert.Error(err) + assert.Contains(err.Error(), "already exists") +} + +func TestValidateInner_UpdateValid(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + update := createUpdate(t, priv, []string{pubKey}, genesisCID) + t1 := t0.Add(time.Hour) + + seqop := &SequencedOp{ + DID: did, + CID: update.CID().String(), + Operation: update, + CreatedAt: t1, + Seq: 2, + } + + prepOp, err := validateInner(ctx, seqop, store) + assert.NoError(err) + assert.Equal(did, prepOp.DID) + assert.Equal(genesisCID, prepOp.PrevHead) + assert.Nil(prepOp.NullifiedOps) +} + +func TestValidateInner_UpdateTimestampNotAdvanced(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + update := createUpdate(t, priv, []string{pubKey}, genesisCID) + + // same timestamp as genesis — should fail + seqop := &SequencedOp{ + DID: did, + CID: update.CID().String(), + Operation: update, + CreatedAt: t0, + Seq: 2, + } + + _, err := validateInner(ctx, seqop, store) + assert.Error(err) + assert.Contains(err.Error(), "timestamp order") +} + +func TestValidateInner_UpdateWrongSignatureKey(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + priv1, pubKey1 := generateKey(t) + genesis, did := createGenesis(t, priv1, []string{pubKey1}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + // sign update with a different key not in rotation keys + priv2, _ := generateKey(t) + update := createUpdate(t, priv2, []string{pubKey1}, genesisCID) + t1 := t0.Add(time.Hour) + + seqop := &SequencedOp{ + DID: did, + CID: update.CID().String(), + Operation: update, + CreatedAt: t1, + Seq: 2, + } + + _, err := validateInner(ctx, seqop, store) + assert.Error(err) + assert.Contains(err.Error(), "signature invalid") +} + +func TestValidateInner_UpdateNonexistentDID(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + priv, pubKey := generateKey(t) + prevCID := "bafyreifakecid" + update := createUpdate(t, priv, []string{pubKey}, prevCID) + + seqop := &SequencedOp{ + DID: "did:plc:nonexistent", + CID: update.CID().String(), + Operation: update, + CreatedAt: time.Now(), + Seq: 1, + } + + _, err := validateInner(ctx, seqop, store) + assert.Error(err) + assert.Contains(err.Error(), "DID not found") +} + +func TestValidateInner_Nullification(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + // nullification requires two rotation keys: a recovery key (index 0) + // and a regular key (index 1). The regular update uses key 1, which + // trims allowed keys to [:1]. The nullification then uses key 0. + privRecovery, pubKeyRecovery := generateKey(t) + priv, pubKey := generateKey(t) + rotationKeys := []string{pubKeyRecovery, pubKey} + + genesis, did := createGenesis(t, privRecovery, rotationKeys) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + // create and commit a regular update signed by key at index 1 + update1 := createUpdate(t, priv, rotationKeys, genesisCID) + t1 := t0.Add(time.Hour) + prepOp1, err := didplc.VerifyOperation(ctx, store, did, update1, t1) + require.NoError(t, err) + require.Equal(t, 1, prepOp1.KeyIndex) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp1})) + + // now create a nullification signed by the recovery key (index 0), + // whose prev is the genesis (not update1) — this should nullify update1 + nullify := createUpdate(t, privRecovery, rotationKeys, genesisCID) + t2 := t1.Add(time.Hour) + + seqop := &SequencedOp{ + DID: did, + CID: nullify.CID().String(), + Operation: nullify, + CreatedAt: t2, + Seq: 3, + } + + prepOp, err := validateInner(ctx, seqop, store) + assert.NoError(err) + assert.Len(prepOp.NullifiedOps, 1) + assert.Equal(update1.CID().String(), prepOp.NullifiedOps[0]) +} + +func TestValidateInner_NullificationTooSlow(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + privRecovery, pubKeyRecovery := generateKey(t) + priv, pubKey := generateKey(t) + rotationKeys := []string{pubKeyRecovery, pubKey} + + genesis, did := createGenesis(t, privRecovery, rotationKeys) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + // create and commit a regular update signed by key at index 1 + update1 := createUpdate(t, priv, rotationKeys, genesisCID) + t1 := t0.Add(time.Hour) + prepOp1, err := didplc.VerifyOperation(ctx, store, did, update1, t1) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{prepOp1})) + + // try nullification after 72h have passed since update1 + nullify := createUpdate(t, privRecovery, rotationKeys, genesisCID) + tLate := t1.Add(73 * time.Hour) + + seqop := &SequencedOp{ + DID: did, + CID: nullify.CID().String(), + Operation: nullify, + CreatedAt: tLate, + Seq: 3, + } + + _, err = validateInner(ctx, seqop, store) + assert.Error(err) + assert.Contains(err.Error(), "72h") +} + +func TestValidateInner_Tombstone(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + tombstone := &didplc.TombstoneOp{ + Type: "plc_tombstone", + Prev: genesisCID, + } + require.NoError(t, tombstone.Sign(priv)) + t1 := t0.Add(time.Hour) + + seqop := &SequencedOp{ + DID: did, + CID: tombstone.CID().String(), + Operation: tombstone, + CreatedAt: t1, + Seq: 2, + } + + prepOp, err := validateInner(ctx, seqop, store) + assert.NoError(err) + assert.Equal(did, prepOp.DID) +} + +func TestValidateWorker_ValidOp(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + infl := NewInFlight(-1) + + priv, pubKey := generateKey(t) + op, did := createGenesis(t, priv, []string{pubKey}) + + seqops := make(chan *SequencedOp, 1) + validatedOps := make(chan ValidatedOp, 1) + + seqop := &SequencedOp{ + DID: did, + CID: op.CID().String(), + Operation: op, + CreatedAt: time.Now(), + Seq: 1, + } + infl.AddInFlight(did, 1) + seqops <- seqop + close(seqops) + + ValidateWorker(ctx, seqops, validatedOps, infl, store) + close(validatedOps) + + var results []ValidatedOp + for vop := range validatedOps { + results = append(results, vop) + } + + assert.Len(results, 1) + assert.Equal(int64(1), results[0].Seq) + assert.Equal(did, results[0].PrepOp.DID) +} + +func TestValidateWorker_InvalidOp(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + infl := NewInFlight(-1) + + priv, pubKey := generateKey(t) + op, _ := createGenesis(t, priv, []string{pubKey}) + + seqops := make(chan *SequencedOp, 1) + validatedOps := make(chan ValidatedOp, 1) + + // wrong DID should cause validation failure + seqop := &SequencedOp{ + DID: "did:plc:wrong", + CID: op.CID().String(), + Operation: op, + CreatedAt: time.Now(), + Seq: 1, + } + infl.AddInFlight("did:plc:wrong", 1) + seqops <- seqop + close(seqops) + + ValidateWorker(ctx, seqops, validatedOps, infl, store) + close(validatedOps) + + var results []ValidatedOp + for vop := range validatedOps { + results = append(results, vop) + } + assert.Empty(results) + + // inflight should have been cleaned up + assert.True(infl.AddInFlight("did:plc:wrong", 2), "DID should be available again after failed validation") +} + +func TestCommitWorker_CommitsBatch(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + infl := NewInFlight(-1) + + priv, pubKey := generateKey(t) + op, did := createGenesis(t, priv, []string{pubKey}) + now := time.Now() + + prepOp, err := didplc.VerifyOperation(ctx, store, did, op, now) + require.NoError(t, err) + + validatedOps := make(chan ValidatedOp) // unbuffered: send blocks until worker reads + flushCh := make(chan chan struct{}) + + infl.AddInFlight(did, 1) + + workerDone := make(chan struct{}) + go func() { + CommitWorker(ctx, validatedOps, infl, store, flushCh, NewReplicaState()) + close(workerDone) + }() + + // send blocks until CommitWorker reads, so we know it has the op + validatedOps <- ValidatedOp{Seq: 1, PrepOp: prepOp} + + // now flush — the op is guaranteed to be in the batch + done := make(chan struct{}) + flushCh <- done + <-done + + // verify it was committed + head, err := store.GetLatest(ctx, did) + assert.NoError(err) + assert.Equal(op.CID().String(), head.OpCid) + + // close to stop the worker and wait for it to exit + close(validatedOps) + <-workerDone + + // inflight should have been cleaned up + assert.True(infl.AddInFlight(did, 2)) +} + +func TestCommitWorker_FlushOnClose(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + infl := NewInFlight(-1) + + priv, pubKey := generateKey(t) + op, did := createGenesis(t, priv, []string{pubKey}) + now := time.Now() + + prepOp, err := didplc.VerifyOperation(ctx, store, did, op, now) + require.NoError(t, err) + + validatedOps := make(chan ValidatedOp, 1) + flushCh := make(chan chan struct{}) + + infl.AddInFlight(did, 1) + validatedOps <- ValidatedOp{Seq: 1, PrepOp: prepOp} + close(validatedOps) + + // run synchronously — CommitWorker returns when channel is closed + CommitWorker(ctx, validatedOps, infl, store, flushCh, NewReplicaState()) + + head, err := store.GetLatest(ctx, did) + assert.NoError(err) + assert.Equal(op.CID().String(), head.OpCid) +} + +func TestEndToEnd_MultipleOps(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + infl := NewInFlight(-1) + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + + // validate and commit genesis + genesisPrepOp, err := didplc.VerifyOperation(ctx, store, did, genesis, t0) + require.NoError(t, err) + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{genesisPrepOp})) + genesisCID := genesisPrepOp.OpCid + + // create update + update := createUpdate(t, priv, []string{pubKey}, genesisCID) + t1 := t0.Add(time.Hour) + + seqops := make(chan *SequencedOp, 1) + validatedOps := make(chan ValidatedOp, 1) + flushCh := make(chan chan struct{}, 1) + + seqop := &SequencedOp{ + DID: did, + CID: update.CID().String(), + Operation: update, + CreatedAt: t1, + Seq: 2, + } + infl.AddInFlight(did, 2) + seqops <- seqop + close(seqops) + + // run validate worker + ValidateWorker(ctx, seqops, validatedOps, infl, store) + close(validatedOps) + + // collect validated ops and commit + var validated []ValidatedOp + for vop := range validatedOps { + validated = append(validated, vop) + } + require.Len(t, validated, 1) + + // commit via store directly + require.NoError(t, store.CommitOperations(ctx, []*didplc.PreparedOperation{validated[0].PrepOp})) + infl.RemoveInFlight(did, 2) + _ = flushCh // unused in this test path + + // verify head updated + head, err := store.GetLatest(ctx, did) + assert.NoError(err) + assert.Equal(update.CID().String(), head.OpCid) +} + +func TestValidateInner_RotationKeyChange(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + + priv1, pubKey1 := generateKey(t) + priv2, pubKey2 := generateKey(t) + + // genesis with both keys as rotation keys + genesis, did := createGenesis(t, priv1, []string{pubKey1, pubKey2}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + genesisCID := commitGenesis(t, ctx, store, genesis, did, t0) + + // update signed by key2 (second rotation key) — should succeed + update := createUpdate(t, priv2, []string{pubKey2}, genesisCID) + t1 := t0.Add(time.Hour) + + seqop := &SequencedOp{ + DID: did, + CID: update.CID().String(), + Operation: update, + CreatedAt: t1, + Seq: 2, + } + + prepOp, err := validateInner(ctx, seqop, store) + assert.NoError(err) + assert.Equal(1, prepOp.KeyIndex, "should be signed by second rotation key") +} + +func TestCommitWorker_UpdatesReplicaState(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + infl := NewInFlight(-1) + state := NewReplicaState() + + priv, pubKey := generateKey(t) + op, did := createGenesis(t, priv, []string{pubKey}) + opTime := time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC) + + prepOp, err := didplc.VerifyOperation(ctx, store, did, op, opTime) + require.NoError(t, err) + + validatedOps := make(chan ValidatedOp, 1) + flushCh := make(chan chan struct{}) + + infl.AddInFlight(did, 1) + validatedOps <- ValidatedOp{Seq: 1, PrepOp: prepOp} + close(validatedOps) + + CommitWorker(ctx, validatedOps, infl, store, flushCh, state) + + assert.Equal(opTime, state.GetLastCommittedOpTime(), + "ReplicaState should reflect the committed op time") +} + +func TestCommitWorker_ReplicaState_MaxTime(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + infl := NewInFlight(-1) + state := NewReplicaState() + + // Create two independent DIDs with different timestamps + priv1, pubKey1 := generateKey(t) + op1, did1 := createGenesis(t, priv1, []string{pubKey1}) + t1 := time.Date(2024, 6, 15, 10, 0, 0, 0, time.UTC) + + priv2, pubKey2 := generateKey(t) + op2, did2 := createGenesis(t, priv2, []string{pubKey2}) + t2 := time.Date(2024, 6, 15, 14, 0, 0, 0, time.UTC) // later + + prepOp1, err := didplc.VerifyOperation(ctx, store, did1, op1, t1) + require.NoError(t, err) + prepOp2, err := didplc.VerifyOperation(ctx, store, did2, op2, t2) + require.NoError(t, err) + + validatedOps := make(chan ValidatedOp, 2) + flushCh := make(chan chan struct{}) + + infl.AddInFlight(did1, 1) + infl.AddInFlight(did2, 2) + validatedOps <- ValidatedOp{Seq: 1, PrepOp: prepOp1} + validatedOps <- ValidatedOp{Seq: 2, PrepOp: prepOp2} + close(validatedOps) + + CommitWorker(ctx, validatedOps, infl, store, flushCh, state) + + assert.Equal(t2, state.GetLastCommittedOpTime(), + "ReplicaState should reflect the later of the two op times") +} + +func TestEndToEnd_ValidateThenCommitWorker(t *testing.T) { + assert := assert.New(t) + ctx := context.Background() + store := didplc.NewMemOpStore() + infl := NewInFlight(-1) + state := NewReplicaState() + + priv, pubKey := generateKey(t) + genesis, did := createGenesis(t, priv, []string{pubKey}) + t0 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + + seqops := make(chan *SequencedOp, 1) + validatedOps := make(chan ValidatedOp, 1) + flushCh := make(chan chan struct{}) + + seqop := &SequencedOp{ + DID: did, + CID: genesis.CID().String(), + Operation: genesis, + CreatedAt: t0, + Seq: 1, + } + infl.AddInFlight(did, 1) + seqops <- seqop + close(seqops) + + // Run validate worker — it reads from seqops and writes to validatedOps + ValidateWorker(ctx, seqops, validatedOps, infl, store) + close(validatedOps) + + // Run commit worker — it reads from validatedOps and commits to store + CommitWorker(ctx, validatedOps, infl, store, flushCh, state) + + // Verify the op was committed + head, err := store.GetLatest(ctx, did) + assert.NoError(err) + require.NotNil(t, head) + assert.Equal(genesis.CID().String(), head.OpCid) + + // Verify state was updated + assert.Equal(t0, state.GetLastCommittedOpTime()) + + // Verify inflight was cleaned up + assert.True(infl.AddInFlight(did, 2), "DID should be available after pipeline completes") +}