internal/firestore: Firestore implementation of storage.DB
Change-Id: I6f63005d5116e4559c6c3d69bb834916a6894b1e
Reviewed-on: https://go-review.googlesource.com/c/oscar/+/600071
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Zvonimir Pavlinovic <zpavlinovic@google.com>
diff --git a/go.mod b/go.mod
index 80fbe5e..5de6a20 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@
require (
github.com/cockroachdb/pebble v1.1.1
github.com/google/generative-ai-go v0.16.0
+ github.com/google/go-replayers/grpcreplay v1.3.0
golang.org/x/tools v0.23.0
google.golang.org/api v0.186.0
rsc.io/markdown v0.0.0-20240617154923-1f2ef1438fed
@@ -19,6 +20,7 @@
cloud.google.com/go/auth v0.6.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect
cloud.google.com/go/compute/metadata v0.3.0 // indirect
+ cloud.google.com/go/firestore v1.15.0 // indirect
cloud.google.com/go/longrunning v0.5.7 // indirect
github.com/DataDog/zstd v1.4.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@@ -36,7 +38,6 @@
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
- github.com/google/go-replayers/grpcreplay v1.2.0 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
@@ -65,6 +66,7 @@
golang.org/x/sys v0.22.0 // indirect
golang.org/x/text v0.16.0 // indirect
golang.org/x/time v0.5.0 // indirect
+ google.golang.org/genproto v0.0.0-20240617180043-68d350f18fd4 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 // indirect
google.golang.org/grpc v1.64.1 // indirect
diff --git a/go.sum b/go.sum
index 01b7262..cf23ab1 100644
--- a/go.sum
+++ b/go.sum
@@ -31,6 +31,8 @@
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
+cloud.google.com/go/firestore v1.15.0 h1:/k8ppuWOtNuDHt2tsRV42yI21uaGnKDEQnRFeBpbFF8=
+cloud.google.com/go/firestore v1.15.0/go.mod h1:GWOxFXcv8GZUtYpWHw/w6IuYNux/BtmeVTMmjrm4yhk=
cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU=
cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
@@ -163,6 +165,8 @@
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-replayers/grpcreplay v1.2.0 h1:JlxRH0a1d8lLTXq6Xl+CD5aqMwHNhMyMxvFjqa3EAvg=
github.com/google/go-replayers/grpcreplay v1.2.0/go.mod h1:v6NgKtkijC0d3e3RW8il6Sy5sqRVUwoQa4mHOGEy8DI=
+github.com/google/go-replayers/grpcreplay v1.3.0 h1:1Keyy0m1sIpqstQmgz307zhiJ1pV4uIlFds5weTmxbo=
+github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6Sy5sqRVUwoQa4mHOGEy8DI=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@@ -541,6 +545,8 @@
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20240617180043-68d350f18fd4 h1:CUiCqkPw1nNrNQzCCG4WA65m0nAmQiwXHpub3dNyruU=
+google.golang.org/genproto v0.0.0-20240617180043-68d350f18fd4/go.mod h1:EvuUDCulqGgV80RvP1BHuom+smhX4qtlhnNatHuroGQ=
google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4 h1:MuYw1wJzT+ZkybKfaOXKp5hJiZDn2iHaXRw0mRYdHSc=
google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4/go.mod h1:px9SlOOZBg1wM1zdnr8jEL4CNGUBZ+ZKYtNPApNQc4c=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 h1:Di6ANFilr+S60a4S61ZM00vLdw0IrQOSMS2/6mrnOU0=
diff --git a/internal/firestore/db.go b/internal/firestore/db.go
new file mode 100644
index 0000000..447cada
--- /dev/null
+++ b/internal/firestore/db.go
@@ -0,0 +1,240 @@
+// Copyright 2024 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package firestore
+
+import (
+ "context"
+ "encoding/hex"
+ "fmt"
+ "iter"
+ "math/rand/v2"
+ "net/url"
+ "slices"
+ "time"
+
+ "cloud.google.com/go/firestore"
+ "golang.org/x/oscar/internal/storage"
+ "google.golang.org/api/option"
+)
+
+// DB implements [storage.DB].
+type DB struct {
+ *fstore
+ uid int64 // unique ID, to identify lock owners
+}
+
+// NewDB constructs a [DB].
+func NewDB(ctx context.Context, dbopts *DBOptions, copts ...option.ClientOption) (*DB, error) {
+ fs, err := newFstore(ctx, dbopts, copts)
+ if err != nil {
+ return nil, err
+ }
+ return &DB{fs, rand.Int64()}, nil
+}
+
+const (
+ lockCollection = "locks"
+ valueCollection = "values"
+)
+
+// vars for testing
+var (
+ // how long to wait before stealing a lock
+ lockTimeout = 2 * time.Minute
+
+ timeSince = time.Since
+)
+
+// Lock implements [storage.DB.Lock].
+func (db *DB) Lock(name string) {
+ // Wait for the lock in a separate function to avoid defers inside a loop, consuming
+ // memory on each iteration.
+ for !db.waitForLock(name) {
+ }
+}
+
+// waitForLock waits for the lock to become available.
+// It returns true if it acquires the lock.
+// It returns false if the snapshot iterator timed out without the lock
+// being acquired.
+func (db *DB) waitForLock(name string) bool {
+ ctx, cancel := context.WithTimeout(context.Background(), lockTimeout)
+ defer cancel()
+ // A snapshot iterator iterates over changing states of the document.
+ // It yields its first value immediately, and subsequent values only when
+ // the document changes state.
+ dr := db.client.Collection(lockCollection).Doc(url.PathEscape(name))
+ iter := dr.Snapshots(ctx)
+ defer iter.Stop()
+ for {
+ ds, err := iter.Next()
+ if err == nil {
+ if !ds.Exists() && db.tryLock(name) {
+ // The lock doesn't exist and we managed to get it.
+ return true
+ }
+ // Wait for a change in the lock document.
+ continue
+ }
+ if isTimeout(err) {
+ return db.tryLock(name)
+ }
+ // unreachable except for bad DB
+ db.Panic("firestore waiting for lock", "name", name, "err", err)
+ }
+}
+
+// tryLock tries to acquire the named lock in a transaction.
+func (db *DB) tryLock(name string) (res bool) {
+ db.runTransaction(func(ctx context.Context, tx *firestore.Transaction) {
+ uid, createTime := db.getLock(tx, name)
+ if createTime.IsZero() || timeSince(createTime) > lockTimeout {
+ // Lock does not exist or timed out.
+ if !createTime.IsZero() {
+ db.slog.Warn("taking lock", "name", name, "old", uid, "new", db.uid)
+ }
+ db.setLock(tx, name)
+ res = true
+ } else {
+ res = false
+ }
+ })
+ return res
+}
+
+// Unlock releases the lock. It panics if the lock isn't locked by this DB.
+func (db *DB) Unlock(name string) {
+ db.runTransaction(func(ctx context.Context, tx *firestore.Transaction) {
+ uid, createTime := db.getLock(tx, name)
+ if createTime.IsZero() {
+ db.Panic("unlock of never locked key", "key", name)
+ }
+ if uid != db.uid {
+ db.Panic("unlocker is not owner", "key", name)
+ }
+ db.deleteLock(tx, name)
+ })
+}
+
+// A lock describes a lock in firestore.
+// The value consists of the UID of the DB that acquired the lock.
+type lock struct {
+ UID int64
+}
+
+// setLock sets the value of the named lock in the DB, along with its creation time.
+func (db *DB) setLock(tx *firestore.Transaction, name string) {
+ db.set(tx, lockCollection, url.PathEscape(name), lock{db.uid})
+}
+
+// getLock returns the value of the named lock in the DB.
+func (db *DB) getLock(tx *firestore.Transaction, name string) (int64, time.Time) {
+ ds := db.get(tx, lockCollection, url.PathEscape(name))
+ if ds == nil {
+ return 0, time.Time{}
+ }
+ uid := dataTo[lock](db.fstore, ds).UID
+ return uid, ds.CreateTime
+}
+
+// deleteLock deletes the named lock in the DB.
+func (db *DB) deleteLock(tx *firestore.Transaction, name string) {
+ db.delete(tx, lockCollection, url.PathEscape(name))
+}
+
+// Set implements [storage.DB.Set].
+func (db *DB) Set(key, val []byte) {
+ db.set(nil, valueCollection, encodeKey(key), encodeValue(val))
+}
+
+// Get implements [storage.DB.Get].
+func (db *DB) Get(key []byte) ([]byte, bool) {
+ ekey := encodeKey(key)
+ ds := db.get(nil, valueCollection, ekey)
+ if ds == nil {
+ return nil, false
+ }
+ return decodeValue(ds.Data()), true
+}
+
+// Delete implements [storage.DB.Delete].
+func (db *DB) Delete(key []byte) {
+ db.delete(nil, valueCollection, encodeKey(key))
+}
+
+// DeleteRange implements [storage.DB.DeleteRange].
+func (db *DB) DeleteRange(start, end []byte) {
+ db.deleteRange(valueCollection, encodeKey(start), encodeKey(end))
+}
+
+// Scan implements [storage.DB.Scan].
+func (db *DB) Scan(start, end []byte) iter.Seq2[[]byte, func() []byte] {
+ return func(yield func(key []byte, valf func() []byte) bool) {
+ for ds := range db.scan(nil, valueCollection, encodeKey(start), encodeKey(end)) {
+ if !yield(decodeKey(ds.Ref.ID), func() []byte { return decodeValue(ds.Data()) }) {
+ return
+ }
+ }
+ }
+}
+
+// Batch implements [storage.DB.Batch].
+func (db *DB) Batch() storage.Batch {
+ return &dbBatch{db.newBatch(valueCollection)}
+}
+
+type dbBatch struct {
+ b *batch
+}
+
+// Delete implements [storage.Batch.Delete].
+func (b *dbBatch) Delete(key []byte) {
+ b.b.delete(encodeKey(key))
+}
+
+// DeleteRange implements [storage.Batch.DeleteRange].
+func (b *dbBatch) DeleteRange(start, end []byte) {
+ b.b.deleteRange(encodeKey(start), encodeKey(end))
+}
+
+// Set implements [storage.Batch.Set].
+func (b *dbBatch) Set(key, val []byte) {
+ // TODO(jba): account for size of encoded map.
+ b.b.set(encodeKey(key), encodeValue(slices.Clone(val)), len(val))
+}
+
+// MaybeApply implements [storage.Batch.MaybeApply].
+func (b *dbBatch) MaybeApply() bool {
+ return b.b.maybeApply()
+}
+
+// Apply implements [storage.Batch.Apply].
+func (b *dbBatch) Apply() {
+ b.b.apply()
+}
+
+// encodeKey converts k to a string, preserving ordering.
+func encodeKey(k []byte) string {
+ return hex.EncodeToString(k)
+}
+
+func decodeKey(s string) []byte {
+ b, err := hex.DecodeString(s)
+ if err != nil {
+ // unreachable except for bad DB
+ panic(fmt.Sprintf("decodeKey(%q) failed: %v", s, err))
+ }
+ return b
+}
+
+// encodeValue encodes v in a format acceptable to Firestore.
+// (Firestore values must be maps or structs.)
+func encodeValue(v []byte) any {
+ return map[string][]byte{"v": v}
+}
+
+func decodeValue(m map[string]any) []byte {
+ return m["v"].([]byte)
+}
diff --git a/internal/firestore/db_test.go b/internal/firestore/db_test.go
new file mode 100644
index 0000000..dc11d6e
--- /dev/null
+++ b/internal/firestore/db_test.go
@@ -0,0 +1,156 @@
+// Copyright 2024 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package firestore
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "math/rand/v2"
+ "strings"
+ "testing"
+ "time"
+
+ "golang.org/x/oscar/internal/grpcrr"
+ "golang.org/x/oscar/internal/storage"
+)
+
+var (
+ project = flag.String("project", "", "project ID for testing")
+ database = flag.String("database", "", "Firestore database for testing")
+)
+
+func TestDB(t *testing.T) {
+ ctx := context.Background()
+ rr, err := grpcrr.Open("testdata/db.grpcrr")
+ if err != nil {
+ t.Fatalf("grpcrr.Open: %v", err)
+ }
+ defer func() {
+ if err := rr.Close(); err != nil {
+ t.Fatal(err)
+ }
+ }()
+
+ var fsProject, fsDatabase string
+ if rr.Recording() {
+ if *project == "" {
+ t.Fatal("recording requires -project")
+ }
+ // The -database flag can be omitted. We'll use the default one.
+ rr.SetInitial([]byte(*project + "," + *database))
+ fsProject = *project
+ fsDatabase = *database
+ } else {
+ // Allow -project and -database on replay because other tests might need them.
+ var found bool
+ fsProject, fsDatabase, found = strings.Cut(string(rr.Initial()), ",")
+ if !found {
+ t.Fatal("bad initial state")
+ }
+ }
+
+ db, err := NewDB(ctx, &DBOptions{ProjectID: fsProject, Database: fsDatabase}, rr.ClientOptions()...)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Use a fixed UID for deterministic record/replay.
+ db.uid = 1
+ defer db.Close()
+
+ // Make the timeout short, in case the locks exist from a previous run.
+ defer func(lt time.Duration) {
+ lockTimeout = lt
+ }(lockTimeout)
+ lockTimeout = 2 * time.Second
+
+ storage.TestDB(t, db)
+
+ // Test Batch.DeleteRange with MaybeApply.
+ b := db.Batch()
+ b.DeleteRange([]byte("a"), []byte("b"))
+ applied := b.MaybeApply()
+ if !applied {
+ t.Error("MaybeApply with DeleteRange did not apply")
+ }
+}
+
+func TestLock(t *testing.T) {
+ // The lock tests cannot be run with record/replay because they depend too much on time and state.
+ if *project == "" {
+ t.Skip("missing -project")
+ }
+ ctx := context.Background()
+ db, err := NewDB(ctx, &DBOptions{ProjectID: *project, Database: *database})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db.Close()
+
+ defer func(lt time.Duration) {
+ lockTimeout = lt
+ }(lockTimeout)
+ lockTimeout = 2 * time.Second
+
+ t.Run("basic", func(t *testing.T) {
+ storage.TestDBLock(t, db)
+ })
+
+ t.Run("timeout", func(t *testing.T) {
+ c := make(chan struct{})
+
+ go func() {
+ db.Lock("L")
+ // The second Lock should wait until the first Lock times
+ // out, then it should succeed.
+ db.Lock("L")
+ db.Unlock("L")
+ close(c)
+ }()
+ select {
+ case <-c:
+ // Success.
+ case <-time.After(2 * lockTimeout):
+ t.Fatal("lock timeout didn't happen in time")
+ }
+ })
+ t.Run("owner", func(t *testing.T) {
+ // When run frequently, this test causes contention on the lock name.
+ // So pick a random name.
+ name := fmt.Sprintf("M%d", rand.IntN(10))
+ db.deleteLock(nil, name) // Ensure the lock is not present.
+ db2, err := NewDB(ctx, &DBOptions{ProjectID: *project, Database: *database})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer db2.Close()
+
+ func() {
+ defer func() { recover() }()
+ db.Lock(name)
+ db2.Unlock(name)
+ t.Error("unlock wrong owner did not panic")
+ }()
+ })
+}
+
+func TestErrors(t *testing.T) {
+ if !panics(func() {
+ var b batch
+ b.set("x", nil, 1)
+ }) {
+ t.Error("batch.set does not panic on nil value")
+ }
+}
+
+func panics(f func()) (b bool) {
+ defer func() {
+ if recover() != nil {
+ b = true
+ }
+ }()
+ f()
+ return false
+}
diff --git a/internal/firestore/firestore.go b/internal/firestore/firestore.go
new file mode 100644
index 0000000..25d215b
--- /dev/null
+++ b/internal/firestore/firestore.go
@@ -0,0 +1,312 @@
+// Copyright 2024 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package firestore implements [storage.DB] and [storage.VectorDB]
+// using Google Cloud's [Firestore] service.
+//
+// A brief introduction to Firestore: it is a document DB with hierarchical keys.
+// A key is a string of the form "coll1/id1/coll2/id2/.../collN/idN",
+// where the colls are called "collections". The values are called "documents".
+// Each document is a set of key-value pairs. In Go, a document is represented
+// by a map[string]any. The Go Firestore client provides convenience functions for
+// converting documents to and from structs.
+//
+// [Firestore]: https://cloud.google.com/firestore
+package firestore
+
+import (
+ "context"
+ "iter"
+ "log/slog"
+ "reflect"
+
+ "cloud.google.com/go/firestore"
+ "golang.org/x/oscar/internal/storage"
+ "google.golang.org/api/iterator"
+ "google.golang.org/api/option"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+// DBOptions is the configuration for a [DB] or [VectorDB].
+type DBOptions struct {
+ ProjectID string
+ Database string
+ Logger *slog.Logger
+}
+
+// fstore implements operations common to both [storage.DB] and [storage.VectorDB].
+type fstore struct {
+ client *firestore.Client
+ slog *slog.Logger
+}
+
+func newFstore(ctx context.Context, dbopts *DBOptions, copts []option.ClientOption) (*fstore, error) {
+ if dbopts == nil {
+ dbopts = &DBOptions{}
+ }
+ if dbopts.ProjectID == "" {
+ dbopts.ProjectID = firestore.DetectProjectID
+ }
+ if dbopts.Database == "" {
+ dbopts.Database = firestore.DefaultDatabaseID
+ }
+ if dbopts.Logger == nil {
+ dbopts.Logger = slog.Default()
+ }
+ client, err := firestore.NewClientWithDatabase(ctx, dbopts.ProjectID, dbopts.Database, copts...)
+ if err != nil {
+ return nil, err
+ }
+ return &fstore{client: client, slog: dbopts.Logger}, nil
+}
+
+func (f *fstore) Flush() {
+ // Firestore operations do not require flushing.
+}
+
+func (f *fstore) Close() {
+ if err := f.client.Close(); err != nil {
+ // unreachable except for bad DB
+ f.Panic("firestore close", "err", err)
+ }
+}
+
+func (f *fstore) Panic(msg string, args ...any) {
+ f.slog.Error(msg, args...)
+ storage.Panic(msg, args...)
+}
+
+// get retrieves the document with the given collection and ID.
+// If tx is non-nil, the get happens inside the transaction.
+func (f *fstore) get(tx *firestore.Transaction, coll, id string) *firestore.DocumentSnapshot {
+ dr := f.client.Collection(coll).Doc(id)
+ var ds *firestore.DocumentSnapshot
+ var err error
+ if tx == nil {
+ ds, err = dr.Get(context.TODO())
+ } else {
+ ds, err = tx.Get(dr)
+ }
+ if err != nil {
+ if isNotFound(err) {
+ return nil
+ }
+ // unreachable except for bad DB
+ f.Panic("firestore get", "collection", coll, "key", id, "err", err)
+ }
+ return ds
+}
+
+// set sets the document with the given collection and ID to value.
+// If tx is non-nil, the set happens inside the transaction.
+func (f *fstore) set(tx *firestore.Transaction, coll, key string, value any) {
+ dr := f.client.Collection(coll).Doc(key)
+ var err error
+ if tx == nil {
+ _, err = dr.Set(context.TODO(), value)
+ } else {
+ err = tx.Set(dr, value)
+ }
+ if err != nil {
+ // unreachable except for bad DB
+ f.Panic("firestore set", "collection", coll, "key", key, "err", err)
+ }
+}
+
+// delete deletes the document with the given collection and ID.
+// If tx is non-nil, the delete happens inside the transaction.
+// It is not an error to call delete on a document that doesn't exist.
+func (f *fstore) delete(tx *firestore.Transaction, coll, key string) {
+ dr := f.client.Collection(coll).Doc(key)
+ var err error
+ if tx == nil {
+ _, err = dr.Delete(context.TODO())
+ } else {
+ err = tx.Delete(dr)
+ }
+ if err != nil {
+ // unreachable except for bad DB
+ f.Panic("firestore delete", "collection", coll, "key", key, "err", err)
+ }
+}
+
+// deleteRange deletes all the documents in the collection coll whose IDs are between
+// start and end, inclusive.
+func (f *fstore) deleteRange(coll, start, end string) {
+ bw := f.client.BulkWriter(context.TODO())
+ for ds := range f.scan(nil, coll, start, end) {
+ if _, err := bw.Delete(ds.Ref); err != nil {
+ // unreachable except for bad DB
+ f.Panic("firestore delete range", "collection", coll, "err", err)
+ }
+ }
+ bw.End()
+}
+
+// runTransaction executes fn inside a transaction.
+func (f *fstore) runTransaction(fn func(ctx context.Context, tx *firestore.Transaction)) {
+ err := f.client.RunTransaction(context.TODO(), func(ctx context.Context, tx *firestore.Transaction) error {
+ fn(ctx, tx)
+ return nil
+ })
+ if err != nil {
+ // unreachable except for bad DB
+ f.Panic("firestore transaction", "err", err)
+ }
+}
+
+// scan returns an iterator over the documents in the collection coll whose IDs are
+// between start and end, inclusive.
+func (f *fstore) scan(tx *firestore.Transaction, coll, start, end string) iter.Seq[*firestore.DocumentSnapshot] {
+ query := f.client.Collection(coll).
+ OrderBy(firestore.DocumentID, firestore.Asc).
+ StartAt(start).
+ EndAt(end)
+ var iter *firestore.DocumentIterator
+ if tx == nil {
+ iter = query.Documents(context.TODO())
+ } else {
+ iter = tx.Documents(query)
+ }
+ return func(yield func(*firestore.DocumentSnapshot) bool) {
+ defer iter.Stop()
+ for {
+ ds, err := iter.Next()
+ if err == iterator.Done {
+ return
+ }
+ if err != nil {
+ // unreachable except for bad DB
+ f.Panic("firestore scan", "collection", coll, "err", err)
+ }
+ if !yield(ds) {
+ return
+ }
+ }
+ }
+}
+
+// dataTo converts the data in the given DocumentSnapshot to a value of type T.
+func dataTo[T any](f *fstore, ds *firestore.DocumentSnapshot) T {
+ var data T
+ if err := ds.DataTo(&data); err != nil {
+ // unreachable except for bad DB
+ f.Panic("dataTo", "type", reflect.TypeFor[T](), "err", err)
+ }
+ return data
+}
+
+// batch implements [storage.Batch].
+// All of a batch's operations must occur within the same collection.
+type batch struct {
+ f *fstore
+ coll string
+ ops []*op
+ size int // approximate size of ops
+ hasDeleteRange bool // at least one op is a deleteRange
+}
+
+func (f *fstore) newBatch(coll string) *batch {
+ return &batch{f: f, coll: coll, size: fixedSize}
+}
+
+// An op is a single batch operation.
+type op struct {
+ id string // or start, for deleteRange
+ value any // nil for delete, deleteRange
+ // for deleteRange:
+ end string
+ deleteIDs []string // the list of IDs to delete, determined inside the transaction
+}
+
+// Approximate proto sizes, determined empirically.
+const (
+ fixedSize = 300
+ perWriteSize = 5
+ // Firestore supposedly has a limit of 10 MiB per request
+ // (see https://cloud.google.com/firestore/quotas#writes_and_transactions).
+ // However, I found that exceeding 4 MiB often failed
+ // with a "too big" error.
+ maxSize = 4 << 20
+)
+
+// set adds a set operation to the batch. It is the caller's responsibility to
+// estimate the size of val.
+func (b *batch) set(id string, val any, valSize int) {
+ if val == nil {
+ panic("firestore batch set: nil value")
+ }
+ b.ops = append(b.ops, &op{id: id, value: val})
+ b.size += perWriteSize + len(id) + valSize
+}
+
+// delete adds a delete operation to the batch.
+func (b *batch) delete(id string) {
+ b.ops = append(b.ops, &op{id: id, value: nil})
+ b.size += perWriteSize + len(id)
+}
+
+// delete adds a deleteRange operation to the batch.
+func (b *batch) deleteRange(start, end string) {
+ b.ops = append(b.ops, &op{id: start, end: end})
+ b.size += perWriteSize + len(start) + len(end)
+ b.hasDeleteRange = true
+}
+
+// maybeApply applies the batch if it is big enough.
+func (b *batch) maybeApply() bool {
+ // Apply if the batch is large, or if there is even one deleteRange.
+ // We don't know how many documents are in the range, and each one
+ // is a separate operation in the transaction, so be conservative.
+ if b.size > maxSize || b.hasDeleteRange {
+ b.apply()
+ return true
+ }
+ return false
+}
+
+// apply applies the batch by executing all its operations in a transaction.
+func (b *batch) apply() {
+ b.f.runTransaction(func(ctx context.Context, tx *firestore.Transaction) {
+ // Reads must happen before writes in a transaction, and we can only delete
+ // a range by querying the documents.
+ // So read all the ranges first, and store the IDs in their respective ops
+ // to preserve deletion order.
+ for _, op := range b.ops {
+ if op.end != "" {
+ for ds := range b.f.scan(tx, b.coll, op.id, op.end) {
+ op.deleteIDs = append(op.deleteIDs, ds.Ref.ID)
+ }
+ }
+ }
+
+ // Execute each op inside the transaction.
+ for _, op := range b.ops {
+ switch {
+ case op.value != nil: // set
+ b.f.set(tx, b.coll, op.id, op.value)
+ case op.end == "": // delete
+ b.f.delete(tx, b.coll, op.id)
+ default: // deleteRange
+ for _, id := range op.deleteIDs {
+ b.f.delete(tx, b.coll, id)
+ }
+ }
+ }
+ })
+ b.ops = nil
+ b.size = fixedSize
+}
+
+// isNotFound reports whether an error returned by the Firestore client is a NotFound
+// error.
+func isNotFound(err error) bool {
+ return status.Code(err) == codes.NotFound
+}
+
+// isTimeout reports whether an error returned by the Firestore client indicates a timeout.
+func isTimeout(err error) bool {
+ return status.Code(err) == codes.DeadlineExceeded
+}
diff --git a/internal/firestore/testdata/db.grpcrr b/internal/firestore/testdata/db.grpcrr
new file mode 100644
index 0000000..0750b1a
--- /dev/null
+++ b/internal/firestore/testdata/db.grpcrr
@@ -0,0 +1,1424 @@
+RPCReTxt1
+"go-discovery-exp,"
+443
+kind: REQUEST
+method: "/google.firestore.v1.Firestore/Commit"
+message: {
+ [type.googleapis.com/google.firestore.v1.CommitRequest]: {
+ database: "projects/go-discovery-exp/databases/(default)"
+ writes: {
+ update: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/6b6579"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "value"
+ }
+ }
+ }
+ }
+ }
+}
+290
+kind: RESPONSE
+message: {
+ [type.googleapis.com/google.firestore.v1.CommitResponse]: {
+ write_results: {
+ update_time: {
+ seconds: 1721655326
+ nanos: 448583000
+ }
+ }
+ commit_time: {
+ seconds: 1721655326
+ nanos: 448583000
+ }
+ }
+}
+ref_index: 1
+79
+kind: CREATE_STREAM
+method: "/google.firestore.v1.Firestore/BatchGetDocuments"
+262
+kind: SEND
+message: {
+ [type.googleapis.com/google.firestore.v1.BatchGetDocumentsRequest]: {
+ database: "projects/go-discovery-exp/databases/(default)"
+ documents: "projects/go-discovery-exp/databases/(default)/documents/values/6b6579"
+ }
+}
+ref_index: 3
+552
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.BatchGetDocumentsResponse]: {
+ found: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/6b6579"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "value"
+ }
+ }
+ create_time: {
+ seconds: 1721655326
+ nanos: 448583000
+ }
+ update_time: {
+ seconds: 1721655326
+ nanos: 448583000
+ }
+ }
+ read_time: {
+ seconds: 1721655326
+ nanos: 448583000
+ }
+ }
+}
+ref_index: 3
+39
+kind: RECV
+is_error: true
+ref_index: 3
+79
+kind: CREATE_STREAM
+method: "/google.firestore.v1.Firestore/BatchGetDocuments"
+270
+kind: SEND
+message: {
+ [type.googleapis.com/google.firestore.v1.BatchGetDocumentsRequest]: {
+ database: "projects/go-discovery-exp/databases/(default)"
+ documents: "projects/go-discovery-exp/databases/(default)/documents/values/6d697373696e67"
+ }
+}
+ref_index: 7
+279
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.BatchGetDocumentsResponse]: {
+ missing: "projects/go-discovery-exp/databases/(default)/documents/values/6d697373696e67"
+ read_time: {
+ seconds: 1721655326
+ nanos: 756828000
+ }
+ }
+}
+ref_index: 7
+39
+kind: RECV
+is_error: true
+ref_index: 7
+308
+kind: REQUEST
+method: "/google.firestore.v1.Firestore/Commit"
+message: {
+ [type.googleapis.com/google.firestore.v1.CommitRequest]: {
+ database: "projects/go-discovery-exp/databases/(default)"
+ writes: {
+ delete: "projects/go-discovery-exp/databases/(default)/documents/values/6b6579"
+ }
+ }
+}
+204
+kind: RESPONSE
+message: {
+ [type.googleapis.com/google.firestore.v1.CommitResponse]: {
+ write_results: {}
+ commit_time: {
+ seconds: 1721655326
+ nanos: 859376000
+ }
+ }
+}
+ref_index: 11
+79
+kind: CREATE_STREAM
+method: "/google.firestore.v1.Firestore/BatchGetDocuments"
+263
+kind: SEND
+message: {
+ [type.googleapis.com/google.firestore.v1.BatchGetDocumentsRequest]: {
+ database: "projects/go-discovery-exp/databases/(default)"
+ documents: "projects/go-discovery-exp/databases/(default)/documents/values/6b6579"
+ }
+}
+ref_index: 13
+272
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.BatchGetDocumentsResponse]: {
+ missing: "projects/go-discovery-exp/databases/(default)/documents/values/6b6579"
+ read_time: {
+ seconds: 1721655326
+ nanos: 944912000
+ }
+ }
+}
+ref_index: 13
+40
+kind: RECV
+is_error: true
+ref_index: 13
+222
+kind: REQUEST
+method: "/google.firestore.v1.Firestore/BeginTransaction"
+message: {
+ [type.googleapis.com/google.firestore.v1.BeginTransactionRequest]: {
+ database: "projects/go-discovery-exp/databases/(default)"
+ }
+}
+414
+kind: RESPONSE
+message: {
+ [type.googleapis.com/google.firestore.v1.BeginTransactionResponse]: {
+ transaction: "\x11\xc7b\xd1~RZ5\xd4\"Y\x00\xd4u\xc8\xcdJ\xaf7\xa7\x8b\x0f`\xc7\x12!sG\xae\xacC\xab\x9dh\x92\x94\x87]\xc7\xfe\x15\x07jb\x00l\x8d\x19'2\xfd\x88S\x85\xf9\x13\x84{\x12\xc6\xd5\x18\x80\xc4\"\xa7d\xf8z\xae\xf1\xfe$'V\x15m\xc6ia\x07V\x07 \x8e<\xf3\xba*\xb0*\xeb\x83Yr\x85\xa5Ab\xc9"
+ }
+}
+ref_index: 17
+2848
+kind: REQUEST
+method: "/google.firestore.v1.Firestore/Commit"
+message: {
+ [type.googleapis.com/google.firestore.v1.CommitRequest]: {
+ database: "projects/go-discovery-exp/databases/(default)"
+ writes: {
+ update: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3000"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "0"
+ }
+ }
+ }
+ }
+ writes: {
+ update: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3001"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "1"
+ }
+ }
+ }
+ }
+ writes: {
+ update: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3002"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "2"
+ }
+ }
+ }
+ }
+ writes: {
+ update: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3003"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "3"
+ }
+ }
+ }
+ }
+ writes: {
+ update: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3004"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "4"
+ }
+ }
+ }
+ }
+ writes: {
+ update: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3005"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "5"
+ }
+ }
+ }
+ }
+ writes: {
+ update: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3006"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "6"
+ }
+ }
+ }
+ }
+ writes: {
+ update: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3007"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "7"
+ }
+ }
+ }
+ }
+ writes: {
+ update: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3008"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "8"
+ }
+ }
+ }
+ }
+ writes: {
+ update: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3009"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "9"
+ }
+ }
+ }
+ }
+ transaction: "\x11\xc7b\xd1~RZ5\xd4\"Y\x00\xd4u\xc8\xcdJ\xaf7\xa7\x8b\x0f`\xc7\x12!sG\xae\xacC\xab\x9dh\x92\x94\x87]\xc7\xfe\x15\x07jb\x00l\x8d\x19'2\xfd\x88S\x85\xf9\x13\x84{\x12\xc6\xd5\x18\x80\xc4\"\xa7d\xf8z\xae\xf1\xfe$'V\x15m\xc6ia\x07V\x07 \x8e<\xf3\xba*\xb0*\xeb\x83Yr\x85\xa5Ab\xc9"
+ }
+}
+1271
+kind: RESPONSE
+message: {
+ [type.googleapis.com/google.firestore.v1.CommitResponse]: {
+ write_results: {
+ update_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ }
+ write_results: {
+ update_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ }
+ write_results: {
+ update_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ }
+ write_results: {
+ update_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ }
+ write_results: {
+ update_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ }
+ write_results: {
+ update_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ }
+ write_results: {
+ update_time: {
+ seconds: 1721655264
+ nanos: 64308000
+ }
+ }
+ write_results: {
+ update_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ }
+ write_results: {
+ update_time: {
+ seconds: 1721171630
+ nanos: 112889000
+ }
+ }
+ write_results: {
+ update_time: {
+ seconds: 1721171630
+ nanos: 112889000
+ }
+ }
+ commit_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ }
+}
+ref_index: 19
+70
+kind: CREATE_STREAM
+method: "/google.firestore.v1.Firestore/RunQuery"
+695
+kind: SEND
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryRequest]: {
+ parent: "projects/go-discovery-exp/databases/(default)/documents"
+ structured_query: {
+ from: {
+ collection_id: "values"
+ }
+ order_by: {
+ field: {
+ field_path: "__name__"
+ }
+ direction: ASCENDING
+ }
+ start_at: {
+ values: {
+ reference_value: "projects/go-discovery-exp/databases/(default)/documents/values/3003"
+ }
+ before: true
+ }
+ end_at: {
+ values: {
+ reference_value: "projects/go-discovery-exp/databases/(default)/documents/values/3006"
+ }
+ }
+ }
+ }
+}
+ref_index: 21
+541
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryResponse]: {
+ document: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3003"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "3"
+ }
+ }
+ create_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ update_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ }
+ read_time: {
+ seconds: 1721655327
+ nanos: 322624000
+ }
+ }
+}
+ref_index: 21
+541
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryResponse]: {
+ document: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3004"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "4"
+ }
+ }
+ create_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ update_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ }
+ read_time: {
+ seconds: 1721655327
+ nanos: 322624000
+ }
+ }
+}
+ref_index: 21
+541
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryResponse]: {
+ document: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3005"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "5"
+ }
+ }
+ create_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ update_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ }
+ read_time: {
+ seconds: 1721655327
+ nanos: 322624000
+ }
+ }
+}
+ref_index: 21
+539
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryResponse]: {
+ document: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3006"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "6"
+ }
+ }
+ create_time: {
+ seconds: 1721655264
+ nanos: 64308000
+ }
+ update_time: {
+ seconds: 1721655264
+ nanos: 64308000
+ }
+ }
+ read_time: {
+ seconds: 1721655327
+ nanos: 322624000
+ }
+ }
+}
+ref_index: 21
+40
+kind: RECV
+is_error: true
+ref_index: 21
+70
+kind: CREATE_STREAM
+method: "/google.firestore.v1.Firestore/RunQuery"
+695
+kind: SEND
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryRequest]: {
+ parent: "projects/go-discovery-exp/databases/(default)/documents"
+ structured_query: {
+ from: {
+ collection_id: "values"
+ }
+ order_by: {
+ field: {
+ field_path: "__name__"
+ }
+ direction: ASCENDING
+ }
+ start_at: {
+ values: {
+ reference_value: "projects/go-discovery-exp/databases/(default)/documents/values/3003"
+ }
+ before: true
+ }
+ end_at: {
+ values: {
+ reference_value: "projects/go-discovery-exp/databases/(default)/documents/values/3006"
+ }
+ }
+ }
+ }
+}
+ref_index: 28
+541
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryResponse]: {
+ document: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3003"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "3"
+ }
+ }
+ create_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ update_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ }
+ read_time: {
+ seconds: 1721655327
+ nanos: 565710000
+ }
+ }
+}
+ref_index: 28
+541
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryResponse]: {
+ document: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3004"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "4"
+ }
+ }
+ create_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ update_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ }
+ read_time: {
+ seconds: 1721655327
+ nanos: 565710000
+ }
+ }
+}
+ref_index: 28
+541
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryResponse]: {
+ document: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3005"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "5"
+ }
+ }
+ create_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ update_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ }
+ read_time: {
+ seconds: 1721655327
+ nanos: 565710000
+ }
+ }
+}
+ref_index: 28
+70
+kind: CREATE_STREAM
+method: "/google.firestore.v1.Firestore/RunQuery"
+695
+kind: SEND
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryRequest]: {
+ parent: "projects/go-discovery-exp/databases/(default)/documents"
+ structured_query: {
+ from: {
+ collection_id: "values"
+ }
+ order_by: {
+ field: {
+ field_path: "__name__"
+ }
+ direction: ASCENDING
+ }
+ start_at: {
+ values: {
+ reference_value: "projects/go-discovery-exp/databases/(default)/documents/values/3004"
+ }
+ before: true
+ }
+ end_at: {
+ values: {
+ reference_value: "projects/go-discovery-exp/databases/(default)/documents/values/3007"
+ }
+ }
+ }
+ }
+}
+ref_index: 33
+541
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryResponse]: {
+ document: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3004"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "4"
+ }
+ }
+ create_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ update_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ }
+ read_time: {
+ seconds: 1721655327
+ nanos: 657343000
+ }
+ }
+}
+ref_index: 33
+541
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryResponse]: {
+ document: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3005"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "5"
+ }
+ }
+ create_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ update_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ }
+ read_time: {
+ seconds: 1721655327
+ nanos: 657343000
+ }
+ }
+}
+ref_index: 33
+539
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryResponse]: {
+ document: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3006"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "6"
+ }
+ }
+ create_time: {
+ seconds: 1721655264
+ nanos: 64308000
+ }
+ update_time: {
+ seconds: 1721655264
+ nanos: 64308000
+ }
+ }
+ read_time: {
+ seconds: 1721655327
+ nanos: 657343000
+ }
+ }
+}
+ref_index: 33
+541
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryResponse]: {
+ document: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3007"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "7"
+ }
+ }
+ create_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ update_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ }
+ read_time: {
+ seconds: 1721655327
+ nanos: 657343000
+ }
+ }
+}
+ref_index: 33
+40
+kind: RECV
+is_error: true
+ref_index: 33
+626
+kind: REQUEST
+method: "/google.firestore.v1.Firestore/BatchWrite"
+message: {
+ [type.googleapis.com/google.firestore.v1.BatchWriteRequest]: {
+ database: "projects/go-discovery-exp/databases/(default)"
+ writes: {
+ delete: "projects/go-discovery-exp/databases/(default)/documents/values/3004"
+ }
+ writes: {
+ delete: "projects/go-discovery-exp/databases/(default)/documents/values/3005"
+ }
+ writes: {
+ delete: "projects/go-discovery-exp/databases/(default)/documents/values/3006"
+ }
+ writes: {
+ delete: "projects/go-discovery-exp/databases/(default)/documents/values/3007"
+ }
+ }
+}
+260
+kind: RESPONSE
+message: {
+ [type.googleapis.com/google.firestore.v1.BatchWriteResponse]: {
+ write_results: {}
+ write_results: {}
+ write_results: {}
+ write_results: {}
+ status: {}
+ status: {}
+ status: {}
+ status: {}
+ }
+}
+ref_index: 40
+70
+kind: CREATE_STREAM
+method: "/google.firestore.v1.Firestore/RunQuery"
+695
+kind: SEND
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryRequest]: {
+ parent: "projects/go-discovery-exp/databases/(default)/documents"
+ structured_query: {
+ from: {
+ collection_id: "values"
+ }
+ order_by: {
+ field: {
+ field_path: "__name__"
+ }
+ direction: ASCENDING
+ }
+ start_at: {
+ values: {
+ reference_value: "projects/go-discovery-exp/databases/(default)/documents/values/2fff"
+ }
+ before: true
+ }
+ end_at: {
+ values: {
+ reference_value: "projects/go-discovery-exp/databases/(default)/documents/values/300b"
+ }
+ }
+ }
+ }
+}
+ref_index: 42
+541
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryResponse]: {
+ document: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3000"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "0"
+ }
+ }
+ create_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ update_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ }
+ read_time: {
+ seconds: 1721655327
+ nanos: 903250000
+ }
+ }
+}
+ref_index: 42
+541
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryResponse]: {
+ document: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3001"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "1"
+ }
+ }
+ create_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ update_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ }
+ read_time: {
+ seconds: 1721655327
+ nanos: 903250000
+ }
+ }
+}
+ref_index: 42
+541
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryResponse]: {
+ document: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3002"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "2"
+ }
+ }
+ create_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ update_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ }
+ read_time: {
+ seconds: 1721655327
+ nanos: 903250000
+ }
+ }
+}
+ref_index: 42
+541
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryResponse]: {
+ document: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3003"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "3"
+ }
+ }
+ create_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ update_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ }
+ read_time: {
+ seconds: 1721655327
+ nanos: 903250000
+ }
+ }
+}
+ref_index: 42
+541
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryResponse]: {
+ document: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3008"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "8"
+ }
+ }
+ create_time: {
+ seconds: 1721171630
+ nanos: 112889000
+ }
+ update_time: {
+ seconds: 1721171630
+ nanos: 112889000
+ }
+ }
+ read_time: {
+ seconds: 1721655327
+ nanos: 903250000
+ }
+ }
+}
+ref_index: 42
+541
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryResponse]: {
+ document: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3009"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "9"
+ }
+ }
+ create_time: {
+ seconds: 1721171630
+ nanos: 112889000
+ }
+ update_time: {
+ seconds: 1721171630
+ nanos: 112889000
+ }
+ }
+ read_time: {
+ seconds: 1721655327
+ nanos: 903250000
+ }
+ }
+}
+ref_index: 42
+40
+kind: RECV
+is_error: true
+ref_index: 42
+222
+kind: REQUEST
+method: "/google.firestore.v1.Firestore/BeginTransaction"
+message: {
+ [type.googleapis.com/google.firestore.v1.BeginTransactionRequest]: {
+ database: "projects/go-discovery-exp/databases/(default)"
+ }
+}
+410
+kind: RESPONSE
+message: {
+ [type.googleapis.com/google.firestore.v1.BeginTransactionResponse]: {
+ transaction: "\x11s\xa5\xdd0k\xd2\x04\x0f\"Y\x00\xd4u\xc8\xcd\x1d\xe5\xd31\xeb\xc1i\x01l+~M\xe6\xeb\xc3\xe1\xf22\xa5\xcc\x01G1\xe3i\xc4u\xf0\xa4Lt!\x07\x97\xeb\xaeൃX \xac\x1f\x7f\xf2\x8b\xa9\x7f;\x1e\xd6\xf7\x8f\x11RSi\xfaME\x82\xa1c\x85\xf8^\xf0'\xcfX\xbfN{e\xef\xeb\xa6XRς\x19\x055"
+ }
+}
+ref_index: 51
+70
+kind: CREATE_STREAM
+method: "/google.firestore.v1.Firestore/RunQuery"
+987
+kind: SEND
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryRequest]: {
+ parent: "projects/go-discovery-exp/databases/(default)/documents"
+ structured_query: {
+ from: {
+ collection_id: "values"
+ }
+ order_by: {
+ field: {
+ field_path: "__name__"
+ }
+ direction: ASCENDING
+ }
+ start_at: {
+ values: {
+ reference_value: "projects/go-discovery-exp/databases/(default)/documents/values/3000"
+ }
+ before: true
+ }
+ end_at: {
+ values: {
+ reference_value: "projects/go-discovery-exp/databases/(default)/documents/values/3000"
+ }
+ }
+ }
+ transaction: "\x11s\xa5\xdd0k\xd2\x04\x0f\"Y\x00\xd4u\xc8\xcd\x1d\xe5\xd31\xeb\xc1i\x01l+~M\xe6\xeb\xc3\xe1\xf22\xa5\xcc\x01G1\xe3i\xc4u\xf0\xa4Lt!\x07\x97\xeb\xaeൃX \xac\x1f\x7f\xf2\x8b\xa9\x7f;\x1e\xd6\xf7\x8f\x11RSi\xfaME\x82\xa1c\x85\xf8^\xf0'\xcfX\xbfN{e\xef\xeb\xa6XRς\x19\x055"
+ }
+}
+ref_index: 53
+541
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryResponse]: {
+ document: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3000"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "0"
+ }
+ }
+ create_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ update_time: {
+ seconds: 1721655327
+ nanos: 222942000
+ }
+ }
+ read_time: {
+ seconds: 1721655327
+ nanos: 907076000
+ }
+ }
+}
+ref_index: 53
+40
+kind: RECV
+is_error: true
+ref_index: 53
+2293
+kind: REQUEST
+method: "/google.firestore.v1.Firestore/Commit"
+message: {
+ [type.googleapis.com/google.firestore.v1.CommitRequest]: {
+ database: "projects/go-discovery-exp/databases/(default)"
+ writes: {
+ delete: "projects/go-discovery-exp/databases/(default)/documents/values/3000"
+ }
+ writes: {
+ update: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3000"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "0"
+ }
+ }
+ }
+ }
+ writes: {
+ delete: "projects/go-discovery-exp/databases/(default)/documents/values/3001"
+ }
+ writes: {
+ update: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3002"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "2"
+ }
+ }
+ }
+ }
+ writes: {
+ delete: "projects/go-discovery-exp/databases/(default)/documents/values/3002"
+ }
+ writes: {
+ update: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3004"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "4"
+ }
+ }
+ }
+ }
+ writes: {
+ delete: "projects/go-discovery-exp/databases/(default)/documents/values/3003"
+ }
+ writes: {
+ update: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3006"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "6"
+ }
+ }
+ }
+ }
+ writes: {
+ delete: "projects/go-discovery-exp/databases/(default)/documents/values/3004"
+ }
+ writes: {
+ update: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3008"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "8"
+ }
+ }
+ }
+ }
+ writes: {
+ delete: "projects/go-discovery-exp/databases/(default)/documents/values/3000"
+ }
+ transaction: "\x11s\xa5\xdd0k\xd2\x04\x0f\"Y\x00\xd4u\xc8\xcd\x1d\xe5\xd31\xeb\xc1i\x01l+~M\xe6\xeb\xc3\xe1\xf22\xa5\xcc\x01G1\xe3i\xc4u\xf0\xa4Lt!\x07\x97\xeb\xaeൃX \xac\x1f\x7f\xf2\x8b\xa9\x7f;\x1e\xd6\xf7\x8f\x11RSi\xfaME\x82\xa1c\x85\xf8^\xf0'\xcfX\xbfN{e\xef\xeb\xa6XRς\x19\x055"
+ }
+}
+598
+kind: RESPONSE
+message: {
+ [type.googleapis.com/google.firestore.v1.CommitResponse]: {
+ write_results: {}
+ write_results: {}
+ write_results: {}
+ write_results: {}
+ write_results: {}
+ write_results: {}
+ write_results: {}
+ write_results: {
+ update_time: {
+ seconds: 1721655328
+ nanos: 296191000
+ }
+ }
+ write_results: {}
+ write_results: {
+ update_time: {
+ seconds: 1721171630
+ nanos: 112889000
+ }
+ }
+ write_results: {}
+ commit_time: {
+ seconds: 1721655328
+ nanos: 296191000
+ }
+ }
+}
+ref_index: 57
+70
+kind: CREATE_STREAM
+method: "/google.firestore.v1.Firestore/RunQuery"
+695
+kind: SEND
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryRequest]: {
+ parent: "projects/go-discovery-exp/databases/(default)/documents"
+ structured_query: {
+ from: {
+ collection_id: "values"
+ }
+ order_by: {
+ field: {
+ field_path: "__name__"
+ }
+ direction: ASCENDING
+ }
+ start_at: {
+ values: {
+ reference_value: "projects/go-discovery-exp/databases/(default)/documents/values/2fff"
+ }
+ before: true
+ }
+ end_at: {
+ values: {
+ reference_value: "projects/go-discovery-exp/databases/(default)/documents/values/300b"
+ }
+ }
+ }
+ }
+}
+ref_index: 59
+541
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryResponse]: {
+ document: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3006"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "6"
+ }
+ }
+ create_time: {
+ seconds: 1721655328
+ nanos: 296191000
+ }
+ update_time: {
+ seconds: 1721655328
+ nanos: 296191000
+ }
+ }
+ read_time: {
+ seconds: 1721655328
+ nanos: 389515000
+ }
+ }
+}
+ref_index: 59
+541
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryResponse]: {
+ document: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3008"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "8"
+ }
+ }
+ create_time: {
+ seconds: 1721171630
+ nanos: 112889000
+ }
+ update_time: {
+ seconds: 1721171630
+ nanos: 112889000
+ }
+ }
+ read_time: {
+ seconds: 1721655328
+ nanos: 389515000
+ }
+ }
+}
+ref_index: 59
+541
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryResponse]: {
+ document: {
+ name: "projects/go-discovery-exp/databases/(default)/documents/values/3009"
+ fields: {
+ key: "v"
+ value: {
+ bytes_value: "9"
+ }
+ }
+ create_time: {
+ seconds: 1721171630
+ nanos: 112889000
+ }
+ update_time: {
+ seconds: 1721171630
+ nanos: 112889000
+ }
+ }
+ read_time: {
+ seconds: 1721655328
+ nanos: 389515000
+ }
+ }
+}
+ref_index: 59
+40
+kind: RECV
+is_error: true
+ref_index: 59
+222
+kind: REQUEST
+method: "/google.firestore.v1.Firestore/BeginTransaction"
+message: {
+ [type.googleapis.com/google.firestore.v1.BeginTransactionRequest]: {
+ database: "projects/go-discovery-exp/databases/(default)"
+ }
+}
+408
+kind: RESPONSE
+message: {
+ [type.googleapis.com/google.firestore.v1.BeginTransactionResponse]: {
+ transaction: "\x11\x11 \x8b\x8cX\xe5\x84\xdc\"Y\x00\xd4u\xc8͎\xeb=\xcc\x18ב\x02\x1fu\xed\xaaj\xc0\xf4zZ+\xb8\xb3E\xb9N\xc6\x14\xe2\xbdk\xac\xa8\x1bj\xd7\x0e\x97R<\x7fR\xd3\x0f\x84\xc3\x1d\x14\xb0\xf0\r<G\xf2I\xdf\xee*\xc2x1h.$\xa2X\x12dyY\x10\x95I\xb3w\x99\xdf\xcd\xcefy\xa1V\xe5n4?"
+ }
+}
+ref_index: 65
+70
+kind: CREATE_STREAM
+method: "/google.firestore.v1.Firestore/RunQuery"
+981
+kind: SEND
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryRequest]: {
+ parent: "projects/go-discovery-exp/databases/(default)/documents"
+ structured_query: {
+ from: {
+ collection_id: "values"
+ }
+ order_by: {
+ field: {
+ field_path: "__name__"
+ }
+ direction: ASCENDING
+ }
+ start_at: {
+ values: {
+ reference_value: "projects/go-discovery-exp/databases/(default)/documents/values/61"
+ }
+ before: true
+ }
+ end_at: {
+ values: {
+ reference_value: "projects/go-discovery-exp/databases/(default)/documents/values/62"
+ }
+ }
+ }
+ transaction: "\x11\x11 \x8b\x8cX\xe5\x84\xdc\"Y\x00\xd4u\xc8͎\xeb=\xcc\x18ב\x02\x1fu\xed\xaaj\xc0\xf4zZ+\xb8\xb3E\xb9N\xc6\x14\xe2\xbdk\xac\xa8\x1bj\xd7\x0e\x97R<\x7fR\xd3\x0f\x84\xc3\x1d\x14\xb0\xf0\r<G\xf2I\xdf\xee*\xc2x1h.$\xa2X\x12dyY\x10\x95I\xb3w\x99\xdf\xcd\xcefy\xa1V\xe5n4?"
+ }
+}
+ref_index: 67
+178
+kind: RECV
+message: {
+ [type.googleapis.com/google.firestore.v1.RunQueryResponse]: {
+ read_time: {
+ seconds: 1721655328
+ nanos: 585255000
+ }
+ }
+}
+ref_index: 67
+40
+kind: RECV
+is_error: true
+ref_index: 67
+492
+kind: REQUEST
+method: "/google.firestore.v1.Firestore/Commit"
+message: {
+ [type.googleapis.com/google.firestore.v1.CommitRequest]: {
+ database: "projects/go-discovery-exp/databases/(default)"
+ transaction: "\x11\x11 \x8b\x8cX\xe5\x84\xdc\"Y\x00\xd4u\xc8͎\xeb=\xcc\x18ב\x02\x1fu\xed\xaaj\xc0\xf4zZ+\xb8\xb3E\xb9N\xc6\x14\xe2\xbdk\xac\xa8\x1bj\xd7\x0e\x97R<\x7fR\xd3\x0f\x84\xc3\x1d\x14\xb0\xf0\r<G\xf2I\xdf\xee*\xc2x1h.$\xa2X\x12dyY\x10\x95I\xb3w\x99\xdf\xcd\xcefy\xa1V\xe5n4?"
+ }
+}
+182
+kind: RESPONSE
+message: {
+ [type.googleapis.com/google.firestore.v1.CommitResponse]: {
+ commit_time: {
+ seconds: 1721655328
+ nanos: 585255000
+ }
+ }
+}
+ref_index: 71
diff --git a/internal/grpcrr/rr.go b/internal/grpcrr/rr.go
index c9ac391..7165274 100644
--- a/internal/grpcrr/rr.go
+++ b/internal/grpcrr/rr.go
@@ -76,6 +76,24 @@
return &RecordReplay{replayer: rep}, nil
}
+// SetInitial provides data to be stored at the beginning of the file in record mode.
+// It panics in replay mode.
+func (r *RecordReplay) SetInitial(initial []byte) {
+ if r.recorder == nil {
+ panic("SetInitial called in replay mode")
+ }
+ r.recorder.SetInitial(initial)
+}
+
+// Initial returns the data stored with SetInitial.
+// It panics in record mode.
+func (r *RecordReplay) Initial() []byte {
+ if r.replayer == nil {
+ panic("Initial called in record mode")
+ }
+ return r.replayer.Initial()
+}
+
// ClientOptions returns options to pass to a gRPC client
// that accepts them.
func (r *RecordReplay) ClientOptions() []option.ClientOption {