From 3a77ed1e7ad4683c82f724c3ff3d913437a7a7f9 Mon Sep 17 00:00:00 2001 From: Evgenii Stratonikov Date: Fri, 22 Mar 2024 22:16:50 +0300 Subject: [PATCH] Engine implementation Signed-off-by: Evgenii Stratonikov --- cmd/frostfs-node/tree.go | 1 + go.mod | 2 +- go.sum | 2 + pkg/local_object_storage/engine/tree.go | 30 ++ .../pilorama/new_pilorama.go | 420 ++++++++++++++++++ .../pilorama/new_pilorama_test.go | 151 +++++++ pkg/local_object_storage/pilorama/router.go | 326 ++++++++++++++ .../pilorama/upgrade_test.go | 371 ++++++++++++++++ pkg/local_object_storage/shard/control.go | 9 + pkg/local_object_storage/shard/mode.go | 3 + pkg/local_object_storage/shard/shard.go | 5 +- pkg/local_object_storage/shard/tree.go | 108 +++++ pkg/services/tree/options.go | 9 + pkg/services/tree/service.proto | 140 ++++++ pkg/services/tree/service_frostfs.pb.go | Bin 189487 -> 337973 bytes pkg/services/tree/service_grpc.pb.go | Bin 19139 -> 30864 bytes pkg/services/tree/service_new.go | 229 ++++++++++ pkg/services/tree/types_frostfs.pb.go | Bin 10821 -> 12336 bytes 18 files changed, 1804 insertions(+), 2 deletions(-) create mode 100644 pkg/local_object_storage/pilorama/new_pilorama.go create mode 100644 pkg/local_object_storage/pilorama/new_pilorama_test.go create mode 100644 pkg/local_object_storage/pilorama/router.go create mode 100644 pkg/local_object_storage/pilorama/upgrade_test.go create mode 100644 pkg/services/tree/service_new.go diff --git a/cmd/frostfs-node/tree.go b/cmd/frostfs-node/tree.go index f188e2fbc..320e75c1a 100644 --- a/cmd/frostfs-node/tree.go +++ b/cmd/frostfs-node/tree.go @@ -58,6 +58,7 @@ func initTreeService(c *cfg) { tree.WithPrivateKey(&c.key.PrivateKey), tree.WithLogger(c.log), tree.WithStorage(c.cfgObject.cfgLocalStorage.localStorage), + tree.WithBurnedStorage(c.cfgObject.cfgLocalStorage.localStorage), tree.WithContainerCacheSize(treeConfig.CacheSize()), tree.WithReplicationTimeout(treeConfig.ReplicationTimeout()), tree.WithReplicationChannelCapacity(treeConfig.ReplicationChannelCapacity()), diff --git a/go.mod b/go.mod index aefe2889a..f130e592e 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.22 require ( code.gitea.io/sdk/gitea v0.17.1 - git.frostfs.info/TrueCloudLab/frostfs-api-go/v2 v2.16.1-0.20241007120543-29c522d5d8a3 + git.frostfs.info/TrueCloudLab/frostfs-api-go/v2 v2.16.1-0.20241011114054-f0fc40e116d1 git.frostfs.info/TrueCloudLab/frostfs-contract v0.20.0 git.frostfs.info/TrueCloudLab/frostfs-crypto v0.6.0 git.frostfs.info/TrueCloudLab/frostfs-locode-db v0.4.1-0.20240710074952-65761deb5c0d diff --git a/go.sum b/go.sum index 4d44079d4..ac9211f80 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ code.gitea.io/sdk/gitea v0.17.1 h1:3jCPOG2ojbl8AcfaUCRYLT5MUcBMFwS0OSK2mA5Zok8= code.gitea.io/sdk/gitea v0.17.1/go.mod h1:aCnBqhHpoEWA180gMbaCtdX9Pl6BWBAuuP2miadoTNM= git.frostfs.info/TrueCloudLab/frostfs-api-go/v2 v2.16.1-0.20241007120543-29c522d5d8a3 h1:6QXNnfBgYx81UZsBdpPnQY+ZMSKGFbFc29wV7DJ/UG4= git.frostfs.info/TrueCloudLab/frostfs-api-go/v2 v2.16.1-0.20241007120543-29c522d5d8a3/go.mod h1:F5GS7hRb62PUy5sTYDC4ajVdeffoAfjHSSHTKUJEaYU= +git.frostfs.info/TrueCloudLab/frostfs-api-go/v2 v2.16.1-0.20241011114054-f0fc40e116d1 h1:ivcdxQeQDnx4srF2ezoaeVlF0FAycSAztwfIUJnUI4s= +git.frostfs.info/TrueCloudLab/frostfs-api-go/v2 v2.16.1-0.20241011114054-f0fc40e116d1/go.mod h1:F5GS7hRb62PUy5sTYDC4ajVdeffoAfjHSSHTKUJEaYU= git.frostfs.info/TrueCloudLab/frostfs-contract v0.20.0 h1:8Z5iPhieCrbcdhxBuY/Bajh6V5fki7Whh0b4S2zYJYU= git.frostfs.info/TrueCloudLab/frostfs-contract v0.20.0/go.mod h1:Y2Xorxc8SBO4phoek7n3XxaPZz5rIrFgDsU4TOjmlGA= git.frostfs.info/TrueCloudLab/frostfs-crypto v0.6.0 h1:FxqFDhQYYgpe41qsIHVOcdzSVCB8JNSfPG7Uk4r2oSk= diff --git a/pkg/local_object_storage/engine/tree.go b/pkg/local_object_storage/engine/tree.go index 39122628f..c85d01a79 100644 --- a/pkg/local_object_storage/engine/tree.go +++ b/pkg/local_object_storage/engine/tree.go @@ -9,6 +9,7 @@ import ( "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/local_object_storage/shard" tracingPkg "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/tracing" "git.frostfs.info/TrueCloudLab/frostfs-observability/tracing" + cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" cidSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -440,3 +441,32 @@ func (e *StorageEngine) getTreeShard(ctx context.Context, cid cidSDK.ID, treeID return 0, lst, pilorama.ErrTreeNotFound } + +func (e *StorageEngine) Add(d pilorama.CIDDescriptor, treeID string, key []byte, meta pilorama.Meta) (pilorama.Operation, error) { + lst := e.sortShards(d.CID) + return lst[0].Add(d, treeID, key, meta) +} +func (e *StorageEngine) Remove(d pilorama.CIDDescriptor, treeID string, key []byte) (pilorama.Operation, error) { + lst := e.sortShards(d.CID) + return lst[0].Remove(d, treeID, key) +} +func (e *StorageEngine) Apply(cnr cid.ID, treeID string, op pilorama.Operation) error { + lst := e.sortShards(cnr) + return lst[0].Apply(cnr, treeID, op) +} +func (e *StorageEngine) GetLatestByPrefix(cnr cid.ID, treeID string, key []byte) (pilorama.MetaVersion, error) { + lst := e.sortShards(cnr) + return lst[0].GetLatestByPrefix(cnr, treeID, key) +} +func (e *StorageEngine) GetLatest(cnr cid.ID, treeID string, key []byte) (pilorama.Meta, error) { + lst := e.sortShards(cnr) + return lst[0].GetLatest(cnr, treeID, key) +} +func (e *StorageEngine) ListByPrefix(cnr cid.ID, treeID string, key []byte) ([]pilorama.MetaVersion, error) { + lst := e.sortShards(cnr) + return lst[0].ListByPrefix(cnr, treeID, key) +} +func (e *StorageEngine) ListAll(cnr cid.ID, treeID string, param pilorama.ListParam) ([][]byte, error) { + lst := e.sortShards(cnr) + return lst[0].ListAll(cnr, treeID, param) +} diff --git a/pkg/local_object_storage/pilorama/new_pilorama.go b/pkg/local_object_storage/pilorama/new_pilorama.go new file mode 100644 index 000000000..f5c26db84 --- /dev/null +++ b/pkg/local_object_storage/pilorama/new_pilorama.go @@ -0,0 +1,420 @@ +package pilorama + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/local_object_storage/internal/metaerr" + "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/local_object_storage/shard/mode" + "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/util" + cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" + "github.com/nspcc-dev/neo-go/pkg/io" + "go.etcd.io/bbolt" +) + +type BurnedForest interface { + SetMode(m mode.Mode) error + Open(_ context.Context, mode mode.Mode) error + Init() error + Close() error + BurnedFully +} + +type BurnedFully interface { + Add(d CIDDescriptor, treeID string, key []byte, meta Meta) (Operation, error) + Remove(d CIDDescriptor, treeID string, key []byte) (Operation, error) + Apply(cnr cid.ID, treeID string, op Operation) error + GetLatestByPrefix(cnr cid.ID, treeID string, prefix []byte) (MetaVersion, error) + GetLatest(cnr cid.ID, treeID string, key []byte) (Meta, error) + ListByPrefix(cnr cid.ID, treeID string, key []byte) ([]MetaVersion, error) + ListAll(cnr cid.ID, treeID string, param ListParam) ([][]byte, error) +} + +type newPilorama struct { + db *bbolt.DB + + modeMtx sync.RWMutex + mode mode.Mode + + cfg +} + +var ErrValueNotFound = metaerr.New("value not found") + +type Kind byte + +const ( + OpInsert = iota + OpDelete = iota +) + +type Operation struct { + Kind Kind + Key []byte + Meta Meta +} + +func NewPilorama(opts ...Option) *newPilorama { + p := &newPilorama{ + cfg: cfg{ + perm: os.ModePerm, + maxBatchDelay: bbolt.DefaultMaxBatchDelay, + maxBatchSize: bbolt.DefaultMaxBatchSize, + openFile: os.OpenFile, + metrics: &noopMetrics{}, + }, + } + for i := range opts { + opts[i](&p.cfg) + } + + // p.cfg.path = filepath.Join(p.cfg.path, ".new") + return p +} + +func (p *newPilorama) SetMode(m mode.Mode) error { + p.modeMtx.Lock() + defer p.modeMtx.Unlock() + + if p.mode == m { + return nil + } + + err := p.Close() + if err == nil && !m.NoMetabase() { + if err = p.openBolt(m); err == nil { + err = p.Init() + } + } + if err != nil { + return fmt.Errorf("can't set pilorama mode (old=%s, new=%s): %w", p.mode, m, err) + } + + p.mode = m + p.metrics.SetMode(mode.ConvertToComponentMode(m)) + return nil +} + +func (p *newPilorama) Open(_ context.Context, mode mode.Mode) error { + p.modeMtx.Lock() + defer p.modeMtx.Unlock() + p.mode = mode + if mode.NoMetabase() { + return nil + } + return p.openBolt(mode) +} + +func (p *newPilorama) openBolt(m mode.Mode) error { + readOnly := m.ReadOnly() + err := util.MkdirAllX(filepath.Dir(p.path), p.perm) + if err != nil { + return metaerr.Wrap(fmt.Errorf("can't create dir %s for the pilorama: %w", p.path, err)) + } + + opts := *bbolt.DefaultOptions + opts.ReadOnly = readOnly + opts.NoSync = p.noSync + opts.Timeout = 100 * time.Millisecond + opts.OpenFile = p.openFile + + p.db, err = bbolt.Open(p.path, p.perm, &opts) + if err != nil { + return metaerr.Wrap(fmt.Errorf("can't open the pilorama DB: %w", err)) + } + + p.db.MaxBatchSize = p.maxBatchSize + p.db.MaxBatchDelay = p.maxBatchDelay + p.metrics.SetMode(mode.ConvertToComponentMode(m)) + return nil +} + +func (p *newPilorama) Init() error { + if p.mode.NoMetabase() || p.db.IsReadOnly() { + return nil + } + return nil +} + +func (p *newPilorama) Close() error { + var err error + if p.db != nil { + err = p.db.Close() + } + if err == nil { + p.metrics.Close() + } + return err +} + +var ErrInvalidOperation = errors.New("invalid operation") + +func (op *Operation) Bytes() []byte { + w := io.NewBufBinWriter() + w.WriteB(byte(op.Kind)) + w.WriteVarBytes(op.Key) + op.Meta.EncodeBinary(w.BinWriter) + return w.Bytes() +} + +func (op *Operation) FromBytes(data []byte) error { + r := io.NewBinReaderFromBuf(data) + op.Kind = Kind(r.ReadB()) + op.Key = r.ReadVarBytes() + op.Meta.DecodeBinary(r) + return r.Err +} + +func (p *newPilorama) Add(d CIDDescriptor, treeID string, key []byte, meta Meta) (Operation, error) { + op := Operation{ + Kind: OpInsert, + Key: key, + Meta: meta, + } + + err := p.db.Batch(func(tx *bbolt.Tx) error { + blog, btree, err := p.getTreeBuckets(tx, d.CID, treeID) + if err != nil { + return err + } + + ts := p.getLatestTimestamp(blog, d.Position, d.Size) + op.Meta.Time = ts + + return p.apply(blog, btree, op) + }) + return op, err +} + +func (p *newPilorama) Remove(d CIDDescriptor, treeID string, key []byte) (Operation, error) { + op := Operation{ + Kind: OpDelete, + Key: key, + } + + err := p.db.Batch(func(tx *bbolt.Tx) error { + blog, btree, err := p.getTreeBuckets(tx, d.CID, treeID) + if err != nil { + return err + } + + ts := p.getLatestTimestamp(blog, d.Position, d.Size) + op.Meta.Time = ts + + return p.apply(blog, btree, op) + }) + return op, err +} + +func (p *newPilorama) getLatestTimestamp(b *bbolt.Bucket, pos, size int) uint64 { + var ts uint64 + + c := b.Cursor() + key, _ := c.Last() + if len(key) != 0 { + ts = binary.BigEndian.Uint64(key) + } + return nextTimestamp(ts, uint64(pos), uint64(size)) +} + +func (p *newPilorama) Apply(cnr cid.ID, treeID string, op Operation) error { + return p.db.Batch(func(tx *bbolt.Tx) error { + blog, btree, err := p.getTreeBuckets(tx, cnr, treeID) + if err != nil { + return err + } + return p.apply(blog, btree, op) + }) +} + +func (p *newPilorama) apply(blog, btree *bbolt.Bucket, op Operation) error { + ts := make([]byte, 8) + binary.BigEndian.PutUint64(ts, op.Meta.Time) + if err := blog.Put(ts, op.Bytes()); err != nil { + return err + } + + current := btree.Get(op.Key) + if len(current) == 8 && op.Meta.Time <= binary.BigEndian.Uint64(current) { + return nil + } + return btree.Put(op.Key, ts) +} + +func (p *newPilorama) getTreeBuckets(tx *bbolt.Tx, cnr cid.ID, treeID string) (*bbolt.Bucket, *bbolt.Bucket, error) { + namelog, nametree := burnedNames(cnr, treeID) + + blog := tx.Bucket(namelog) + if blog == nil { + var err error + blog, err = tx.CreateBucketIfNotExists(namelog) + if err != nil { + return nil, nil, err + } + } + + btree := tx.Bucket(nametree) + if btree == nil { + var err error + btree, err = tx.CreateBucketIfNotExists(nametree) + if err != nil { + return nil, nil, err + } + } + return blog, btree, nil +} + +func burnedName(cnr cid.ID, treeID string, typ byte) []byte { + b := make([]byte, 32+len(treeID)+1) + cnr.Encode(b) + copy(b[32:], treeID) + b[len(b)-1] = typ + return b +} + +func burnedNames(cnr cid.ID, treeID string) ([]byte, []byte) { + blog := burnedName(cnr, treeID, 'L') + btree := burnedName(cnr, treeID, 'T') + return blog, btree +} + +func (p *newPilorama) GetLatestByPrefix(cnr cid.ID, treeID string, prefix []byte) (MetaVersion, error) { + // TODO(@fyrchik): can be more optimal + ms, err := p.ListByPrefix(cnr, treeID, prefix) + if err != nil { + return MetaVersion{}, err + } + var maxIndex int + for i := 1; i < len(ms); i++ { + if ms[i].Meta.Time > ms[maxIndex].Meta.Time { + maxIndex = i + } + } + return ms[maxIndex], nil +} + +func (p *newPilorama) GetLatest(cnr cid.ID, treeID string, key []byte) (Meta, error) { + var rawMeta []byte + + nlog, ntree := burnedNames(cnr, treeID) + err := p.db.View(func(tx *bbolt.Tx) error { + blog := tx.Bucket(nlog) + btree := tx.Bucket(ntree) + if blog == nil || btree == nil { + return ErrTreeNotFound + } + + ts := btree.Get(key) + if ts == nil { + return ErrValueNotFound + } + rawMeta = bytes.Clone(blog.Get(ts)) + return nil + }) + if err != nil { + return Meta{}, err + } + + var m Meta + if err := m.FromBytes(rawMeta); err != nil { + return Meta{}, err + } + return m, nil +} + +type MetaVersion struct { + Key []byte + Meta Meta +} + +func (p *newPilorama) ListByPrefix(cnr cid.ID, treeID string, prefix []byte) ([]MetaVersion, error) { + var rawMetas [][]byte + + nlog, ntree := burnedNames(cnr, treeID) + err := p.db.View(func(tx *bbolt.Tx) error { + blog := tx.Bucket(nlog) + btree := tx.Bucket(ntree) + if blog == nil || btree == nil { + return ErrTreeNotFound + } + + c := btree.Cursor() + for k, v := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, v = c.Next() { + rawMetas = append(rawMetas, bytes.Clone(blog.Get(v))) + } + return nil + }) + if err != nil { + return nil, err + } + + res := make([]MetaVersion, 0, len(rawMetas)) + for i := range rawMetas { + var op Operation + if err := op.FromBytes(rawMetas[i]); err != nil { + return nil, err + } + res = append(res, MetaVersion{ + Key: op.Key, + Meta: op.Meta, + }) + } + return res, err +} + +type ListParam struct { + Inclusive bool + Start []byte // Non-inclusive + Count int +} + +func (p *newPilorama) ListAll(cnr cid.ID, treeID string, param ListParam) ([][]byte, error) { + var keys [][]byte + + _, ntree := burnedNames(cnr, treeID) + exact := make([]byte, len(param.Start)) + copy(exact, param.Start) + + err := p.db.View(func(tx *bbolt.Tx) error { + btree := tx.Bucket(ntree) + if btree == nil { + return ErrTreeNotFound + } + + c := btree.Cursor() + k, _ := c.Seek(exact) + if !param.Inclusive && bytes.Equal(k, exact) { + k, _ = c.Next() + } + + for ; k != nil; k, _ = c.Next() { + keys = append(keys, bytes.Clone(k)) + if len(keys) == param.Count { + return nil + } + } + return nil + }) + if err != nil { + return nil, err + } + return keys, nil +} + +func makeFilenameKey(key []byte, version []byte) []byte { + filenameKey := make([]byte, len(key)+len(version)+1) + n := copy(filenameKey, key) + + filenameKey[n] = 0 + n++ + + copy(filenameKey[n:], version) + return filenameKey +} diff --git a/pkg/local_object_storage/pilorama/new_pilorama_test.go b/pkg/local_object_storage/pilorama/new_pilorama_test.go new file mode 100644 index 000000000..ed0f6d8ef --- /dev/null +++ b/pkg/local_object_storage/pilorama/new_pilorama_test.go @@ -0,0 +1,151 @@ +package pilorama + +import ( + "context" + mrand "math/rand" + "path/filepath" + "strconv" + "testing" + "time" + + "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/local_object_storage/shard/mode" + cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" + cidtest "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id/test" + "github.com/stretchr/testify/require" +) + +func newTestNewPilorama(t testing.TB, opts ...Option) BurnedForest { + f := NewPilorama( + append([]Option{ + WithPath(filepath.Join(t.TempDir(), "test.db")), + WithMaxBatchSize(1), + }, opts...)...) + require.NoError(t, f.Open(context.Background(), mode.ReadWrite)) + require.NoError(t, f.Init()) + return f +} + +func BenchmarkBurnedForestApply(b *testing.B) { + for _, bs := range batchSizes { + b.Run("batchsize="+strconv.Itoa(bs), func(b *testing.B) { + r := mrand.New(mrand.NewSource(time.Now().Unix())) + s := newTestNewPilorama(b, WithMaxBatchSize(bs)) + defer func() { require.NoError(b, s.Close()) }() + + benchmarkApplyBurned(b, s, func(opCount int) []Operation { + ops := make([]Operation, opCount) + for i := range ops { + ops[i] = randomOp(b, r) + ops[i].Meta.Time = Timestamp(i) + } + return ops + }) + }) + } +} + +func benchmarkApplyBurned(b *testing.B, s BurnedForest, genFunc func(int) []Operation) { + ops := genFunc(b.N) + cid := cidtest.ID() + treeID := "version" + ch := make(chan int, b.N) + for i := 0; i < b.N; i++ { + ch <- i + } + + b.ResetTimer() + b.ReportAllocs() + b.SetParallelism(10) + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + if err := s.Apply(cid, treeID, ops[<-ch]); err != nil { + b.Fatalf("error in `Apply`: %v", err) + } + } + }) +} + +func TestBurnedForestApplyRandom(t *testing.T) { + r := mrand.New(mrand.NewSource(42)) + + const ( + nodeCount = 5 + opCount = 20 + ) + + ops := prepareRandomBurnedForest(t, nodeCount, opCount) + + cnr := cidtest.ID() + treeID := "version" + + expected := newTestNewPilorama(t, WithNoSync(true)) + defer func() { require.NoError(t, expected.Close()) }() + + for i := range ops { + require.NoError(t, expected.Apply(cnr, treeID, ops[i])) + } + + const iterCount = 200 + for i := 0; i < iterCount; i++ { + // Shuffle random operations, leave initialization in place. + r.Shuffle(len(ops), func(i, j int) { ops[i], ops[j] = ops[j], ops[i] }) + + actual := newTestNewPilorama(t, WithNoSync(true)) + for i := range ops { + require.NoError(t, actual.Apply(cnr, treeID, ops[i])) + } + compareBurnedForests(t, expected, actual, cnr, treeID, nodeCount) + require.NoError(t, actual.Close()) + } +} + +func compareBurnedForests(t testing.TB, expected, actual BurnedForest, cnr cid.ID, treeID string, nodeCount int) { + for i := 0; i < nodeCount; i++ { + expectedMetas, err := expected.ListByPrefix(cnr, treeID, []byte(strconv.Itoa(i))) + require.NoError(t, err) + actualMetas, err := actual.ListByPrefix(cnr, treeID, []byte(strconv.Itoa(i))) + require.NoError(t, err) + + require.Equal(t, expectedMetas, actualMetas) + + r1, err := expected.ListAll(cnr, treeID, ListParam{Start: nil, Count: 100}) + require.NoError(t, err) + r2, err := actual.ListAll(cnr, treeID, ListParam{Start: nil, Count: 100}) + require.NoError(t, err) + require.Equal(t, r1, r2) + } +} + +func prepareRandomBurnedForest(t testing.TB, nodeCount, opCount int) []Operation { + r := mrand.New(mrand.NewSource(42)) + ops := make([]Operation, nodeCount+opCount) + for i := 0; i < nodeCount; i++ { + ops[i] = randomOp(t, r) + ops[i].Key = []byte(strconv.Itoa(i)) + } + + for i := nodeCount; i < len(ops); i++ { + ops[i] = randomOp(t, r) + ops[i].Key = ops[r.Intn(nodeCount)].Key + } + + return ops +} + +func randomOp(t testing.TB, r *mrand.Rand) Operation { + kv := make([][]byte, 5) + for i := range kv { + kv[i] = make([]byte, r.Intn(10)+1) + r.Read(kv[i]) + } + return Operation{ + Key: []byte(strconv.Itoa(r.Int())), + Meta: Meta{ + Time: Timestamp(r.Uint64()), + Items: []KeyValue{ + {Key: string(kv[0]), Value: kv[1]}, + {Key: string(kv[2]), Value: kv[3]}, + }, + }, + } +} diff --git a/pkg/local_object_storage/pilorama/router.go b/pkg/local_object_storage/pilorama/router.go new file mode 100644 index 000000000..2080c164f --- /dev/null +++ b/pkg/local_object_storage/pilorama/router.go @@ -0,0 +1,326 @@ +package pilorama + +import ( + "encoding/binary" + "errors" + "fmt" + + cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" + "go.etcd.io/bbolt" +) + +const ( + systemTreeID = "system" + versionTreeID = "version" + + versionAttribute = "OID" + isUnversionedAttribute = "IsUnversioned" + nullVersionValue = "null" + versionTagAttribute = "IsTag" + versionLockAttribute = "IsLock" + + multipartInfoNumberAttribute = "Number" + multipartInfoUploadIDAttribute = "UploadId" + multipartInfoNewKeySuffix = "Info" + + systemSettingsKey = "bucket-settings" + systemCorsKey = "bucket-cors" + systemTaggingKey = "bucket-tagging" + systemLifecycleKey = "bucket-lifecycle" +) + +type keyValue struct { + timestamp uint64 + key []byte + value []byte +} + +type migrationContext struct { + treeID string + blog *bbolt.Bucket + btree *bbolt.Bucket +} + +func (t *boltForest) migrateVersionAndSystem(tx *bbolt.Tx, cnr cid.ID) error { + if err := t.iterateFull(tx, cnr, versionTreeID); err != nil { + return err + } + if err := t.iterateFull(tx, cnr, systemTreeID); err != nil { + return err + } + return nil +} + +func (t *boltForest) iterateFull(tx *bbolt.Tx, cnr cid.ID, treeID string) error { + switch treeID { + case versionTreeID, systemTreeID: + default: + return fmt.Errorf("unexpected tree ID: '%s'", treeID) + } + _, oldTree, err := t.getTreeBuckets(tx, bucketName(cnr, treeID)) + if err != nil { + return err + } + + logName, treeName := burnedNames(cnr, treeID) + blog, err := tx.CreateBucketIfNotExists(logName) + if err != nil { + return err + } + btree, err := tx.CreateBucketIfNotExists(treeName) + if err != nil { + return err + } + ctx := migrationContext{ + treeID: treeID, + blog: blog, + btree: btree, + } + + var filePath []string + + stack := [][]Node{{RootID}} + for len(stack) != 0 { + if len(stack[len(stack)-1]) == 0 { + stack = stack[:len(stack)-1] + if len(filePath) != 0 { + filePath = filePath[:len(filePath)-1] + } + continue + } + + var next Node + next, stack[len(stack)-1] = stack[len(stack)-1][0], stack[len(stack)-1][1:] + + _, _, rawMeta, _ := t.getState(oldTree, stateKey(make([]byte, 9), next)) + var m Meta + if err := m.FromBytes(rawMeta); err != nil { + return err + } + + fileName := m.GetAttr(AttributeFilename) + filePath = append(filePath, string(fileName)) + descend, err := t.duplicateOnUpdate(ctx, oldTree, next, filePath) + if err != nil { + return err + } + var childIDs []Node + if descend { + key := make([]byte, 9) + key[0] = 'c' + binary.LittleEndian.PutUint64(key[1:], next) + + c := oldTree.Cursor() + for k, _ := c.Seek(key); len(k) == childrenKeySize && binary.LittleEndian.Uint64(k[1:]) == next; k, _ = c.Next() { + childID := binary.LittleEndian.Uint64(k[9:]) + childIDs = append(childIDs, childID) + } + } + + stack = append(stack, childIDs) + } + return nil +} + +func (t *boltForest) duplicateOnUpdate(ctx migrationContext, oldTree *bbolt.Bucket, nodeID Node, filePath []string) (bool, error) { + fmt.Println("duplicateOnUpdate(", filePath, ")") + + const () + + var kvs []keyValue + var err error + switch ctx.treeID { + case systemTreeID: + kvs, err = t.systemRouter(oldTree, nodeID) + case versionTreeID: + kvs, err = t.versionRouter(oldTree, nodeID) + default: + return false, fmt.Errorf("unexpected tree ID: '%s'", ctx.treeID) + } + if err != nil { + return false, err + } + + for _, kv := range kvs { + ts := make([]byte, 8) + binary.BigEndian.PutUint64(ts, kv.timestamp) + if err := ctx.blog.Put(ts, kv.value); err != nil { + return false, err + } + + current := ctx.btree.Get(kv.key) + if len(current) == 8 && kv.timestamp <= binary.BigEndian.Uint64(current) { + continue + } + if err := ctx.btree.Put(kv.key, ts); err != nil { + return false, err + } + } + return len(kvs) == 0, nil +} + +func (t *boltForest) buildPathInreverse(bTree *bbolt.Bucket, nodeID Node) (bool, Timestamp, Meta, []byte, error) { + // 1. Get filepath to the current node. + var components [][]byte + var size int + + parent, ts, rawMeta, _ := t.getState(bTree, stateKey(make([]byte, 9), nodeID)) + var meta Meta + if err := meta.FromBytes(rawMeta); err != nil { + return false, 0, Meta{}, nil, err + } + if len(meta.Items) <= 1 { + return false, 0, Meta{}, nil, nil + } + + for { + var m Meta + if err := m.FromBytes(rawMeta); err != nil { + return false, 0, Meta{}, nil, err + } + + fname := m.GetAttr(AttributeFilename) + if fname == nil { + return false, 0, Meta{}, nil, fmt.Errorf("empty '%s' was found in ancestors", AttributeFilename) + } + + size += len(fname) + components = append(components, fname) + + if parent == RootID { + break + } + nodeID = parent + parent, _, rawMeta, _ = t.getState(bTree, stateKey(make([]byte, 9), nodeID)) + } + + var prefix []byte + for i := len(components) - 1; i >= 0; i-- { + prefix = append(prefix, components[i]...) + if i > 0 { + prefix = append(prefix, '/') + } + } + return true, ts, meta, prefix, nil +} + +func (t *boltForest) systemRouter(bTree *bbolt.Bucket, nodeID Node) ([]keyValue, error) { + // 1. Get filepath to the current node. + isExternalNode, ts, systemMeta, prefix, err := t.buildPathInreverse(bTree, nodeID) + if err != nil || !isExternalNode { + return nil, err + } + + uploadID := systemMeta.GetAttr(multipartInfoUploadIDAttribute) + if uploadID == nil { + attr := systemMeta.GetAttr(AttributeFilename) + switch fname := string(systemMeta.GetAttr(AttributeFilename)); fname { + case systemSettingsKey, systemCorsKey, systemTaggingKey, systemLifecycleKey: + return []keyValue{{ + timestamp: ts, + key: attr, + value: systemMeta.Bytes(), + }}, nil + default: + return nil, fmt.Errorf("neither %s nor %s ('%s') are found in system tree", multipartInfoUploadIDAttribute, AttributeFilename, fname) + } + } + + // The key is '\u0000\u0000Info'. + prefix = append(prefix, 0) + prefix = append(prefix, uploadID...) + prefix = append(prefix, 0) + prefix = prefix[:len(prefix):len(prefix)] + + kvs := []keyValue{{ + timestamp: ts, + key: append(prefix, multipartInfoNewKeySuffix...), + value: systemMeta.Bytes(), + }} + + t.forEachChild(bTree, nodeID, func(info childInfo) error { + number := info.meta.GetAttr(multipartInfoNumberAttribute) + if len(number) == 0 { + return errors.New("empty 'Number' attribute in multipart info") + } + + // The key is '\u0000\u0000', where `` is the `Number` attribute. + newKey := make([]byte, len(prefix)+len(number)) + copy(newKey, prefix) + copy(newKey[len(prefix):], number) + + kvs = append(kvs, keyValue{ + timestamp: info.time, + key: newKey, + value: info.rawMeta, + }) + return nil + }) + + return kvs, nil +} + +func (t *boltForest) versionRouter(bTree *bbolt.Bucket, nodeID Node) ([]keyValue, error) { + // 1. Get filepath to the current node. + isExternalNode, ts, versionMeta, prefix, err := t.buildPathInreverse(bTree, nodeID) + if err != nil || !isExternalNode { + return nil, err + } + + version := versionMeta.GetAttr(versionAttribute) + if version == nil { + if len(versionMeta.GetAttr(isUnversionedAttribute)) == 0 { + return nil, fmt.Errorf("both '%s' and '%s' attributes are empty", versionAttribute, isUnversionedAttribute) + } + version = []byte(nullVersionValue) + } + + t.forEachChild(bTree, nodeID, func(info childInfo) error { + if info.meta.GetAttr(versionTagAttribute) != nil || info.meta.GetAttr(versionLockAttribute) != nil { + versionMeta.Items = append(versionMeta.Items, info.meta.Items...) + } + return nil + }) + + // The key is '\u0000'. + prefix = append(prefix, 0) + prefix = append(prefix, version...) + prefix = prefix[:len(prefix):len(prefix)] + return []keyValue{{ + timestamp: ts, + key: prefix, + value: versionMeta.Bytes(), + }}, nil +} + +type childInfo struct { + time Timestamp + meta Meta + rawMeta []byte +} + +func (t *boltForest) forEachChild(bTree *bbolt.Bucket, nodeID Node, f func(childInfo) error) error { + key := make([]byte, 9) + key[0] = 'c' + binary.LittleEndian.PutUint64(key[1:], nodeID) + + c := bTree.Cursor() + + for k, _ := c.Seek(key); len(k) == childrenKeySize && binary.LittleEndian.Uint64(k[1:]) == nodeID; k, _ = c.Next() { + childID := binary.LittleEndian.Uint64(k[9:]) + + _, ts, rawMeta, inTree := t.getState(bTree, stateKey(make([]byte, 9), childID)) + if !inTree { + continue + } + var m Meta + if err := m.FromBytes(rawMeta); err != nil { + return err + } + + if err := f(childInfo{time: ts, rawMeta: rawMeta, meta: m}); err != nil { + return err + } + } + return nil +} diff --git a/pkg/local_object_storage/pilorama/upgrade_test.go b/pkg/local_object_storage/pilorama/upgrade_test.go new file mode 100644 index 000000000..5651be77c --- /dev/null +++ b/pkg/local_object_storage/pilorama/upgrade_test.go @@ -0,0 +1,371 @@ +package pilorama + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + randv2 "math/rand/v2" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/local_object_storage/shard/mode" + cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" + cidtest "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id/test" + oidtest "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id/test" + usertest "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/user/test" + "github.com/davecgh/go-spew/spew" + "github.com/stretchr/testify/require" + "go.etcd.io/bbolt" +) + +/* +Ниже представлены текущие данные в деревяшке и их новые аналоги. +system tree - настройки бакета, информация о мультипартах + + settings (при миграции атрибуты не меняются, ключ в новом дереве bucket-settings): + FileName: bucket-settings + Versioning + LockConfiguration + cannedACL + ownerKey + cors (при миграции атрибуты не меняются, ключ в новом дереве bucket-cors): + FileName: bucket-cors + OID + bucket-tagging (при миграции атрибуты не меняются, ключ в новом дереве bucket-tagging): + FileName: bucket-tagging + User-Tag- + multiparts info (при миграции атрибуты не меняются, ключ в новом дереве \u0000\u0000Info): + FileName + UploadId + Owner + Created + Finished + multiparts parts (были дочерними для multipart info; при миграции атрибуты не меняются, ключ в новом дереве \u0000\u0000) + Number + OID + Size + Created + ETag + MD5 + +version tree - хранит информацию о конкретных версиях объетов и их дополнительной мета информации (в том числе теги/локи) + + version + old attrs: + OID + FileName + Owner + Created + Size + ETag + MD5 + IdDeleteMarker + IsCombined + IsUnversioned + new attrs: + OID + FilePath + Key + Owner + Created + Size + ETag + MD5 + IdDeleteMarker + IsCombined + IsUnversioned + User-Tag- + LegalHoldOID + RetentionOID + UntilDate + IsCompliance + Ключ по которому сохраняется информация о версии \u0000 (где version-id это OID либо null если установлен флаг IsUnversioned) + Часть новых атрибутов в мете версии взята из тегов и локов, которые были раньше отдельными дочерними узлами версии. + теги: + IsTag + User-Tag- + локи: + IsLock + LegalHoldOID + RetentionOID + UntilDate + IsCompliance +*/ + +func toKeyValue(m map[string]string) []KeyValue { + kvs := make([]KeyValue, 0, len(m)) + for k, v := range m { + kvs = append(kvs, KeyValue{Key: k, Value: []byte(v)}) + } + return kvs +} + +func randBytes(n int) []byte { + a := make([]byte, n) + rand.Read(a) + return a +} + +func newMultipartInfo(filename string) (string, []KeyValue) { + uploadID := base64.StdEncoding.EncodeToString(randBytes(10)) + return uploadID, toKeyValue(map[string]string{ + AttributeFilename: filename, + multipartInfoUploadIDAttribute: uploadID, + "Owner": usertest.ID().EncodeToString(), + "Created": time.Now().String(), + "Finished": time.Now().Add(time.Hour).String(), + }) +} + +func newVersionKV(filename string) (string, []KeyValue) { + objID := oidtest.ID().EncodeToString() + return objID, toKeyValue(map[string]string{ + versionAttribute: objID, + AttributeFilename: filename, + "Owner": usertest.ID().EncodeToString(), + "Created": time.Now().String(), + "Size": strconv.Itoa(randv2.Int()), + "ETag": hex.EncodeToString(randBytes(32)), + "MD5": hex.EncodeToString(randBytes(16)), + "IdDeleteMarker": "false", + "IsCombined": "false", + "IsUnversioned": "false", + }) +} + +func TestUpgradeReal(t *testing.T) { + ctx := context.Background() + filename := filepath.Join("./testpilorama.db") + f := NewBoltForest(WithPath(filename)) + require.NoError(t, f.Open(ctx, mode.ReadWrite)) + require.NoError(t, f.Init()) + + seen := make(map[cid.ID]struct{}) + err := f.(*boltForest).db.Update(func(tx *bbolt.Tx) error { + c := tx.Cursor() + for key, _ := c.First(); key != nil; key, _ = c.Next() { + if len(key) < 32 { + spew.Dump(key) + continue + } + var cnr cid.ID + if err := cnr.Decode(key[:32]); err != nil { + return fmt.Errorf("(%T).Decode: %w", cnr, err) + } + fmt.Println(cnr.String(), string(key[32:])) + if _, ok := seen[cnr]; ok { + continue + } + seen[cnr] = struct{}{} + if err := f.(*boltForest).migrateVersionAndSystem(tx, cnr); err != nil { + return err + } + } + return nil + }) + require.NoError(t, err) + require.NoError(t, f.Close()) + + nf := NewPilorama(WithPath(filename)) + require.NoError(t, nf.Open(ctx, mode.ReadOnly)) + require.NoError(t, nf.Init()) + + defer nf.Close() +} + +func TestUpgrade(t *testing.T) { + ctx := context.Background() + filename := filepath.Join(t.TempDir(), "pilorama") + f := NewBoltForest(WithPath(filename)) + require.NoError(t, f.Open(ctx, mode.ReadWrite)) + require.NoError(t, f.Init()) + + cnr := cidtest.ID() + d := CIDDescriptor{CID: cnr, Position: 1, Size: 4} + + addByPath := func(treeID string, filePath []string, meta []KeyValue) []Move { + res, err := f.TreeAddByPath(ctx, d, treeID, AttributeFilename, filePath, meta) + require.NoError(t, err) + return res + } + + move := func(treeID string, parent Node, meta []KeyValue) { + _, err := f.TreeMove(ctx, d, treeID, &Move{ + Parent: parent, + Meta: Meta{Items: meta}, + }) + require.NoError(t, err) + } + + type addOp struct { + filepath string + version string + meta []KeyValue + unversionedMeta []KeyValue + } + ops := []addOp{ + {filepath: "my/dir/object1"}, + {filepath: "my/dir/object2"}, + {filepath: "my/dir1/object1"}, + {filepath: "toplevel"}, + } + + for i := range ops { + fp := strings.Split(ops[i].filepath, "/") + version, m := newVersionKV(fp[len(fp)-1]) + res := addByPath(versionTreeID, fp[:len(fp)-1], m) + + switch i { + case 0: + // Attach lock info to the first object. + aux := toKeyValue(map[string]string{ + versionLockAttribute: "true", + "LegalHoldOID": "garbage1", + "RetentionOID": "garbage2", + "UntilDate": time.Now().String(), + "IsCompliance": "false", + }) + move(versionTreeID, res[len(res)-1].Child, aux) + m = append(m, aux...) + case 1: + // Attach user tag info to the second object. + aux := toKeyValue(map[string]string{ + versionTagAttribute: "true", + "User-Tag-kek": "my-user-tag", + }) + move(versionTreeID, res[len(res)-1].Child, aux) + m = append(m, aux...) + case 2: + // Add multiple unversioned objects. The last one should remain. + aux := toKeyValue(map[string]string{ + isUnversionedAttribute: "true", + AttributeFilename: fp[len(fp)-1], + "attempt": "1", + }) + addByPath(versionTreeID, fp[:len(fp)-1], aux) + + aux = toKeyValue(map[string]string{ + isUnversionedAttribute: "true", + AttributeFilename: fp[len(fp)-1], + "attempt": "2", + }) + addByPath(versionTreeID, fp[:len(fp)-1], aux) + + ops[i].unversionedMeta = aux + } + + ops[i].version = version + ops[i].meta = m + } + + // System tree. + type multipart struct { + filepath string + uploadID string + meta []KeyValue + parts []int + partsMeta [][]KeyValue + } + + multipartOps := []multipart{ + {filepath: "path1/object1"}, + {filepath: "path1/object2"}, + {filepath: "path2"}, + {filepath: systemSettingsKey}, // Dirty test for naming conflict. + } + + settingsMeta := toKeyValue(map[string]string{ + AttributeFilename: systemSettingsKey, + "Versioning": "enabled", + "LockConfiguration": "something", + }) + addByPath(systemTreeID, nil, settingsMeta) + + lifecycleMeta := toKeyValue(map[string]string{ + AttributeFilename: systemLifecycleKey, + "LockConfiguration": "something", + }) + addByPath(systemTreeID, nil, lifecycleMeta) + + corsMeta := toKeyValue(map[string]string{ + AttributeFilename: systemCorsKey, + "OID": oidtest.ID().String(), + }) + addByPath(systemTreeID, nil, corsMeta) + + taggingMeta := toKeyValue(map[string]string{ + AttributeFilename: systemTaggingKey, + "User-Tag-mytag": "abc", + }) + addByPath(systemTreeID, nil, taggingMeta) + + for i := range multipartOps { + fp := strings.Split(multipartOps[i].filepath, "/") + uploadID, m := newMultipartInfo(fp[len(fp)-1]) + res := addByPath(systemTreeID, fp[:len(fp)-1], m) + for j := 0; j < 4; j++ { + if randv2.Int()%2 == 0 { + aux := toKeyValue(map[string]string{ + multipartInfoNumberAttribute: strconv.Itoa(j), + "OID": oidtest.ID().EncodeToString(), + "Size": strconv.Itoa(randv2.Int()), + "Created": time.Now().String(), + "ETag": hex.EncodeToString(randBytes(10)), + "MD5": hex.EncodeToString(randBytes(16)), + }) + move(systemTreeID, res[len(res)-1].Child, aux) + + multipartOps[i].parts = append(multipartOps[i].parts, j) + multipartOps[i].partsMeta = append(multipartOps[i].partsMeta, aux) + } + } + + multipartOps[i].uploadID = uploadID + multipartOps[i].meta = m + } + + err := f.(*boltForest).db.Update(func(tx *bbolt.Tx) error { + return f.(*boltForest).migrateVersionAndSystem(tx, cnr) + }) + require.NoError(t, err) + require.NoError(t, f.Close()) + + nf := NewPilorama(WithPath(filename)) + require.NoError(t, nf.Open(ctx, mode.ReadOnly)) + require.NoError(t, nf.Init()) + + checkMeta := func(treeID string, key []byte, expected []KeyValue) { + meta, err := nf.GetLatest(cnr, treeID, key) + require.NoError(t, err) + require.Equal(t, expected, meta.Items) + } + for i := range ops { + key := []byte(ops[i].filepath + "\x00" + ops[i].version) + checkMeta(versionTreeID, key, ops[i].meta) + + if ops[i].unversionedMeta != nil { + key := []byte(ops[i].filepath + "\x00" + nullVersionValue) + checkMeta(versionTreeID, key, ops[i].unversionedMeta) + } + } + + checkMeta(systemTreeID, []byte(systemSettingsKey), settingsMeta) + checkMeta(systemTreeID, []byte(systemCorsKey), corsMeta) + checkMeta(systemTreeID, []byte(systemLifecycleKey), lifecycleMeta) + checkMeta(systemTreeID, []byte(systemTaggingKey), taggingMeta) + + for i := range multipartOps { + key := []byte(multipartOps[i].filepath + "\x00" + multipartOps[i].uploadID + "\x00" + "Info") + checkMeta(systemTreeID, key, multipartOps[i].meta) + + for j, number := range multipartOps[i].parts { + key := []byte(multipartOps[i].filepath + "\x00" + multipartOps[i].uploadID + "\x00" + strconv.Itoa(number)) + checkMeta(systemTreeID, key, multipartOps[i].partsMeta[j]) + } + } +} diff --git a/pkg/local_object_storage/shard/control.go b/pkg/local_object_storage/shard/control.go index 62800dbd0..2585efa4c 100644 --- a/pkg/local_object_storage/shard/control.go +++ b/pkg/local_object_storage/shard/control.go @@ -63,6 +63,9 @@ func (s *Shard) Open(ctx context.Context) error { if s.pilorama != nil { components = append(components, s.pilorama) } + if s.burned != nil { + components = append(components, s.burned) + } for i, component := range components { if err := component.Open(ctx, m); err != nil { @@ -168,6 +171,9 @@ func (s *Shard) initializeComponents(m mode.Mode) error { if s.pilorama != nil { components = append(components, s.pilorama) } + if s.burned != nil { + components = append(components, s.burned) + } for _, component := range components { if err := component.Init(); err != nil { @@ -373,6 +379,9 @@ func (s *Shard) Close() error { if s.pilorama != nil { components = append(components, s.pilorama) } + if s.burned != nil { + components = append(components, s.burned) + } if s.hasWriteCache() { prev := s.writecacheSealCancel.Swap(notInitializedCancel) diff --git a/pkg/local_object_storage/shard/mode.go b/pkg/local_object_storage/shard/mode.go index d90a5f4b6..d9c573f73 100644 --- a/pkg/local_object_storage/shard/mode.go +++ b/pkg/local_object_storage/shard/mode.go @@ -41,6 +41,9 @@ func (s *Shard) setMode(m mode.Mode) error { if s.pilorama != nil { components = append(components, s.pilorama) } + if s.burned != nil { + components = append(components, s.burned) + } // The usual flow of the requests (pilorama is independent): // writecache -> blobstor -> metabase diff --git a/pkg/local_object_storage/shard/shard.go b/pkg/local_object_storage/shard/shard.go index 413bfd2f7..b40dcc694 100644 --- a/pkg/local_object_storage/shard/shard.go +++ b/pkg/local_object_storage/shard/shard.go @@ -32,6 +32,8 @@ type Shard struct { pilorama pilorama.ForestStorage + burned pilorama.BurnedForest + metaBase *meta.DB tsSource TombstoneSource @@ -146,7 +148,8 @@ func New(opts ...Option) *Shard { } if s.piloramaOpts != nil { - s.pilorama = pilorama.NewBoltForest(c.piloramaOpts...) + //s.pilorama = pilorama.NewBoltForest(c.piloramaOpts...) + s.burned = pilorama.NewPilorama(c.piloramaOpts...) } s.fillInfo() diff --git a/pkg/local_object_storage/shard/tree.go b/pkg/local_object_storage/shard/tree.go index 26dc8ec1e..d89c52fa8 100644 --- a/pkg/local_object_storage/shard/tree.go +++ b/pkg/local_object_storage/shard/tree.go @@ -7,6 +7,7 @@ import ( "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/local_object_storage/pilorama" "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/local_object_storage/util/logicerr" "git.frostfs.info/TrueCloudLab/frostfs-observability/tracing" + cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" cidSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -427,3 +428,110 @@ func (s *Shard) TreeApplyStream(ctx context.Context, cnr cidSDK.ID, treeID strin } return s.pilorama.TreeApplyStream(ctx, cnr, treeID, source) } + +func (s *Shard) Add(d pilorama.CIDDescriptor, treeID string, key []byte, meta pilorama.Meta) (pilorama.Operation, error) { + if s.burned == nil { + return pilorama.Operation{}, ErrPiloramaDisabled + } + + s.m.RLock() + defer s.m.RUnlock() + + if s.info.Mode.ReadOnly() { + return pilorama.Operation{}, ErrReadOnlyMode + } + if s.info.Mode.NoMetabase() { + return pilorama.Operation{}, ErrDegradedMode + } + return s.burned.Add(d, treeID, key, meta) +} + +func (s *Shard) Remove(d pilorama.CIDDescriptor, treeID string, key []byte) (pilorama.Operation, error) { + if s.burned == nil { + return pilorama.Operation{}, ErrPiloramaDisabled + } + + s.m.RLock() + defer s.m.RUnlock() + + if s.info.Mode.ReadOnly() { + return pilorama.Operation{}, ErrReadOnlyMode + } + if s.info.Mode.NoMetabase() { + return pilorama.Operation{}, ErrDegradedMode + } + return s.burned.Remove(d, treeID, key) +} + +func (s *Shard) Apply(cnr cid.ID, treeID string, op pilorama.Operation) error { + if s.burned == nil { + return ErrPiloramaDisabled + } + + s.m.RLock() + defer s.m.RUnlock() + + if s.info.Mode.ReadOnly() { + return ErrReadOnlyMode + } + if s.info.Mode.NoMetabase() { + return ErrDegradedMode + } + return s.burned.Apply(cnr, treeID, op) +} + +func (s *Shard) GetLatest(cnr cid.ID, treeID string, key []byte) (pilorama.Meta, error) { + if s.burned == nil { + return pilorama.Meta{}, ErrPiloramaDisabled + } + + s.m.RLock() + defer s.m.RUnlock() + + if s.info.Mode.NoMetabase() { + return pilorama.Meta{}, ErrDegradedMode + } + return s.burned.GetLatest(cnr, treeID, key) +} + +func (s *Shard) GetLatestByPrefix(cnr cid.ID, treeID string, prefix []byte) (pilorama.MetaVersion, error) { + if s.burned == nil { + return pilorama.MetaVersion{}, ErrPiloramaDisabled + } + + s.m.RLock() + defer s.m.RUnlock() + + if s.info.Mode.NoMetabase() { + return pilorama.MetaVersion{}, ErrDegradedMode + } + return s.burned.GetLatestByPrefix(cnr, treeID, prefix) +} + +func (s *Shard) ListByPrefix(cnr cid.ID, treeID string, key []byte) ([]pilorama.MetaVersion, error) { + if s.burned == nil { + return nil, ErrPiloramaDisabled + } + + s.m.RLock() + defer s.m.RUnlock() + + if s.info.Mode.NoMetabase() { + return nil, ErrDegradedMode + } + return s.burned.ListByPrefix(cnr, treeID, key) +} + +func (s *Shard) ListAll(cnr cid.ID, treeID string, param pilorama.ListParam) ([][]byte, error) { + if s.burned == nil { + return nil, ErrPiloramaDisabled + } + + s.m.RLock() + defer s.m.RUnlock() + + if s.info.Mode.NoMetabase() { + return nil, ErrDegradedMode + } + return s.burned.ListAll(cnr, treeID, param) +} diff --git a/pkg/services/tree/options.go b/pkg/services/tree/options.go index 1633ae557..e5950fc46 100644 --- a/pkg/services/tree/options.go +++ b/pkg/services/tree/options.go @@ -35,6 +35,8 @@ type cfg struct { cnrSource ContainerSource frostfsidSubjectProvider frostfsidcore.SubjectProvider forest pilorama.Forest + burned pilorama.BurnedFully + // replication-related parameters replicatorChannelCapacity int replicatorWorkerCount int @@ -97,6 +99,13 @@ func WithStorage(s pilorama.Forest) Option { } } +// WithBurnedStorage sets tree storage for a service. +func WithBurnedStorage(s pilorama.BurnedFully) Option { + return func(c *cfg) { + c.burned = s + } +} + func WithReplicationChannelCapacity(n int) Option { return func(c *cfg) { if n > 0 { diff --git a/pkg/services/tree/service.proto b/pkg/services/tree/service.proto index 88bf0bca4..5284b79ff 100644 --- a/pkg/services/tree/service.proto +++ b/pkg/services/tree/service.proto @@ -51,6 +51,146 @@ service TreeService { rpc GetOpLog(GetOpLogRequest) returns (stream GetOpLogResponse); // Healthcheck is a dummy rpc to check service availability rpc Healthcheck(HealthcheckRequest) returns (HealthcheckResponse); + + rpc BurnedAdd(BurnedAddRequest) returns (BurnedAddResponse); + rpc BurnedRemove(BurnedRemoveRequest) returns (BurnedRemoveResponse); + rpc BurnedApply(BurnedApplyRequest) returns (BurnedApplyResponse); + rpc BurnedGet(BurnedGetRequest) returns (BurnedGetResponse); + rpc BurnedGetLatestByPrefix(BurnedGetLatestByPrefixRequest) + returns (BurnedGetLatestByPrefixResponse); + rpc BurnedListByPrefix(BurnedListByPrefixRequest) + returns (BurnedListByPrefixResponse); + rpc BurnedList(BurnedListRequest) returns (stream BurnedListResponse); +} + +message BurnedAddRequest { + message Body { + bytes container_id = 1; + string tree_id = 2; + string key = 3; + repeated KeyValue meta = 4; + } + Body body = 1; + Signature signature = 2; +} +message BurnedAddResponse { + message Body {} + Body body = 1; + Signature signature = 2; +} + +message BurnedRemoveRequest { + message Body { + bytes container_id = 1; + string tree_id = 2; + string key = 3; + } + Body body = 1; + Signature signature = 2; +} +message BurnedRemoveResponse { + message Body {} + Body body = 1; + Signature signature = 2; +} + +message BurnedApplyRequest { + message Body { + bytes container_id = 1; + string tree_id = 2; + bytes op = 3; + } + Body body = 1; + Signature signature = 2; +} +message BurnedApplyResponse { + message Body {} + Body body = 1; + Signature signature = 2; +} + +message BurnedGetRequest { + message Body { + bytes container_id = 1; + string tree_id = 2; + string key = 3; + bytes bearer_token = 5; + } + Body body = 1; + Signature signature = 2; +} + +message BurnedGetResponse { + message Body { + uint64 timestamp = 1; + repeated KeyValue meta = 2; + } + Body body = 1; + Signature signature = 2; +} + +message BurnedGetLatestByPrefixRequest { + message Body { + bytes container_id = 1; + string tree_id = 2; + string prefix = 3; + bytes bearer_token = 5; + } + Body body = 1; + Signature signature = 2; +} + +message BurnedGetLatestByPrefixResponse { + message Body { + string key = 1; + uint64 timestamp = 2; + repeated KeyValue meta = 3; + } + Body body = 1; + Signature signature = 2; +} + +message BurnedListByPrefixRequest { + message Body { + bytes container_id = 1; + string tree_id = 2; + string key = 3; + } + Body body = 1; + Signature signature = 2; +} + +message BurnedListByPrefixResponse { + message Body { + message Info { + string key = 1; + uint64 timestamp = 2; + repeated KeyValue meta = 3; + } + repeated Info list = 1; + } + Body body = 1; + Signature signature = 2; +} + +message BurnedListRequest { + message Body { + bytes container_id = 1; + string tree_id = 2; + string start = 3; + } + Body body = 1; + Signature signature = 2; +} + +message BurnedListResponse { + message Body { + string key = 1; + uint64 timestamp = 2; + repeated KeyValue meta = 3; + } + Body body = 1; + Signature signature = 2; } message AddRequest { diff --git a/pkg/services/tree/service_frostfs.pb.go b/pkg/services/tree/service_frostfs.pb.go index 7b6abb1dd7b333ed7341e9c6ddcba5f943433af9..16dc0a0604c45d55ce36c39aa950423b468950c7 100644 GIT binary patch literal 337973 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 z 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

2I50?9O| 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 ILpc`z59OR`0RR91 diff --git a/pkg/services/tree/service_grpc.pb.go b/pkg/services/tree/service_grpc.pb.go index 63f96e11a561bc8a96b26272e90478e59539536b..e04e43aea11962b56a2bc21aee1b511df3c77d89 100644 GIT binary patch delta 3774 zcmbVPUrbw77!SQ&2Q9>Iy=77gy+VPut1Byi<^cKA#bFVo6!k$RqiK(U2^304Ml>$O z#FssY`=Z-}Grsr^>GL9qzRROAF~0er4`%Vv#QB|j&uPzX&vDbId%u6b?|0AlyI*g= z`@HG!ansR(1Ln05eBnr#*Pk3C<~6J(@wx~J=Nb6W5r8A@Dr7VlWVH_XTN^cV(#~P? zde3>;y#9#xqIMRRwdZ?ZCAp3AT7He>-|um^yip=#nUwa{^W;YAR%weAW(oy3cIdP& zT=hsZvt+ZlN6h3!E%<_!Y&oU*u7?`%SI=q6Hv^l(i<&1+Cj%tEt4#{C3veVmZv?Yz??9 z3!bzM@Y^Gozt!^cgKQ1h9Ys%=pMXw z#H59RR+j8;7q@mv+#sb=u@n``><33%U8t7T#Sn5_5mPk~50+w|x9V+fguT@0{x?MN zK&CDXz+b*rZw=wv#yZ(5SK8W6y^uH;R8B_-ob>51*yRyC54{P&y6oGx&kG;L?!bp& zBQr+~JMDgo$Y*umJHp`Te2+(caM-m#**(*oJ1CSb6X|j2Ri_TqzMBkQDg+^Be^Q!V zds1rQlPc%`6m2N$SWjgf87O1ON2VFIlw{eAfGmW)Wo~RMyygL^`MpTgh{P=6!--pm z(>^D}gDqn5%fW&pk${74z4B*Q%*9`Px~p;_U%)CxD@%56;c^uKacOZa8(kLi2XErw7lRVe!wYv6&v^%?Ruwxxv~?pPf} zcLTURsK(`Er1=a)r6>9uPD)XOIO!^{1jD{&xN)&x*jC7`i=Dh=uyph%eXj}mr~Tu? zq@fq7y>fdX*46*5 z1KLN4j9q1W0~@mM82jYw#4HAB8yOUK7^x}jKL`Y##IA`e;I!XgNyPU$V0!SpF!iO0 z`+}95+UMhVF0mp6mWDs>tcod;FW-(EC9-pg^MR(pC0_VL@*%(77`nr6e;-<9a8xJ9 zqo1&EO-o>nh?XA?lCN`wlgTgn?YmPS*<|*3=xrXNzk{p8WhUPlx^o)AO*h4E=CXjy z!~@2UstM0Qun=Cb@F>`Og_(`mvjlb~7nO>^^H#>Nt1^Zhlrdy9#vi0^i^=%arOy|8nGJky@@N8Z(jLTgFj|{OdNEj zE~P_)_1SE%{reEZb_ZtIl#B^n$=&IQsJbdJ)hCvz)sPM;V^}AhQaU7YS3XGlo4}ZB P7td#Mu17T2=HB@SSpHFX delta 153 zcmbR6k@4_U#tn^3lg~1XPF}#IKlvD|=;U`yrju_niB5K6Hk#ZGq$e{QOqOOBo&1Q| zaiiS#>zN3w!#jb0<5KTzwHWIaijGIbP4j_YK!~rUPMEMh{0Mo3CrDS8R*C7>( z@&H^a?U^Uw63NfNu@}Sx@CfYzuCTM4mKYVNM03Cw+cUE}-^};Te0(^1Ir^NNMKNa^ zUE0Hy@kjb8w{zlT5VDx#D=y|NA>#Yo-1RWPi`x3uKnQgc?mY0{cw3xUrfpWGZGcHT zCRn+3q>BSzn!qOt`I6W%u-G#$9Lrs1e(9{l)OQ~H?S$Y(7t^o}$;E2EypRC{Sl2iU z>84ewD5TA4soL)wWPO- zT^HAMvC;wY3W1Ir=vN?Z(xZL`>FlXpV@i!(E?yi0#0)DH#R{0bBnSwDmjpEC*=a@* zp{++D_{L2yJ}%&b>Bg!{tDFANo>cjVs$ zqq;87k4dAh>F7HU0>CCk2f4F68|YVN-?58A5-~ z44ju*Z?;-D8S64jdS&D?m>J;-qbh?|b<~68iPTM_ZuRtqpsT1Sw#(iW2S|J=H~+Vm zq_z1vn$pnAZK{S|jm#?9%J8X1Ix{gc0?0bnfhqoZ-|sD5I-}67g`0E&o+z5pU% ztpYMtlZ%rxzqCXzG&8Tn%tWI?F94z<1t?L=#aTUBQCo@~s4+LSBoS(%BnwOe=(-#k z9YMHW=Zwsp6wegR$+kRklW)rkZO)N>!pIMG5>%Du