A request gate in the replica client: a refusal stops the attempt before it is sent or counted
The request meter sends when it cannot record, by design, so a recorder that refuses could never stop a request (second review, both reviewers, P1). Permission to send is now its own middleware, outside the meter in the same step: a refused attempt reaches neither the network nor the meter, and litestream sees a failed upload. Every metered database must name a gate. Tested through a real S3 client in both option orders, on a retry, and through the store's own clients.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 files changed,
+337
−19
internal/lease/meteredstore.go+17 −3internal/lease/meteredstore_test.go+18 −14internal/lease/requestgate.go+76 −0internal/lease/requestgate_test.go+224 −0internal/lease/requestmeter.go+2 −2
diff --git a/internal/lease/meteredstore.go b/internal/lease/meteredstore.go| index 367dbbf6a..6533ecb74 100644 |
| --- a/internal/lease/meteredstore.go |
| +++ b/internal/lease/meteredstore.go |
| @@ -31,7 +31,9 @@ import ( |
| // |
| // What it refuses. A database with no meter. There is no unmetered |
| // fallback here, on purpose: a write that is not counted is exactly what |
| -// the attestation would be lying about. |
| +// the attestation would be lying about. And a database with no gate: the |
| +// meter sends when it cannot record (and holds the day), so stopping a |
| +// request is the gate's job and not the meter's. |
| // |
| // What it is not, yet. Nothing calls it. It does not replace the external |
| // binary, change a unit, or attest anything. Replacing the binary also |
| @@ -49,6 +51,12 @@ type MeteredDB struct { |
| // Meter records every request the replica client makes. It is bound |
| // by the caller to the database's owner. Required. |
| Meter RequestRecorder |
| + // Gate is asked before every request the replica client makes, and a |
| + // refusal stops the request before it is sent (RequestGate). It is |
| + // where the caller says whether this database is still the owner's to |
| + // replicate. Required: a client nothing can stop would go on writing, |
| + // uncounted, after its meter had been told to stop counting. |
| + Gate RequestGate |
| // MetaDir is where litestream keeps this database's shadow state. |
| // Required, absolute, and not shared with any other database. The |
| // caller puts it in a tree only the platform can write: beside the |
| @@ -63,7 +71,7 @@ type MeteredDB struct { |
| // NewMeteredStore builds one litestream store over dbs, each replica client |
| // metered by its own recorder. The store is returned unopened. It refuses |
| -// an empty list, a database with no path, prefix or meter, and two |
| +// an empty list, a database with no path, prefix, meter or gate, and two |
| // databases that share a file or a replica prefix (two owners must never |
| // be billed for one stream, nor one stream written twice). |
| func NewMeteredStore(bucket, region string, dbs []MeteredDB) (*litestream.Store, error) { |
| @@ -98,6 +106,8 @@ func NewMeteredStore(bucket, region string, dbs []MeteredDB) (*litestream.Store, |
| return nil, fmt.Errorf("lease: metered store: %s has no replica prefix", d.Path) |
| case d.Meter == nil: |
| return nil, fmt.Errorf("lease: metered store: %s: %w", d.Path, errRequestMeterRequired) |
| + case d.Gate == nil: |
| + return nil, fmt.Errorf("lease: metered store: %s: %w", d.Path, errRequestGateRequired) |
| case paths[filepath.Clean(d.Path)]: |
| return nil, fmt.Errorf("lease: metered store: %s is listed twice", d.Path) |
| case prefixes[d.Prefix]: |
| @@ -115,7 +125,11 @@ func NewMeteredStore(bucket, region string, dbs []MeteredDB) (*litestream.Store, |
| // caller's, in a tree tenants cannot write (MeteredDB.MetaDir). |
| db.SetMetaPath(filepath.Clean(d.MetaDir)) |
| db.MonitorInterval = dbMonitorInterval |
| - db.Replica = litestream.NewReplicaWithClient(db, newReplicaClient(bucket, d.Prefix, region, []func(*awss3.Options){option}...)) |
| + gate, err := requestGateOption(d.Gate) |
| + if err != nil { |
| + return nil, fmt.Errorf("lease: metered store: %s: %w", d.Path, err) |
| + } |
| + db.Replica = litestream.NewReplicaWithClient(db, newReplicaClient(bucket, d.Prefix, region, []func(*awss3.Options){gate, option}...)) |
| if d.SyncInterval > 0 { |
| db.Replica.SyncInterval = d.SyncInterval |
| } |
diff --git a/internal/lease/meteredstore_test.go b/internal/lease/meteredstore_test.go| index bfd400823..a78abe152 100644 |
| --- a/internal/lease/meteredstore_test.go |
| +++ b/internal/lease/meteredstore_test.go |
| @@ -20,8 +20,8 @@ func TestMeteredStoreCountsEveryRequestOnItsOwnersMeter(t *testing.T) { |
| dir := t.TempDir() |
| one, two := &memoryRequestRecorder{}, &memoryRequestRecorder{} |
| store, err := NewMeteredStore("test-bucket", "eu-west-1", []MeteredDB{ |
| - {Path: filepath.Join(dir, "one.db"), Prefix: "replicas/bab/one", Meter: one, MetaDir: filepath.Join(dir, ".replicator", "one")}, |
| - {Path: filepath.Join(dir, "two.db"), Prefix: "replicas/bac/two", Meter: two, MetaDir: filepath.Join(dir, ".replicator", "two")}, |
| + {Path: filepath.Join(dir, "one.db"), Prefix: "replicas/bab/one", Meter: one, Gate: admitEveryRequest, MetaDir: filepath.Join(dir, ".replicator", "one")}, |
| + {Path: filepath.Join(dir, "two.db"), Prefix: "replicas/bac/two", Meter: two, Gate: admitEveryRequest, MetaDir: filepath.Join(dir, ".replicator", "two")}, |
| }) |
| if err != nil { |
| t.Fatal(err) |
| @@ -66,7 +66,7 @@ func TestMeteredStoreRefusesWhatItCannotAttribute(t *testing.T) { |
| dir := t.TempDir() |
| m := &memoryRequestRecorder{} |
| metaA, metaB := filepath.Join(dir, ".replicator", "a"), filepath.Join(dir, ".replicator", "b") |
| - ok := MeteredDB{Path: filepath.Join(dir, "a.db"), Prefix: "replicas/a", Meter: m, MetaDir: metaA} |
| + ok := MeteredDB{Path: filepath.Join(dir, "a.db"), Prefix: "replicas/a", Meter: m, Gate: admitEveryRequest, MetaDir: metaA} |
| // The same file under another name: a link a tenant could plant. |
| if err := os.WriteFile(ok.Path, []byte("x"), 0o644); err != nil { |
| t.Fatal(err) |
| @@ -77,24 +77,28 @@ func TestMeteredStoreRefusesWhatItCannotAttribute(t *testing.T) { |
| } |
| for name, dbs := range map[string][]MeteredDB{ |
| "no databases": nil, |
| - "no meter": {{Path: ok.Path, Prefix: ok.Prefix, MetaDir: metaA}}, |
| - "no prefix": {{Path: ok.Path, Meter: m, MetaDir: metaA}}, |
| - "a relative path": {{Path: "a.db", Prefix: ok.Prefix, Meter: m, MetaDir: metaA}}, |
| - "no metadata directory": {{Path: ok.Path, Prefix: ok.Prefix, Meter: m}}, |
| - "a relative metadata directory": {{Path: ok.Path, Prefix: ok.Prefix, Meter: m, MetaDir: ".replicator/a"}}, |
| - "one metadata directory for two": {ok, {Path: filepath.Join(dir, "b.db"), Prefix: "replicas/b", Meter: m, MetaDir: metaA}}, |
| - "one file listed twice": {ok, {Path: filepath.Join(dir, ".", "a.db"), Prefix: "replicas/b", Meter: m, MetaDir: metaB}}, |
| - "one file under a link": {ok, {Path: link, Prefix: "replicas/b", Meter: m, MetaDir: metaB}}, |
| - "one prefix for two": {ok, {Path: filepath.Join(dir, "b.db"), Prefix: ok.Prefix, Meter: m, MetaDir: metaB}}, |
| - "a meter missing in many": {ok, {Path: filepath.Join(dir, "b.db"), Prefix: "replicas/b", MetaDir: metaB}}, |
| + "no meter": {{Path: ok.Path, Prefix: ok.Prefix, Gate: admitEveryRequest, MetaDir: metaA}}, |
| + "no gate": {{Path: ok.Path, Prefix: ok.Prefix, Meter: m, MetaDir: metaA}}, |
| + "no prefix": {{Path: ok.Path, Meter: m, Gate: admitEveryRequest, MetaDir: metaA}}, |
| + "a relative path": {{Path: "a.db", Prefix: ok.Prefix, Meter: m, Gate: admitEveryRequest, MetaDir: metaA}}, |
| + "no metadata directory": {{Path: ok.Path, Prefix: ok.Prefix, Meter: m, Gate: admitEveryRequest}}, |
| + "a relative metadata directory": {{Path: ok.Path, Prefix: ok.Prefix, Meter: m, Gate: admitEveryRequest, MetaDir: ".replicator/a"}}, |
| + "one metadata directory for two": {ok, {Path: filepath.Join(dir, "b.db"), Prefix: "replicas/b", Meter: m, Gate: admitEveryRequest, MetaDir: metaA}}, |
| + "one file listed twice": {ok, {Path: filepath.Join(dir, ".", "a.db"), Prefix: "replicas/b", Meter: m, Gate: admitEveryRequest, MetaDir: metaB}}, |
| + "one file under a link": {ok, {Path: link, Prefix: "replicas/b", Meter: m, Gate: admitEveryRequest, MetaDir: metaB}}, |
| + "one prefix for two": {ok, {Path: filepath.Join(dir, "b.db"), Prefix: ok.Prefix, Meter: m, Gate: admitEveryRequest, MetaDir: metaB}}, |
| + "a meter missing in many": {ok, {Path: filepath.Join(dir, "b.db"), Prefix: "replicas/b", Gate: admitEveryRequest, MetaDir: metaB}}, |
| } { |
| if store, err := NewMeteredStore("test-bucket", "eu-west-1", dbs); err == nil || store != nil { |
| t.Errorf("%s: accepted", name) |
| } |
| } |
| - if _, err := NewMeteredStore("test-bucket", "eu-west-1", []MeteredDB{{Path: ok.Path, Prefix: ok.Prefix, MetaDir: metaA}}); !errors.Is(err, errRequestMeterRequired) { |
| + if _, err := NewMeteredStore("test-bucket", "eu-west-1", []MeteredDB{{Path: ok.Path, Prefix: ok.Prefix, Gate: admitEveryRequest, MetaDir: metaA}}); !errors.Is(err, errRequestMeterRequired) { |
| t.Errorf("no meter: %v, want the meter-required error", err) |
| } |
| + if _, err := NewMeteredStore("test-bucket", "eu-west-1", []MeteredDB{{Path: ok.Path, Prefix: ok.Prefix, Meter: m, MetaDir: metaA}}); !errors.Is(err, errRequestGateRequired) { |
| + t.Errorf("no gate: %v, want the gate-required error", err) |
| + } |
| if _, err := NewMeteredStore("", "eu-west-1", []MeteredDB{ok}); err == nil { |
| t.Error("no bucket: accepted") |
| } |
diff --git a/internal/lease/requestgate.go b/internal/lease/requestgate.go| new file mode 100644 |
| index 000000000..6b3bfe345 |
| --- /dev/null |
| +++ b/internal/lease/requestgate.go |
| @@ -0,0 +1,76 @@ |
| +package lease |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + |
| + "github.com/aws/aws-sdk-go-v2/service/s3" |
| + "github.com/aws/smithy-go/middleware" |
| +) |
| + |
| +// RequestGate decides whether one physical attempt may be sent. It is asked |
| +// on the request path, once for every attempt (a retry is asked again), |
| +// before the attempt is recorded and before it is dispatched. A non-nil |
| +// answer stops the attempt: nothing is written to the network, nothing is |
| +// recorded, and the caller of the S3 operation gets ErrRequestRefused |
| +// wrapping the gate's own error. |
| +// |
| +// It exists apart from the request meter on purpose. The meter records; a |
| +// recording that fails must not stop replication, so the meter sends anyway |
| +// and holds the day. Permission to send is a different question, and an |
| +// answer of "no" has to reach litestream as a failed upload so that it does |
| +// not advance its replica position. |
| +// |
| +// The gate is given the request's context and decides for itself how long |
| +// it is willing to take. An attempt waiting on its gate has not been sent. |
| +type RequestGate func(ctx context.Context) error |
| + |
| +// ErrRequestRefused: a request gate stopped an attempt before dispatch. |
| +var ErrRequestRefused = errors.New("lease: replica request refused before dispatch") |
| + |
| +var errRequestGateRequired = errors.New("replication_gate_required") |
| + |
| +const ( |
| + requestGateMiddlewareID = "CarlosReplicationRequestGate" |
| + requestMeterMiddlewareID = "CarlosReplicationRequestMeter" |
| +) |
| + |
| +// requestGateOption puts gate in front of every attempt an S3 client makes. |
| +// It sits in the same step as the request meter and outside it, whichever |
| +// of the two options is applied first: a refused attempt never reaches the |
| +// meter, so it is neither counted nor left as a started row nobody |
| +// finishes. A second gate on one client is a wiring error and fails every |
| +// operation, rather than letting either gate think it is the only one. |
| +func requestGateOption(gate RequestGate) (func(*s3.Options), error) { |
| + if gate == nil { |
| + return nil, errRequestGateRequired |
| + } |
| + return func(options *s3.Options) { |
| + options.APIOptions = append(options.APIOptions, func(stack *middleware.Stack) error { |
| + if _, exists := stack.Deserialize.Get(requestGateMiddlewareID); exists { |
| + return fmt.Errorf("%w: two request gates on one client", ErrRequestRefused) |
| + } |
| + hook := middleware.DeserializeMiddlewareFunc(requestGateMiddlewareID, func(ctx context.Context, in middleware.DeserializeInput, next middleware.DeserializeHandler) (middleware.DeserializeOutput, middleware.Metadata, error) { |
| + if err := askRequestGate(ctx, gate); err != nil { |
| + return middleware.DeserializeOutput{}, middleware.Metadata{}, fmt.Errorf("%w: %w", ErrRequestRefused, err) |
| + } |
| + return next.HandleDeserialize(ctx, in) |
| + }) |
| + if _, metered := stack.Deserialize.Get(requestMeterMiddlewareID); metered { |
| + return stack.Deserialize.Insert(hook, requestMeterMiddlewareID, middleware.Before) |
| + } |
| + return stack.Deserialize.Add(hook, middleware.After) |
| + }) |
| + }, nil |
| +} |
| + |
| +// askRequestGate asks the gate, and reads a gate that panics as a refusal. |
| +func askRequestGate(ctx context.Context, gate RequestGate) (err error) { |
| + defer func() { |
| + if recover() != nil { |
| + err = errors.New("request gate panicked") |
| + } |
| + }() |
| + return gate(ctx) |
| +} |
diff --git a/internal/lease/requestgate_test.go b/internal/lease/requestgate_test.go| new file mode 100644 |
| index 000000000..c358ee62b |
| --- /dev/null |
| +++ b/internal/lease/requestgate_test.go |
| @@ -0,0 +1,224 @@ |
| +package lease |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "errors" |
| + "net/http" |
| + "net/http/httptest" |
| + "path/filepath" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/aws/aws-sdk-go-v2/aws" |
| + "github.com/aws/aws-sdk-go-v2/aws/retry" |
| + "github.com/aws/aws-sdk-go-v2/credentials" |
| + "github.com/aws/aws-sdk-go-v2/service/s3" |
| +) |
| + |
| +func admitEveryRequest(context.Context) error { return nil } |
| + |
| +// gatedTestClient is a real S3 client over a test server with a gate and a |
| +// meter on it, the two options applied in the order given. |
| +func gatedTestClient(t *testing.T, handler http.HandlerFunc, gate RequestGate, recorder requestRecorder, gateFirst bool) *s3.Client { |
| + t.Helper() |
| + srv := httptest.NewServer(handler) |
| + t.Cleanup(srv.Close) |
| + meterOpt, err := requestMeterOption(recorder) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + gateOpt, err := requestGateOption(gate) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + cfg := aws.Config{Region: "eu-west-1", Credentials: credentials.NewStaticCredentialsProvider("dummy", "dummy", ""), HTTPClient: srv.Client(), Retryer: func() aws.Retryer { |
| + return retry.AddWithMaxBackoffDelay(retry.NewStandard(func(o *retry.StandardOptions) { o.MaxAttempts = 3 }), time.Millisecond) |
| + }} |
| + base := func(o *s3.Options) { |
| + o.BaseEndpoint = aws.String(srv.URL) |
| + o.UsePathStyle = true |
| + o.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired |
| + o.ResponseChecksumValidation = aws.ResponseChecksumValidationWhenRequired |
| + } |
| + if gateFirst { |
| + return s3.NewFromConfig(cfg, base, gateOpt, meterOpt) |
| + } |
| + return s3.NewFromConfig(cfg, base, meterOpt, gateOpt) |
| +} |
| + |
| +// A refusal stops the attempt: nothing reaches the server, nothing is |
| +// recorded (not even a started row or a held day), the caller gets the |
| +// gate's error, and the SDK does not try again. This is the difference |
| +// from the meter, which sends when it cannot record. It holds whichever of |
| +// the two options the client was given first. |
| +func TestRequestGateStopsAnAttemptBeforeItIsSentOrCounted(t *testing.T) { |
| + for name, gateFirst := range map[string]bool{"gate option first": true, "meter option first": false} { |
| + t.Run(name, func(t *testing.T) { |
| + recorder := &memoryRequestRecorder{} |
| + var received, asked atomic.Int32 |
| + var refuse atomic.Bool |
| + stale := errors.New("not the owner's any more") |
| + refuse.Store(true) |
| + client := gatedTestClient(t, func(w http.ResponseWriter, r *http.Request) { |
| + received.Add(1) |
| + w.WriteHeader(http.StatusOK) |
| + }, func(context.Context) error { |
| + asked.Add(1) |
| + if refuse.Load() { |
| + return stale |
| + } |
| + return nil |
| + }, recorder, gateFirst) |
| + err := meterPut(context.Background(), client) |
| + if !errors.Is(err, ErrRequestRefused) || !errors.Is(err, stale) { |
| + t.Fatalf("a refused request returned %v, want ErrRequestRefused wrapping the gate's error", err) |
| + } |
| + if received.Load() != 0 { |
| + t.Fatalf("%d requests reached the server after a refusal", received.Load()) |
| + } |
| + if asked.Load() != 1 { |
| + t.Fatalf("the gate was asked %d times for one refused call: a refusal must not be retried", asked.Load()) |
| + } |
| + recorder.mu.Lock() |
| + if len(recorder.requests) != 0 || recorder.incomplete != 0 || recorder.holds != 0 { |
| + t.Fatalf("a refused request reached the meter: %d started, %d incomplete, %d holds", len(recorder.requests), recorder.incomplete, recorder.holds) |
| + } |
| + recorder.mu.Unlock() |
| + // The same client, admitted: sent once and counted once. |
| + refuse.Store(false) |
| + if err := meterPut(context.Background(), client); err != nil { |
| + t.Fatal(err) |
| + } |
| + recorder.mu.Lock() |
| + defer recorder.mu.Unlock() |
| + if received.Load() != 1 || len(recorder.requests) != 1 || !recorder.requests[0].finished { |
| + t.Fatalf("an admitted request: %d reached the server, %d metered", received.Load(), len(recorder.requests)) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// The gate is asked for every physical attempt, not once for the |
| +// operation: a retry the SDK makes after the gate has closed is not sent. |
| +func TestRequestGateIsAskedAgainForARetry(t *testing.T) { |
| + recorder := &memoryRequestRecorder{} |
| + var received, asked atomic.Int32 |
| + var refuse atomic.Bool |
| + client := gatedTestClient(t, func(w http.ResponseWriter, r *http.Request) { |
| + received.Add(1) |
| + // The first attempt fails in a way the SDK retries, and the |
| + // gate closes while it is in flight. |
| + refuse.Store(true) |
| + w.WriteHeader(http.StatusInternalServerError) |
| + _, _ = w.Write([]byte(`<Error><Code>InternalError</Code></Error>`)) |
| + }, func(context.Context) error { |
| + asked.Add(1) |
| + if refuse.Load() { |
| + return errors.New("closed") |
| + } |
| + return nil |
| + }, recorder, true) |
| + err := meterPut(context.Background(), client) |
| + if !errors.Is(err, ErrRequestRefused) { |
| + t.Fatalf("the retry returned %v, want ErrRequestRefused", err) |
| + } |
| + if received.Load() != 1 || asked.Load() != 2 { |
| + t.Fatalf("%d attempts reached the server and the gate was asked %d times: want 1 and 2", received.Load(), asked.Load()) |
| + } |
| + recorder.mu.Lock() |
| + defer recorder.mu.Unlock() |
| + if len(recorder.requests) != 1 || !recorder.requests[0].finished { |
| + t.Fatalf("the attempt that was sent must be metered whole, and only it: %+v", recorder.requests) |
| + } |
| +} |
| + |
| +// A gate that panics has not said yes. |
| +func TestRequestGateThatPanicsRefuses(t *testing.T) { |
| + recorder := &memoryRequestRecorder{} |
| + var received atomic.Int32 |
| + client := gatedTestClient(t, func(w http.ResponseWriter, r *http.Request) { |
| + received.Add(1) |
| + w.WriteHeader(http.StatusOK) |
| + }, func(context.Context) error { panic("gate") }, recorder, true) |
| + if err := meterPut(context.Background(), client); !errors.Is(err, ErrRequestRefused) { |
| + t.Fatalf("a panicking gate returned %v", err) |
| + } |
| + if received.Load() != 0 { |
| + t.Fatal("a request was sent past a gate that panicked") |
| + } |
| +} |
| + |
| +// Two gates on one client is a wiring error. Every operation fails and |
| +// nothing is sent, whatever the gates would have said. |
| +func TestTwoRequestGatesOnOneClientSendNothing(t *testing.T) { |
| + var received atomic.Int32 |
| + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + received.Add(1) |
| + w.WriteHeader(http.StatusOK) |
| + })) |
| + t.Cleanup(srv.Close) |
| + one, _ := requestGateOption(admitEveryRequest) |
| + two, _ := requestGateOption(admitEveryRequest) |
| + cfg := aws.Config{Region: "eu-west-1", Credentials: credentials.NewStaticCredentialsProvider("dummy", "dummy", ""), HTTPClient: srv.Client()} |
| + client := s3.NewFromConfig(cfg, func(o *s3.Options) { |
| + o.BaseEndpoint = aws.String(srv.URL) |
| + o.UsePathStyle = true |
| + }, one, two) |
| + if err := meterPut(context.Background(), client); !errors.Is(err, ErrRequestRefused) { |
| + t.Fatalf("two gates: %v, want ErrRequestRefused", err) |
| + } |
| + if received.Load() != 0 { |
| + t.Fatal("a request was sent through a client with two gates") |
| + } |
| + if _, err := requestGateOption(nil); !errors.Is(err, errRequestGateRequired) { |
| + t.Fatalf("no gate: %v", err) |
| + } |
| +} |
| + |
| +// The store's own clients, the ones litestream writes through: a database |
| +// whose gate refuses sends nothing and counts nothing, litestream's write |
| +// fails, and its neighbour in the same store is untouched. |
| +func TestMeteredStoreClientIsStoppedByItsGate(t *testing.T) { |
| + f, endpoint := newFakeS3(t) |
| + fakeCreds(t) |
| + t.Setenv("CARLOS_S3_ENDPOINT", endpoint) |
| + dir := t.TempDir() |
| + one, two := &memoryRequestRecorder{}, &memoryRequestRecorder{} |
| + var closed atomic.Bool |
| + store, err := NewMeteredStore("test-bucket", "eu-west-1", []MeteredDB{ |
| + {Path: filepath.Join(dir, "one.db"), Prefix: "replicas/bab/one", Meter: one, MetaDir: filepath.Join(dir, ".replicator", "one"), Gate: func(context.Context) error { |
| + if closed.Load() { |
| + return errors.New("closed") |
| + } |
| + return nil |
| + }}, |
| + {Path: filepath.Join(dir, "two.db"), Prefix: "replicas/bac/two", Meter: two, MetaDir: filepath.Join(dir, ".replicator", "two"), Gate: admitEveryRequest}, |
| + }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + dbs := store.DBs() |
| + ctx := context.Background() |
| + if _, err := dbs[0].Replica.Client.WriteLTXFile(ctx, 0, 2, 2, bytes.NewReader(mustLTXBytes(t))); err != nil { |
| + t.Fatal(err) |
| + } |
| + closed.Store(true) |
| + if _, err := dbs[0].Replica.Client.WriteLTXFile(ctx, 0, 3, 3, bytes.NewReader(mustLTXBytes(t))); !errors.Is(err, ErrRequestRefused) { |
| + t.Fatalf("litestream's write through a closed gate returned %v, want ErrRequestRefused", err) |
| + } |
| + if _, err := dbs[1].Replica.Client.WriteLTXFile(ctx, 0, 2, 2, bytes.NewReader(mustLTXBytes(t))); err != nil { |
| + t.Fatalf("the neighbour's write: %v", err) |
| + } |
| + one.mu.Lock() |
| + defer one.mu.Unlock() |
| + two.mu.Lock() |
| + defer two.mu.Unlock() |
| + if len(one.requests) != 1 || len(two.requests) != 1 { |
| + t.Fatalf("meters saw %d and %d requests, want 1 and 1", len(one.requests), len(two.requests)) |
| + } |
| + if got := len(f.requests()); got != 2 { |
| + t.Fatalf("%d requests reached the bucket, want 2: %v", got, f.requests()) |
| + } |
| +} |
diff --git a/internal/lease/requestmeter.go b/internal/lease/requestmeter.go| index 620b49e34..ac4baf2a2 100644 |
| --- a/internal/lease/requestmeter.go |
| +++ b/internal/lease/requestmeter.go |
| @@ -76,7 +76,7 @@ func requestMeterOption(recorder requestRecorder) (func(*s3.Options), error) { |
| slots := make(chan struct{}, requestMeterSlots) |
| return func(options *s3.Options) { |
| options.APIOptions = append(options.APIOptions, func(stack *middleware.Stack) error { |
| - if previous, exists := stack.Deserialize.Get("CarlosReplicationRequestMeter"); exists { |
| + if previous, exists := stack.Deserialize.Get(requestMeterMiddlewareID); exists { |
| now := time.Now().UTC() |
| if hook, ok := previous.(*requestMeterHook); ok { |
| hook.recorder.Incomplete(now) |
| @@ -84,7 +84,7 @@ func requestMeterOption(recorder requestRecorder) (func(*s3.Options), error) { |
| recorder.Incomplete(now) |
| return nil |
| } |
| - return stack.Deserialize.Add(&requestMeterHook{recorder: recorder, DeserializeMiddleware: middleware.DeserializeMiddlewareFunc("CarlosReplicationRequestMeter", func(ctx context.Context, in middleware.DeserializeInput, next middleware.DeserializeHandler) (middleware.DeserializeOutput, middleware.Metadata, error) { |
| + return stack.Deserialize.Add(&requestMeterHook{recorder: recorder, DeserializeMiddleware: middleware.DeserializeMiddlewareFunc(requestMeterMiddlewareID, func(ctx context.Context, in middleware.DeserializeInput, next middleware.DeserializeHandler) (middleware.DeserializeOutput, middleware.Metadata, error) { |
| return meterAttempt(ctx, in, next, recorder, slots) |
| })}, middleware.After) |
| }) |