diff --git a/docs/architecture/state-sync.md b/docs/architecture/state-sync.md index 3f7b263ea5b..06b860bea80 100644 --- a/docs/architecture/state-sync.md +++ b/docs/architecture/state-sync.md @@ -118,7 +118,7 @@ sequenceDiagram D-CS-->>-SSEH-CS: SSEH-CS->>+SSES-CS: OnExportRetrieved() loop - SSES-CS->>+SSEH-CS: provider.ReadArtifact() + SSES-CS->>+SSEH-CS: provider.ReadNextArtifact() SSEH-CS->>+D-CS: Read(artifactFile) D-CS-->>-SSEH-CS: SSEH-CS-->>-SSES-CS: artifact{name, data} @@ -246,16 +246,16 @@ sequenceDiagram SSEH-CS->>SSEH-CS: activeOperation = operationDetails{} SSEH-CS->>+D-CS: MkDir(exportDir) D-CS-->>-SSEH-CS: - SSEH-CS->>+SSES-CS: provider.GetExportData() + SSEH-CS->>+SSES-CS: provider.GetExportDataReader() SSES-CS->>+MS-CS: ExportStorageFromPrefix
("swingStore.") MS-CS-->>-SSES-CS: vstorage data entries - SSES-CS-->>-SSEH-CS: + SSES-CS--)-SSEH-CS: export data reader loop each data entry SSEH-CS->>+D-CS: Append(export-data.jsonl,
"JSON(entry tuple)\n") D-CS-->>-SSEH-CS: end loop extension snapshot items - SSEH-CS->>+SSES-CS: provider.readArtifact() + SSEH-CS->>+SSES-CS: provider.ReadNextArtifact() SSES-CS->>+SM-CS: payloadReader() SM-CS->>+SM-M: chunk = <-chunks SM-M-->>-SM-CS: diff --git a/golang/cosmos/app/app.go b/golang/cosmos/app/app.go index 44d0d7bda39..07779ef0916 100644 --- a/golang/cosmos/app/app.go +++ b/golang/cosmos/app/app.go @@ -102,11 +102,13 @@ import ( tmjson "github.com/tendermint/tendermint/libs/json" "github.com/tendermint/tendermint/libs/log" tmos "github.com/tendermint/tendermint/libs/os" + tmproto "github.com/tendermint/tendermint/proto/tendermint/types" dbm "github.com/tendermint/tm-db" gaiaappparams "github.com/Agoric/agoric-sdk/golang/cosmos/app/params" appante "github.com/Agoric/agoric-sdk/golang/cosmos/ante" + agorictypes "github.com/Agoric/agoric-sdk/golang/cosmos/types" "github.com/Agoric/agoric-sdk/golang/cosmos/vm" "github.com/Agoric/agoric-sdk/golang/cosmos/x/lien" "github.com/Agoric/agoric-sdk/golang/cosmos/x/swingset" @@ -472,10 +474,19 @@ func NewAgoricApp( return sendToController(true, string(bz)) }, ) + + getSwingStoreExportDataShadowCopyReader := func(height int64) agorictypes.KVEntryReader { + ctx := app.NewUncachedContext(false, tmproto.Header{Height: height}) + exportDataEntries := app.SwingSetKeeper.ExportSwingStore(ctx) + if len(exportDataEntries) == 0 { + return nil + } + return agorictypes.NewVstorageDataEntriesReader(exportDataEntries) + } app.SwingSetSnapshotter = *swingsetkeeper.NewExtensionSnapshotter( bApp, &app.SwingStoreExportsHandler, - app.SwingSetKeeper.ExportSwingStore, + getSwingStoreExportDataShadowCopyReader, ) app.VibcKeeper = vibc.NewKeeper( diff --git a/golang/cosmos/types/kv_entry.go b/golang/cosmos/types/kv_entry.go new file mode 100644 index 00000000000..44448ad25b6 --- /dev/null +++ b/golang/cosmos/types/kv_entry.go @@ -0,0 +1,114 @@ +package types + +import ( + "encoding/json" + "fmt" +) + +var _ json.Marshaler = &KVEntry{} +var _ json.Unmarshaler = &KVEntry{} + +// KVEntry represents a string key / string value pair, where the value may be +// missing, which is different from an empty value. +// The semantics of a missing value are purpose-dependent rather than specified +// here, but frequently correspond with deletion/incompleteness/etc. +// A KVEntry with an empty key is considered invalid. +type KVEntry struct { + key string + value *string +} + +// NewKVEntry creates a KVEntry with the provided key and value +func NewKVEntry(key string, value string) KVEntry { + return KVEntry{key, &value} +} + +// NewKVEntryWithNoValue creates a KVEntry with the provided key and no value +func NewKVEntryWithNoValue(key string) KVEntry { + return KVEntry{key, nil} +} + +// UnmarshalJSON updates a KVEntry from JSON text corresponding with a +// [key: string, value?: string | null] shape, or returns an error indicating +// invalid input. +// The key must be a non-empty string, and the value (if present) must be a +// string or null. +// +// Implements json.Unmarshaler +// Note: unlike other methods, this accepts a pointer to satisfy +// the Unmarshaler semantics. +func (entry *KVEntry) UnmarshalJSON(input []byte) (err error) { + var generic []*string + err = json.Unmarshal(input, &generic) + if err != nil { + return err + } + + length := len(generic) + + if generic == nil { + return fmt.Errorf("KVEntry cannot be null") + } + if length != 1 && length != 2 { + return fmt.Errorf("KVEntry must be an array of length 1 or 2 (not %d)", length) + } + + key := generic[0] + if key == nil || *key == "" { + return fmt.Errorf("KVEntry key must be a non-empty string: %v", key) + } + + var value *string + if length == 2 { + value = generic[1] + } + + entry.key = *key + entry.value = value + + return nil +} + +// MarshalJSON encodes the KVEntry into a JSON array of [key: string, value?: string], +// with the value missing (array length of 1) if the entry has no value. +// +// Implements json.Marshaler +func (entry KVEntry) MarshalJSON() ([]byte, error) { + if !entry.IsValidKey() { + return nil, fmt.Errorf("cannot marshal invalid KVEntry") + } + if entry.value != nil { + return json.Marshal([2]string{entry.key, *entry.value}) + } else { + return json.Marshal([1]string{entry.key}) + } +} + +// IsValidKey returns whether the KVEntry has a non-empty key. +func (entry KVEntry) IsValidKey() bool { + return entry.key != "" +} + +// Key returns the string key. +func (entry KVEntry) Key() string { + return entry.key +} + +// HasValue returns whether the KVEntry has a value or not. +func (entry KVEntry) HasValue() bool { + return entry.value != nil +} + +// Value returns a pointer to the string value or nil if the entry has no value. +func (entry KVEntry) Value() *string { + return entry.value +} + +// StringValue returns the string value, or the empty string if the entry has no value. +// Note that the result therefore does not differentiate an empty string value from no value. +func (entry KVEntry) StringValue() string { + if entry.value != nil { + return *entry.value + } + return "" +} diff --git a/golang/cosmos/types/kv_entry_helpers.go b/golang/cosmos/types/kv_entry_helpers.go new file mode 100644 index 00000000000..d6bd20b8e7a --- /dev/null +++ b/golang/cosmos/types/kv_entry_helpers.go @@ -0,0 +1,220 @@ +package types + +import ( + "encoding/json" + "fmt" + "io" + + vstoragetypes "github.com/Agoric/agoric-sdk/golang/cosmos/x/vstorage/types" + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// These helpers facilitate handling KVEntry streams, in particular for the +// swing-store "export data" use case. The goal is to avoid passing around +// large slices of key/value pairs. +// +// Handling of these streams is primarily accomplished through a KVEntryReader +// interface, with multiple implementations for different backing sources, as +// well as a helper function to consume a reader and write the entries into a +// byte Writer as line terminated json encoded KVEntry. + +// We attempt to pass sdk.Iterator around as much as possible to abstract a +// stream of Key/Value pairs without requiring the whole slice to be held in +// memory if possible. Cosmos SDK defines iterators as yielding Key/Value +// pairs, both as byte slices. +// +// More precisely, we define here the following: +// - A KVEntryReader interface allowing to Read the KVEntry one by one from an +// underlying source. +// - Multiple implementations of the KVEntryReader interface: +// - NewKVIteratorReader constructs a reader which consumes an sdk.Iterator. +// Keys and values are converted from byte slices to strings, and nil values +// are preserved as KVEntry instances with no value. +// - A generic reader which uses a slice of key/value data, and a conversion +// function from that data type to a KVEntry. The reader does bounds +// checking and keeps track of the current position. The following data +// types are available: +// - NewVstorageDataEntriesReader constructs a reader from a slice of +// vstorage DataEntry values. +// - NewJsonRawMessageKVEntriesReader constructs a reader from a slice of +// [key: string, value?: string | null] JSON array values. +// - NewJsonlKVEntryDecoderReader constructs a reader from an io.ReadCloser +// (like a file) containing JSON Lines in which each item is a +// [key: string, value?: string | null] array. +// - EncodeKVEntryReaderToJsonl consumes a KVEntryReader and writes its entries +// into an io.Writer as a sequence of single-line JSON texts. The encoding of +// each line is [key, value] if the KVEntry has a value, and [key] otherwise. +// This format terminates each line, but is still compatible with JSON Lines +// (which is line feed *separated*) for Go and JS decoders. + +// KVEntryReader is an abstraction for iteratively reading KVEntry data. +type KVEntryReader interface { + // Read returns the next KVEntry, or an error. + // An `io.EOF` error indicates that the previous Read() returned the final KVEntry. + Read() (KVEntry, error) + // Close frees the underlying resource (such as a slice or file descriptor). + Close() error +} + +var _ KVEntryReader = &kvIteratorReader{} + +// kvIteratorReader is a KVEntryReader backed by an sdk.Iterator +type kvIteratorReader struct { + iter sdk.Iterator +} + +// NewKVIteratorReader returns a KVEntryReader backed by an sdk.Iterator. +func NewKVIteratorReader(iter sdk.Iterator) KVEntryReader { + return &kvIteratorReader{ + iter: iter, + } +} + +// Read yields the next KVEntry from the source iterator +// Implements KVEntryReader +func (ir kvIteratorReader) Read() (next KVEntry, err error) { + if !ir.iter.Valid() { + // There is unfortunately no way to differentiate completion from iteration + // errors with the implementation of Iterators by cosmos-sdk since the + // iter.Error() returns an error in both cases + return KVEntry{}, io.EOF + } + + key := ir.iter.Key() + if len(key) == 0 { + return KVEntry{}, fmt.Errorf("nil or empty key yielded by iterator") + } + + value := ir.iter.Value() + ir.iter.Next() + if value == nil { + return NewKVEntryWithNoValue(string(key)), nil + } else { + return NewKVEntry(string(key), string(value)), nil + } +} + +func (ir kvIteratorReader) Close() error { + return ir.iter.Close() +} + +var _ KVEntryReader = &kvEntriesReader[any]{} + +// kvEntriesReader is the KVEntryReader using an underlying slice of generic +// kv entries. It reads from the slice sequentially using a type specific +// toKVEntry func, performing bounds checks, and tracking the position. +type kvEntriesReader[T any] struct { + entries []T + toKVEntry func(T) (KVEntry, error) + nextIndex int +} + +// Read yields the next KVEntry from the source +// Implements KVEntryReader +func (reader *kvEntriesReader[T]) Read() (next KVEntry, err error) { + if reader.entries == nil { + return KVEntry{}, fmt.Errorf("reader closed") + } + + length := len(reader.entries) + + if reader.nextIndex < length { + entry, err := reader.toKVEntry(reader.entries[reader.nextIndex]) + reader.nextIndex += 1 + if err != nil { + return KVEntry{}, err + } + if !entry.IsValidKey() { + return KVEntry{}, fmt.Errorf("source yielded a KVEntry with an invalid key") + } + return entry, err + } else if reader.nextIndex == length { + reader.nextIndex += 1 + return KVEntry{}, io.EOF + } else { + return KVEntry{}, fmt.Errorf("index %d is out of source bounds (length %d)", reader.nextIndex, length) + } +} + +// Close releases the source slice +// Implements KVEntryReader +func (reader *kvEntriesReader[any]) Close() error { + reader.entries = nil + return nil +} + +// NewVstorageDataEntriesReader creates a KVEntryReader backed by a +// vstorage DataEntry slice +func NewVstorageDataEntriesReader(vstorageDataEntries []*vstoragetypes.DataEntry) KVEntryReader { + return &kvEntriesReader[*vstoragetypes.DataEntry]{ + entries: vstorageDataEntries, + toKVEntry: func(sourceEntry *vstoragetypes.DataEntry) (KVEntry, error) { + return NewKVEntry(sourceEntry.Path, sourceEntry.Value), nil + }, + } +} + +// NewJsonRawMessageKVEntriesReader creates a KVEntryReader backed by +// a json.RawMessage slice +func NewJsonRawMessageKVEntriesReader(jsonEntries []json.RawMessage) KVEntryReader { + return &kvEntriesReader[json.RawMessage]{ + entries: jsonEntries, + toKVEntry: func(sourceEntry json.RawMessage) (entry KVEntry, err error) { + err = json.Unmarshal(sourceEntry, &entry) + return entry, err + }, + } +} + +var _ KVEntryReader = &jsonlKVEntryDecoderReader{} + +// jsonlKVEntryDecoderReader is the KVEntryReader decoding +// jsonl-like encoded key/value pairs. +type jsonlKVEntryDecoderReader struct { + closer io.Closer + decoder *json.Decoder +} + +// Read yields the next decoded KVEntry +// Implements KVEntryReader +func (reader jsonlKVEntryDecoderReader) Read() (next KVEntry, err error) { + err = reader.decoder.Decode(&next) + return next, err +} + +// Close release the underlying resource backing the decoder +// Implements KVEntryReader +func (reader jsonlKVEntryDecoderReader) Close() error { + return reader.closer.Close() +} + +// NewJsonlKVEntryDecoderReader creates a KVEntryReader over a byte +// stream reader that decodes each line as a json encoded KVEntry. The entries +// are yielded in order they're present in the stream. +func NewJsonlKVEntryDecoderReader(byteReader io.ReadCloser) KVEntryReader { + return &jsonlKVEntryDecoderReader{ + closer: byteReader, + decoder: json.NewDecoder(byteReader), + } +} + +// EncodeKVEntryReaderToJsonl consumes a KVEntryReader and JSON encodes each +// KVEntry, terminating by new lines. +// It will not Close the Reader when done +func EncodeKVEntryReaderToJsonl(reader KVEntryReader, bytesWriter io.Writer) (err error) { + encoder := json.NewEncoder(bytesWriter) + encoder.SetEscapeHTML(false) + for { + entry, err := reader.Read() + if err == io.EOF { + return nil + } else if err != nil { + return err + } + + err = encoder.Encode(entry) + if err != nil { + return err + } + } +} diff --git a/golang/cosmos/types/kv_entry_helpers_test.go b/golang/cosmos/types/kv_entry_helpers_test.go new file mode 100644 index 00000000000..3037b5f024d --- /dev/null +++ b/golang/cosmos/types/kv_entry_helpers_test.go @@ -0,0 +1,237 @@ +package types + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +func toKVEntryIdentity(entry KVEntry) (KVEntry, error) { + return entry, nil +} + +func toKVEntryError(err error) (KVEntry, error) { + return KVEntry{}, err +} + +func checkSameKVEntry(t *testing.T, got KVEntry, expected KVEntry) { + if got.key != expected.key { + t.Errorf("got key %s, expected key %s", got.key, expected.key) + } + if got.value == nil && expected.value != nil { + t.Errorf("got nil value, expected string %s", *expected.value) + } else if got.value != nil && expected.value == nil { + t.Errorf("got string value %s, expected nil", *got.value) + } else if got.value != nil && expected.value != nil { + if *got.value != *expected.value { + t.Errorf("got string value %s, expected %s", *got.value, *expected.value) + } + } +} + +func TestKVEntriesReaderNormal(t *testing.T) { + source := []KVEntry{NewKVEntry("foo", "bar"), NewKVEntryWithNoValue("baz")} + reader := kvEntriesReader[KVEntry]{entries: source, toKVEntry: toKVEntryIdentity} + + got1, err := reader.Read() + if err != nil { + t.Errorf("unexpected error: %v", err) + } + checkSameKVEntry(t, got1, source[0]) + + got2, err := reader.Read() + if err != nil { + t.Errorf("unexpected error: %v", err) + } + checkSameKVEntry(t, got2, source[1]) + + _, err = reader.Read() + if err != io.EOF { + t.Errorf("expected error io.EOF, got %v", err) + } + + _, err = reader.Read() + if err == nil || !strings.Contains(err.Error(), "bounds") { + t.Errorf("expected out of bounds error, got %v", err) + } + + err = reader.Close() + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + _, err = reader.Read() + if err == nil || !strings.Contains(err.Error(), "reader closed") { + t.Errorf("expected reader closed error, got %v", err) + } +} + +func TestKVEntriesReaderErrors(t *testing.T) { + source := []error{errors.New("foo"), errors.New("bar")} + reader := kvEntriesReader[error]{entries: source, toKVEntry: toKVEntryError} + + _, err := reader.Read() + if err != source[0] { + t.Errorf("got error %v, expected error %v", err, source[0]) + } + + // Nothing in the reader prevents reading after previous errors + _, err = reader.Read() + if err != source[1] { + t.Errorf("got error %v, expected error %v", err, source[1]) + } + + _, err = reader.Read() + if err != io.EOF { + t.Errorf("expected error io.EOF, got %v", err) + } +} + +type kvEntryReaderIterator struct { + reader KVEntryReader + current KVEntry + err error +} + +// newKVEntryReaderIterator creates an iterator over a KVEntryReader. +// KVEntry keys and values are reported as []byte from the reader in order. +func newKVEntryReaderIterator(reader KVEntryReader) sdk.Iterator { + iter := &kvEntryReaderIterator{ + reader: reader, + } + iter.Next() + return iter +} + +// Domain implements sdk.Iterator +func (iter *kvEntryReaderIterator) Domain() (start []byte, end []byte) { + return nil, nil +} + +// Valid returns whether the current iterator is valid. Once invalid, the +// Iterator remains invalid forever. +func (iter *kvEntryReaderIterator) Valid() bool { + if iter.err == io.EOF { + return false + } else if iter.err != nil { + panic(iter.err) + } + return true +} + +// checkValid implements the validity invariants of sdk.Iterator methods. +func (iter *kvEntryReaderIterator) checkValid() { + if !iter.Valid() { + panic("invalid iterator") + } +} + +// Next moves the iterator to the next entry from the reader. +// If Valid() returns false, this method will panic. +func (iter *kvEntryReaderIterator) Next() { + iter.checkValid() + + iter.current, iter.err = iter.reader.Read() +} + +// Key returns the key at the current position. Panics if the iterator is invalid. +// CONTRACT: key readonly []byte +func (iter *kvEntryReaderIterator) Key() (key []byte) { + iter.checkValid() + + return []byte(iter.current.Key()) +} + +// Value returns the value at the current position. Panics if the iterator is invalid. +// CONTRACT: value readonly []byte +func (iter *kvEntryReaderIterator) Value() (value []byte) { + iter.checkValid() + + if !iter.current.HasValue() { + return nil + } else { + return []byte(iter.current.StringValue()) + } +} + +// Error returns the last error encountered by the iterator, if any. +func (iter *kvEntryReaderIterator) Error() error { + err := iter.err + if err == io.EOF { + return nil + } + + return err +} + +// Close closes the iterator, releasing any allocated resources. +func (iter *kvEntryReaderIterator) Close() error { + return iter.reader.Close() +} + +func TestKVIteratorReader(t *testing.T) { + source := []KVEntry{NewKVEntry("foo", "bar"), NewKVEntryWithNoValue("baz")} + iterator := newKVEntryReaderIterator(&kvEntriesReader[KVEntry]{entries: source, toKVEntry: toKVEntryIdentity}) + reader := NewKVIteratorReader(iterator) + + got1, err := reader.Read() + if err != nil { + t.Errorf("unexpected error: %v", err) + } + checkSameKVEntry(t, got1, source[0]) + + got2, err := reader.Read() + if err != nil { + t.Errorf("unexpected error: %v", err) + } + checkSameKVEntry(t, got2, source[1]) + + _, err = reader.Read() + if err != io.EOF { + t.Errorf("expected error io.EOF, got %v", err) + } + + err = reader.Close() + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestJsonlEncodeAndReadBack(t *testing.T) { + source := []KVEntry{NewKVEntry("foo", "bar"), NewKVEntryWithNoValue("baz")} + sourceReader := &kvEntriesReader[KVEntry]{entries: source, toKVEntry: toKVEntryIdentity} + + var encodedKVEntries bytes.Buffer + err := EncodeKVEntryReaderToJsonl(sourceReader, &encodedKVEntries) + if err != nil { + t.Errorf("unexpected encode error %v", err) + } + + jsonlReader := NewJsonlKVEntryDecoderReader(io.NopCloser(&encodedKVEntries)) + + got1, err := jsonlReader.Read() + if err != nil { + t.Errorf("unexpected error: %v", err) + } + checkSameKVEntry(t, got1, source[0]) + + got2, err := jsonlReader.Read() + if err != nil { + t.Errorf("unexpected error: %v", err) + } + checkSameKVEntry(t, got2, source[1]) + + _, err = jsonlReader.Read() + if err != io.EOF { + t.Errorf("expected error io.EOF, got %v", err) + } + + err = jsonlReader.Close() + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} diff --git a/golang/cosmos/types/kv_entry_test.go b/golang/cosmos/types/kv_entry_test.go new file mode 100644 index 00000000000..2a5c5b1e859 --- /dev/null +++ b/golang/cosmos/types/kv_entry_test.go @@ -0,0 +1,143 @@ +package types + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +func checkEntry(t *testing.T, label string, entry KVEntry, isValidKey bool, expectedKey string, hasValue bool, expectedValue string) { + gotValidKey := entry.IsValidKey() + if gotValidKey != isValidKey { + t.Errorf("%s: valid key is %v, expected %v", label, gotValidKey, isValidKey) + } + + gotKey := entry.Key() + if gotKey != expectedKey { + t.Errorf("%s: got %q, want %q", label, gotKey, expectedKey) + } + + if entry.HasValue() { + if !hasValue { + t.Errorf("%s: expected has no value", label) + } + + gotValue := *entry.Value() + if gotValue != expectedValue { + t.Errorf("%s: got %q, want %q", label, gotValue, expectedValue) + } + } else { + if hasValue { + t.Errorf("%s: expected has value", label) + } + + gotValuePointer := entry.Value() + if gotValuePointer != nil { + t.Errorf("%s: got %#v, want nil", label, gotValuePointer) + } + } + + gotValue := entry.StringValue() + if gotValue != expectedValue { + t.Errorf("%s: got %q, want %q", label, gotValue, expectedValue) + } +} + +func TestKVEntry(t *testing.T) { + type testCase struct { + label string + entry KVEntry + isValidKey bool + expectedKey string + hasValue bool + expectedValue string + } + cases := []testCase{ + {label: "normal", entry: NewKVEntry("foo", "bar"), isValidKey: true, expectedKey: "foo", hasValue: true, expectedValue: "bar"}, + {label: "empty string value", entry: NewKVEntry("foo", ""), isValidKey: true, expectedKey: "foo", hasValue: true, expectedValue: ""}, + {label: "no value", entry: NewKVEntryWithNoValue("foo"), isValidKey: true, expectedKey: "foo", hasValue: false, expectedValue: ""}, + {label: "empty key", entry: NewKVEntryWithNoValue(""), isValidKey: false, expectedKey: "", hasValue: false, expectedValue: ""}, + } + for _, desc := range cases { + checkEntry(t, desc.label, desc.entry, desc.isValidKey, desc.expectedKey, desc.hasValue, desc.expectedValue) + } +} + +func TestKVEntryMarshall(t *testing.T) { + type testCase struct { + label string + entry KVEntry + expectedError error + expectedEncoding string + } + cases := []testCase{ + {label: "normal", entry: NewKVEntry("foo", "bar"), expectedEncoding: `["foo","bar"]`}, + {label: "empty string value", entry: NewKVEntry("foo", ""), expectedEncoding: `["foo",""]`}, + {label: "no value", entry: NewKVEntryWithNoValue("foo"), expectedEncoding: `["foo"]`}, + {label: "empty key", entry: NewKVEntryWithNoValue(""), expectedError: errors.New("cannot marshal invalid KVEntry")}, + } + for _, desc := range cases { + marshalled, err := json.Marshal(desc.entry) + if desc.expectedError != nil && err == nil { + t.Errorf("%s: got nil error, expected marshal error: %q", desc.label, desc.expectedError.Error()) + } else if err != nil { + if desc.expectedError == nil { + t.Errorf("%s: got error %v, expected no error", desc.label, err) + } else if !strings.Contains(err.Error(), desc.expectedError.Error()) { + t.Errorf("%s: got error %q, expected error %q", desc.label, err.Error(), desc.expectedError.Error()) + } + continue + } + if string(marshalled) != desc.expectedEncoding { + t.Errorf("%s: got %q, want %q", desc.label, string(marshalled), desc.expectedEncoding) + } + } +} + +func TestKVEntryUnmarshall(t *testing.T) { + type testCase struct { + label string + encoded string + expectedError error + expectedKey string + hasValue bool + expectedValue string + } + cases := []testCase{ + {label: "normal", encoded: `["foo","bar"]`, expectedKey: "foo", hasValue: true, expectedValue: "bar"}, + {label: "empty string value", encoded: `["foo",""]`, expectedKey: "foo", hasValue: true, expectedValue: ""}, + {label: "no value", encoded: `["foo"]`, expectedKey: "foo", hasValue: false, expectedValue: ""}, + {label: "null value", encoded: `["foo",null]`, expectedKey: "foo", hasValue: false, expectedValue: ""}, + {label: "null", encoded: `null`, expectedError: errors.New("KVEntry cannot be null")}, + {label: "string", encoded: `"foo"`, expectedError: errors.New("json")}, + {label: "empty array", encoded: `[]`, expectedError: errors.New("KVEntry must be an array of length 1 or 2 (not 0)")}, + {label: "[null, null] array", encoded: `[null,null]`, expectedError: errors.New("KVEntry key must be a non-empty string")}, + {label: "invalid key array", encoded: `[42]`, expectedError: errors.New("json")}, + {label: "empty key", encoded: `["",null]`, expectedError: errors.New("KVEntry key must be a non-empty string")}, + {label: "too many entries array", encoded: `["foo","bar",null]`, expectedError: errors.New("KVEntry must be an array of length 1 or 2 (not 3)")}, + {label: "invalid value array", encoded: `["foo",42]`, expectedError: errors.New("json")}, + } + for _, desc := range cases { + unmarshalled := NewKVEntry("untouched", "untouched") + err := json.Unmarshal([]byte(desc.encoded), &unmarshalled) + if desc.expectedError != nil && err == nil { + t.Errorf("%s: got nil error, expected unmarshal error: %q", desc.label, desc.expectedError.Error()) + } else if err != nil { + if unmarshalled.Key() != "untouched" { + t.Errorf("%s: expected error to not modify target key, got %s", desc.label, unmarshalled.Key()) + } + if unmarshalled.StringValue() != "untouched" { + t.Errorf("%s: expected error to not modify target value, got %v", desc.label, unmarshalled.Value()) + } + if desc.expectedError == nil { + t.Errorf("%s: got error %v, expected no error", desc.label, err) + } else if !strings.Contains(err.Error(), desc.expectedError.Error()) { + t.Errorf("%s: got error %q, expected error %q", desc.label, err.Error(), desc.expectedError.Error()) + } + continue + } + + checkEntry(t, desc.label, unmarshalled, true, desc.expectedKey, desc.hasValue, desc.expectedValue) + } +} diff --git a/golang/cosmos/x/swingset/keeper/extension_snapshotter.go b/golang/cosmos/x/swingset/keeper/extension_snapshotter.go index e6f5e28d666..8e4c1fc0f2e 100644 --- a/golang/cosmos/x/swingset/keeper/extension_snapshotter.go +++ b/golang/cosmos/x/swingset/keeper/extension_snapshotter.go @@ -2,19 +2,16 @@ package keeper import ( "bytes" - "encoding/json" "errors" "fmt" "io" "math" + agoric "github.com/Agoric/agoric-sdk/golang/cosmos/types" "github.com/Agoric/agoric-sdk/golang/cosmos/x/swingset/types" - vstoragetypes "github.com/Agoric/agoric-sdk/golang/cosmos/x/vstorage/types" "github.com/cosmos/cosmos-sdk/baseapp" snapshots "github.com/cosmos/cosmos-sdk/snapshots/types" - sdk "github.com/cosmos/cosmos-sdk/types" "github.com/tendermint/tendermint/libs/log" - tmproto "github.com/tendermint/tendermint/proto/tendermint/types" ) // This module implements a Cosmos ExtensionSnapshotter to capture and restore @@ -67,30 +64,26 @@ type snapshotDetails struct { type ExtensionSnapshotter struct { isConfigured func() bool // takeAppSnapshot is called by OnExportStarted when creating a snapshot - takeAppSnapshot func(height int64) - newRestoreContext func(height int64) sdk.Context - swingStoreExportsHandler *SwingStoreExportsHandler - getSwingStoreExportDataShadowCopy func(ctx sdk.Context) []*vstoragetypes.DataEntry - logger log.Logger - activeSnapshot *snapshotDetails + takeAppSnapshot func(height int64) + swingStoreExportsHandler *SwingStoreExportsHandler + getSwingStoreExportDataShadowCopyReader func(height int64) agoric.KVEntryReader + logger log.Logger + activeSnapshot *snapshotDetails } // NewExtensionSnapshotter creates a new swingset ExtensionSnapshotter func NewExtensionSnapshotter( app *baseapp.BaseApp, swingStoreExportsHandler *SwingStoreExportsHandler, - getSwingStoreExportDataShadowCopy func(ctx sdk.Context) []*vstoragetypes.DataEntry, + getSwingStoreExportDataShadowCopyReader func(height int64) agoric.KVEntryReader, ) *ExtensionSnapshotter { return &ExtensionSnapshotter{ - isConfigured: func() bool { return app.SnapshotManager() != nil }, - takeAppSnapshot: app.Snapshot, - newRestoreContext: func(height int64) sdk.Context { - return app.NewUncachedContext(false, tmproto.Header{Height: height}) - }, - logger: app.Logger().With("module", fmt.Sprintf("x/%s", types.ModuleName), "submodule", "extension snapshotter"), - swingStoreExportsHandler: swingStoreExportsHandler, - getSwingStoreExportDataShadowCopy: getSwingStoreExportDataShadowCopy, - activeSnapshot: nil, + isConfigured: func() bool { return app.SnapshotManager() != nil }, + takeAppSnapshot: app.Snapshot, + logger: app.Logger().With("module", fmt.Sprintf("x/%s", types.ModuleName), "submodule", "extension snapshotter"), + swingStoreExportsHandler: swingStoreExportsHandler, + getSwingStoreExportDataShadowCopyReader: getSwingStoreExportDataShadowCopyReader, + activeSnapshot: nil, } } @@ -235,7 +228,7 @@ func (snapshotter *ExtensionSnapshotter) OnExportRetrieved(provider SwingStoreEx } for { - artifact, err := provider.ReadArtifact() + artifact, err := provider.ReadNextArtifact() if err == io.EOF { break } else if err != nil { @@ -248,13 +241,14 @@ func (snapshotter *ExtensionSnapshotter) OnExportRetrieved(provider SwingStoreEx } } - swingStoreExportDataEntries, err := provider.GetExportData() + exportDataReader, err := provider.GetExportDataReader() if err != nil { return err } - if len(swingStoreExportDataEntries) == 0 { + if exportDataReader == nil { return nil } + defer exportDataReader.Close() // For debugging, write out any retrieved export data as a single untrusted artifact // which has the same encoding as the internal SwingStore export data representation: @@ -262,14 +256,9 @@ func (snapshotter *ExtensionSnapshotter) OnExportRetrieved(provider SwingStoreEx exportDataArtifact := types.SwingStoreArtifact{Name: UntrustedExportDataArtifactName} var encodedExportData bytes.Buffer - encoder := json.NewEncoder(&encodedExportData) - encoder.SetEscapeHTML(false) - for _, dataEntry := range swingStoreExportDataEntries { - entry := []string{dataEntry.Path, dataEntry.Value} - err := encoder.Encode(entry) - if err != nil { - return err - } + err = agoric.EncodeKVEntryReaderToJsonl(exportDataReader, &encodedExportData) + if err != nil { + return err } exportDataArtifact.Data = encodedExportData.Bytes() @@ -298,13 +287,12 @@ func (snapshotter *ExtensionSnapshotter) RestoreExtension(blockHeight uint64, fo // At this point the content of the cosmos DB has been verified against the // AppHash, which means the SwingStore data it contains can be used as the // trusted root against which to validate the artifacts. - getExportData := func() ([]*vstoragetypes.DataEntry, error) { - ctx := snapshotter.newRestoreContext(height) - exportData := snapshotter.getSwingStoreExportDataShadowCopy(ctx) - return exportData, nil + getExportDataReader := func() (agoric.KVEntryReader, error) { + exportDataReader := snapshotter.getSwingStoreExportDataShadowCopyReader(height) + return exportDataReader, nil } - readArtifact := func() (artifact types.SwingStoreArtifact, err error) { + readNextArtifact := func() (artifact types.SwingStoreArtifact, err error) { payloadBytes, err := payloadReader() if err != nil { return artifact, err @@ -315,7 +303,7 @@ func (snapshotter *ExtensionSnapshotter) RestoreExtension(blockHeight uint64, fo } return snapshotter.swingStoreExportsHandler.RestoreExport( - SwingStoreExportProvider{BlockHeight: blockHeight, GetExportData: getExportData, ReadArtifact: readArtifact}, + SwingStoreExportProvider{BlockHeight: blockHeight, GetExportDataReader: getExportDataReader, ReadNextArtifact: readNextArtifact}, SwingStoreRestoreOptions{IncludeHistorical: false}, ) } diff --git a/golang/cosmos/x/swingset/keeper/extension_snapshotter_test.go b/golang/cosmos/x/swingset/keeper/extension_snapshotter_test.go index 85440591c4f..2f20b1662f1 100644 --- a/golang/cosmos/x/swingset/keeper/extension_snapshotter_test.go +++ b/golang/cosmos/x/swingset/keeper/extension_snapshotter_test.go @@ -4,7 +4,6 @@ import ( "io" "testing" - sdk "github.com/cosmos/cosmos-sdk/types" "github.com/tendermint/tendermint/libs/log" ) @@ -12,7 +11,6 @@ func newTestExtensionSnapshotter() *ExtensionSnapshotter { logger := log.NewNopLogger() // log.NewTMLogger(log.NewSyncWriter( /* os.Stdout*/ io.Discard)).With("module", "sdk/app") return &ExtensionSnapshotter{ isConfigured: func() bool { return true }, - newRestoreContext: func(height int64) sdk.Context { return sdk.Context{} }, logger: logger, swingStoreExportsHandler: newTestSwingStoreExportsHandler(), } diff --git a/golang/cosmos/x/swingset/keeper/keeper.go b/golang/cosmos/x/swingset/keeper/keeper.go index 2640e7176e0..00f4191a8dd 100644 --- a/golang/cosmos/x/swingset/keeper/keeper.go +++ b/golang/cosmos/x/swingset/keeper/keeper.go @@ -16,6 +16,7 @@ import ( paramtypes "github.com/cosmos/cosmos-sdk/x/params/types" "github.com/Agoric/agoric-sdk/golang/cosmos/ante" + agoric "github.com/Agoric/agoric-sdk/golang/cosmos/types" "github.com/Agoric/agoric-sdk/golang/cosmos/vm" "github.com/Agoric/agoric-sdk/golang/cosmos/x/swingset/types" vstoragekeeper "github.com/Agoric/agoric-sdk/golang/cosmos/x/vstorage/keeper" @@ -261,7 +262,7 @@ func getBeansOwingPathForAddress(addr sdk.AccAddress) string { func (k Keeper) GetBeansOwing(ctx sdk.Context, addr sdk.AccAddress) sdk.Uint { path := getBeansOwingPathForAddress(addr) entry := k.vstorageKeeper.GetEntry(ctx, path) - if !entry.HasData() { + if !entry.HasValue() { return sdk.ZeroUint() } return sdk.NewUintFromString(entry.StringValue()) @@ -271,7 +272,7 @@ func (k Keeper) GetBeansOwing(ctx sdk.Context, addr sdk.AccAddress) sdk.Uint { // feeCollector but has not yet paid. func (k Keeper) SetBeansOwing(ctx sdk.Context, addr sdk.AccAddress, beans sdk.Uint) { path := getBeansOwingPathForAddress(addr) - k.vstorageKeeper.SetStorage(ctx, vstoragetypes.NewStorageEntry(path, beans.String())) + k.vstorageKeeper.SetStorage(ctx, agoric.NewKVEntry(path, beans.String())) } // ChargeBeans charges the given address the given number of beans. It divides @@ -375,7 +376,7 @@ func (k Keeper) ChargeForProvisioning(ctx sdk.Context, submitter, addr sdk.AccAd func (k Keeper) GetEgress(ctx sdk.Context, addr sdk.AccAddress) types.Egress { path := StoragePathEgress + "." + addr.String() entry := k.vstorageKeeper.GetEntry(ctx, path) - if !entry.HasData() { + if !entry.HasValue() { return types.Egress{} } @@ -398,7 +399,7 @@ func (k Keeper) SetEgress(ctx sdk.Context, egress *types.Egress) error { } // FIXME: We should use just SetStorageAndNotify here, but solo needs legacy for now. - k.vstorageKeeper.LegacySetStorageAndNotify(ctx, vstoragetypes.NewStorageEntry(path, string(bz))) + k.vstorageKeeper.LegacySetStorageAndNotify(ctx, agoric.NewKVEntry(path, string(bz))) // Now make sure the corresponding account has been initialised. if acc := k.accountKeeper.GetAccount(ctx, egress.Peer); acc != nil { @@ -431,7 +432,7 @@ func (k Keeper) GetMailbox(ctx sdk.Context, peer string) string { func (k Keeper) SetMailbox(ctx sdk.Context, peer string, mailbox string) { path := StoragePathMailbox + "." + peer // FIXME: We should use just SetStorageAndNotify here, but solo needs legacy for now. - k.vstorageKeeper.LegacySetStorageAndNotify(ctx, vstoragetypes.NewStorageEntry(path, mailbox)) + k.vstorageKeeper.LegacySetStorageAndNotify(ctx, agoric.NewKVEntry(path, mailbox)) } func (k Keeper) ExportSwingStore(ctx sdk.Context) []*vstoragetypes.DataEntry { diff --git a/golang/cosmos/x/swingset/keeper/querier.go b/golang/cosmos/x/swingset/keeper/querier.go index ca678950371..3195b40885b 100644 --- a/golang/cosmos/x/swingset/keeper/querier.go +++ b/golang/cosmos/x/swingset/keeper/querier.go @@ -80,7 +80,7 @@ func queryMailbox(ctx sdk.Context, path []string, req abci.RequestQuery, keeper // nolint: unparam func legacyQueryStorage(ctx sdk.Context, path string, req abci.RequestQuery, keeper Keeper, legacyQuerierCdc *codec.LegacyAmino) (res []byte, err error) { entry := keeper.vstorageKeeper.GetEntry(ctx, path) - if !entry.HasData() { + if !entry.HasValue() { return []byte{}, sdkerrors.Wrapf(sdkerrors.ErrUnknownRequest, "could not get swingset %+v", path) } diff --git a/golang/cosmos/x/swingset/keeper/swing_store_exports_handler.go b/golang/cosmos/x/swingset/keeper/swing_store_exports_handler.go index a01bcf35ff3..a0c34268102 100644 --- a/golang/cosmos/x/swingset/keeper/swing_store_exports_handler.go +++ b/golang/cosmos/x/swingset/keeper/swing_store_exports_handler.go @@ -9,9 +9,9 @@ import ( "path/filepath" "regexp" + agoric "github.com/Agoric/agoric-sdk/golang/cosmos/types" "github.com/Agoric/agoric-sdk/golang/cosmos/vm" "github.com/Agoric/agoric-sdk/golang/cosmos/x/swingset/types" - vstoragetypes "github.com/Agoric/agoric-sdk/golang/cosmos/x/vstorage/types" sdkerrors "github.com/cosmos/cosmos-sdk/types/errors" "github.com/tendermint/tendermint/libs/log" ) @@ -177,8 +177,8 @@ type SwingStoreExportOptions struct { // packages/swing-store/src/swingStore.js makeSwingStoreExporter ExportMode string `json:"exportMode,omitempty"` // A flag indicating whether "export data" should be part of the swing-store export - // If false, the resulting SwingStoreExportProvider's GetExportData will - // return an empty list of "export data" entries. + // If false, the resulting SwingStoreExportProvider's GetExportDataReader + // will return nil IncludeExportData bool `json:"includeExportData,omitempty"` } @@ -364,11 +364,12 @@ func checkNotActive() error { type SwingStoreExportProvider struct { // BlockHeight is the block height of the SwingStore export. BlockHeight uint64 - // GetExportData is a function to return the "export data" of the SwingStore export, if any. - GetExportData func() ([]*vstoragetypes.DataEntry, error) - // ReadArtifact is a function to return the next unread artifact in the SwingStore export. - // It errors with io.EOF upon reaching the end of the artifact list. - ReadArtifact func() (types.SwingStoreArtifact, error) + // GetExportDataReader returns a KVEntryReader for the "export data" of the + // SwingStore export, or nil if the "export data" is not part of this export. + GetExportDataReader func() (agoric.KVEntryReader, error) + // ReadNextArtifact is a function to return the next unread artifact in the SwingStore export. + // It errors with io.EOF upon reaching the end of the list of available artifacts. + ReadNextArtifact func() (types.SwingStoreArtifact, error) } // SwingStoreExportEventHandler is used to handle events that occur while generating @@ -615,41 +616,22 @@ func (exportsHandler SwingStoreExportsHandler) retrieveExport(onExportRetrieved return fmt.Errorf("export manifest blockHeight (%d) doesn't match (%d)", manifest.BlockHeight, blockHeight) } - getExportData := func() ([]*vstoragetypes.DataEntry, error) { - entries := []*vstoragetypes.DataEntry{} + getExportDataReader := func() (agoric.KVEntryReader, error) { if manifest.Data == "" { - return entries, nil + return nil, nil } dataFile, err := os.Open(filepath.Join(exportDir, manifest.Data)) if err != nil { return nil, err } - defer dataFile.Close() - - decoder := json.NewDecoder(dataFile) - for { - var jsonEntry []string - err = decoder.Decode(&jsonEntry) - if err == io.EOF { - break - } else if err != nil { - return nil, err - } - - if len(jsonEntry) != 2 { - return nil, fmt.Errorf("invalid export data entry (length %d)", len(jsonEntry)) - } - entry := vstoragetypes.DataEntry{Path: jsonEntry[0], Value: jsonEntry[1]} - entries = append(entries, &entry) - } - - return entries, nil + exportDataReader := agoric.NewJsonlKVEntryDecoderReader(dataFile) + return exportDataReader, nil } nextArtifact := 0 - readArtifact := func() (artifact types.SwingStoreArtifact, err error) { + readNextArtifact := func() (artifact types.SwingStoreArtifact, err error) { if nextArtifact == len(manifest.Artifacts) { return artifact, io.EOF } else if nextArtifact > len(manifest.Artifacts) { @@ -670,7 +652,7 @@ func (exportsHandler SwingStoreExportsHandler) retrieveExport(onExportRetrieved return artifact, err } - err = onExportRetrieved(SwingStoreExportProvider{BlockHeight: manifest.BlockHeight, GetExportData: getExportData, ReadArtifact: readArtifact}) + err = onExportRetrieved(SwingStoreExportProvider{BlockHeight: manifest.BlockHeight, GetExportDataReader: getExportDataReader, ReadNextArtifact: readNextArtifact}) if err != nil { return err } @@ -724,12 +706,14 @@ func (exportsHandler SwingStoreExportsHandler) RestoreExport(provider SwingStore BlockHeight: blockHeight, } - exportDataEntries, err := provider.GetExportData() + exportDataReader, err := provider.GetExportDataReader() if err != nil { return err } - if len(exportDataEntries) > 0 { + if exportDataReader != nil { + defer exportDataReader.Close() + manifest.Data = exportDataFilename exportDataFile, err := os.OpenFile(filepath.Join(exportDir, exportDataFilename), os.O_CREATE|os.O_WRONLY, exportedFilesMode) if err != nil { @@ -737,14 +721,9 @@ func (exportsHandler SwingStoreExportsHandler) RestoreExport(provider SwingStore } defer exportDataFile.Close() - encoder := json.NewEncoder(exportDataFile) - encoder.SetEscapeHTML(false) - for _, dataEntry := range exportDataEntries { - entry := []string{dataEntry.Path, dataEntry.Value} - err := encoder.Encode(entry) - if err != nil { - return err - } + err = agoric.EncodeKVEntryReaderToJsonl(exportDataReader, exportDataFile) + if err != nil { + return err } err = exportDataFile.Sync() @@ -758,7 +737,7 @@ func (exportsHandler SwingStoreExportsHandler) RestoreExport(provider SwingStore } for { - artifact, err := provider.ReadArtifact() + artifact, err := provider.ReadNextArtifact() if err == io.EOF { break } else if err != nil { diff --git a/golang/cosmos/x/swingset/keeper/swing_store_exports_handler_test.go b/golang/cosmos/x/swingset/keeper/swing_store_exports_handler_test.go index 7396c501157..c13951c9414 100644 --- a/golang/cosmos/x/swingset/keeper/swing_store_exports_handler_test.go +++ b/golang/cosmos/x/swingset/keeper/swing_store_exports_handler_test.go @@ -31,7 +31,7 @@ func newTestSwingStoreEventHandler() testSwingStoreEventHandler { }, onExportRetrieved: func(provider SwingStoreExportProvider) error { for { - _, err := provider.ReadArtifact() + _, err := provider.ReadNextArtifact() if err == io.EOF { return nil } else if err != nil { diff --git a/golang/cosmos/x/vstorage/keeper/grpc_query.go b/golang/cosmos/x/vstorage/keeper/grpc_query.go index 49bc47af39c..0c0c9025492 100644 --- a/golang/cosmos/x/vstorage/keeper/grpc_query.go +++ b/golang/cosmos/x/vstorage/keeper/grpc_query.go @@ -200,7 +200,7 @@ func (k Querier) CapData(c context.Context, req *types.QueryCapDataRequest) (*ty // Read data, auto-upgrading a standalone value to a single-value StreamCell. entry := k.GetEntry(ctx, req.Path) - if !entry.HasData() { + if !entry.HasValue() { return nil, status.Error(codes.FailedPrecondition, "no data") } value := entry.StringValue() diff --git a/golang/cosmos/x/vstorage/keeper/keeper.go b/golang/cosmos/x/vstorage/keeper/keeper.go index 9a91e793b10..cc0e9d3298c 100644 --- a/golang/cosmos/x/vstorage/keeper/keeper.go +++ b/golang/cosmos/x/vstorage/keeper/keeper.go @@ -35,7 +35,7 @@ type ProposedChange struct { } type ChangeManager interface { - Track(ctx sdk.Context, k Keeper, entry types.StorageEntry, isLegacy bool) + Track(ctx sdk.Context, k Keeper, entry agoric.KVEntry, isLegacy bool) EmitEvents(ctx sdk.Context, k Keeper) Rollback(ctx sdk.Context) } @@ -65,8 +65,8 @@ type Keeper struct { storeKey sdk.StoreKey } -func (bcm *BatchingChangeManager) Track(ctx sdk.Context, k Keeper, entry types.StorageEntry, isLegacy bool) { - path := entry.Path() +func (bcm *BatchingChangeManager) Track(ctx sdk.Context, k Keeper, entry agoric.KVEntry, isLegacy bool) { + path := entry.Key() // TODO: differentiate between deletion and setting empty string? // Using empty string for deletion for backwards compatibility value := entry.StringValue() @@ -177,7 +177,7 @@ func (k Keeper) ImportStorage(ctx sdk.Context, entries []*types.DataEntry) { for _, entry := range entries { // This set does the bookkeeping for us in case the entries aren't a // complete tree. - k.SetStorage(ctx, types.NewStorageEntry(entry.Path, entry.Value)) + k.SetStorage(ctx, agoric.NewKVEntry(entry.Path, entry.Value)) } } @@ -205,22 +205,22 @@ func (k Keeper) EmitChange(ctx sdk.Context, change *ProposedChange) { } // GetEntry gets generic storage. The default value is an empty string. -func (k Keeper) GetEntry(ctx sdk.Context, path string) types.StorageEntry { +func (k Keeper) GetEntry(ctx sdk.Context, path string) agoric.KVEntry { //fmt.Printf("GetEntry(%s)\n", path); store := ctx.KVStore(k.storeKey) encodedKey := types.PathToEncodedKey(path) rawValue := store.Get(encodedKey) if len(rawValue) == 0 { - return types.NewStorageEntryWithNoData(path) + return agoric.NewKVEntryWithNoValue(path) } if bytes.Equal(rawValue, types.EncodedNoDataValue) { - return types.NewStorageEntryWithNoData(path) + return agoric.NewKVEntryWithNoValue(path) } value, hasPrefix := cutPrefix(rawValue, types.EncodedDataPrefix) if !hasPrefix { panic(fmt.Errorf("value at path %q starts with unexpected prefix", path)) } - return types.NewStorageEntry(path, string(value)) + return agoric.NewKVEntry(path, string(value)) } func (k Keeper) getKeyIterator(ctx sdk.Context, path string) db.Iterator { @@ -249,7 +249,7 @@ func (k Keeper) GetChildren(ctx sdk.Context, path string) *types.Children { // (just an empty string) and exist only to provide linkage to subnodes with // data. func (k Keeper) HasStorage(ctx sdk.Context, path string) bool { - return k.GetEntry(ctx, path).HasData() + return k.GetEntry(ctx, path).HasValue() } // HasEntry tells if a given path has either subnodes or data. @@ -278,12 +278,12 @@ func (k Keeper) FlushChangeEvents(ctx sdk.Context) { k.changeManager.Rollback(ctx) } -func (k Keeper) SetStorageAndNotify(ctx sdk.Context, entry types.StorageEntry) { +func (k Keeper) SetStorageAndNotify(ctx sdk.Context, entry agoric.KVEntry) { k.changeManager.Track(ctx, k, entry, false) k.SetStorage(ctx, entry) } -func (k Keeper) LegacySetStorageAndNotify(ctx sdk.Context, entry types.StorageEntry) { +func (k Keeper) LegacySetStorageAndNotify(ctx sdk.Context, entry agoric.KVEntry) { k.changeManager.Track(ctx, k, entry, true) k.SetStorage(ctx, entry) } @@ -308,7 +308,7 @@ func (k Keeper) AppendStorageValueAndNotify(ctx sdk.Context, path, value string) if err != nil { return err } - k.SetStorageAndNotify(ctx, types.NewStorageEntry(path, string(bz))) + k.SetStorageAndNotify(ctx, agoric.NewKVEntry(path, string(bz))) return nil } @@ -320,12 +320,12 @@ func componentsToPath(components []string) string { // // Maintains the invariant: path entries exist if and only if self or some // descendant has non-empty storage -func (k Keeper) SetStorage(ctx sdk.Context, entry types.StorageEntry) { +func (k Keeper) SetStorage(ctx sdk.Context, entry agoric.KVEntry) { store := ctx.KVStore(k.storeKey) - path := entry.Path() + path := entry.Key() encodedKey := types.PathToEncodedKey(path) - if !entry.HasData() { + if !entry.HasValue() { if !k.HasChildren(ctx, path) { // We have no children, can delete. store.Delete(encodedKey) @@ -340,7 +340,7 @@ func (k Keeper) SetStorage(ctx sdk.Context, entry types.StorageEntry) { // Update our other parent children. pathComponents := strings.Split(path, types.PathSeparator) - if !entry.HasData() { + if !entry.HasValue() { // delete placeholder ancestors if they're no longer needed for i := len(pathComponents) - 1; i >= 0; i-- { ancestor := componentsToPath(pathComponents[0:i]) @@ -381,7 +381,7 @@ func (k Keeper) GetNoDataValue() []byte { func (k Keeper) getIntValue(ctx sdk.Context, path string) (sdk.Int, error) { indexEntry := k.GetEntry(ctx, path) - if !indexEntry.HasData() { + if !indexEntry.HasValue() { return sdk.NewInt(0), nil } @@ -420,10 +420,10 @@ func (k Keeper) PushQueueItem(ctx sdk.Context, queuePath string, value string) e // Set the vstorage corresponding to the queue entry for the current tail. path := queuePath + "." + tail.String() - k.SetStorage(ctx, types.NewStorageEntry(path, value)) + k.SetStorage(ctx, agoric.NewKVEntry(path, value)) // Update the tail to point to the next available entry. path = queuePath + ".tail" - k.SetStorage(ctx, types.NewStorageEntry(path, nextTail.String())) + k.SetStorage(ctx, agoric.NewKVEntry(path, nextTail.String())) return nil } diff --git a/golang/cosmos/x/vstorage/keeper/keeper_grpc_test.go b/golang/cosmos/x/vstorage/keeper/keeper_grpc_test.go index b5883d61c3e..26e586e9bb8 100644 --- a/golang/cosmos/x/vstorage/keeper/keeper_grpc_test.go +++ b/golang/cosmos/x/vstorage/keeper/keeper_grpc_test.go @@ -9,6 +9,7 @@ import ( grpcCodes "google.golang.org/grpc/codes" grpcStatus "google.golang.org/grpc/status" + agoric "github.com/Agoric/agoric-sdk/golang/cosmos/types" "github.com/Agoric/agoric-sdk/golang/cosmos/x/vstorage/capdata" "github.com/Agoric/agoric-sdk/golang/cosmos/x/vstorage/types" @@ -271,9 +272,9 @@ func TestCapData(t *testing.T) { for _, desc := range testCases { desc.request.Path = "key" if desc.data == nil { - keeper.SetStorage(ctx, types.NewStorageEntryWithNoData(desc.request.Path)) + keeper.SetStorage(ctx, agoric.NewKVEntryWithNoValue(desc.request.Path)) } else { - keeper.SetStorage(ctx, types.NewStorageEntry(desc.request.Path, *desc.data)) + keeper.SetStorage(ctx, agoric.NewKVEntry(desc.request.Path, *desc.data)) } resp, err := querier.CapData(sdk.WrapSDKContext(ctx), &desc.request) if desc.errCode == grpcCodes.OK { diff --git a/golang/cosmos/x/vstorage/keeper/keeper_test.go b/golang/cosmos/x/vstorage/keeper/keeper_test.go index 1a2ce5d58ee..120e707b64b 100644 --- a/golang/cosmos/x/vstorage/keeper/keeper_test.go +++ b/golang/cosmos/x/vstorage/keeper/keeper_test.go @@ -4,6 +4,7 @@ import ( "reflect" "testing" + agoric "github.com/Agoric/agoric-sdk/golang/cosmos/types" "github.com/Agoric/agoric-sdk/golang/cosmos/x/vstorage/types" "github.com/cosmos/cosmos-sdk/store" @@ -57,19 +58,19 @@ func TestStorage(t *testing.T) { ctx, keeper := testKit.ctx, testKit.vstorageKeeper // Test that we can store and retrieve a value. - keeper.SetStorage(ctx, types.NewStorageEntry("inited", "initValue")) + keeper.SetStorage(ctx, agoric.NewKVEntry("inited", "initValue")) if got := keeper.GetEntry(ctx, "inited").StringValue(); got != "initValue" { t.Errorf("got %q, want %q", got, "initValue") } // Test that unknown children return empty string. - if got := keeper.GetEntry(ctx, "unknown"); got.HasData() || got.StringValue() != "" { + if got := keeper.GetEntry(ctx, "unknown"); got.HasValue() || got.StringValue() != "" { t.Errorf("got %q, want no value", got.StringValue()) } // Test that we can store and retrieve an empty string value. - keeper.SetStorage(ctx, types.NewStorageEntry("inited", "")) - if got := keeper.GetEntry(ctx, "inited"); !got.HasData() || got.StringValue() != "" { + keeper.SetStorage(ctx, agoric.NewKVEntry("inited", "")) + if got := keeper.GetEntry(ctx, "inited"); !got.HasValue() || got.StringValue() != "" { t.Errorf("got %q, want empty string", got.StringValue()) } @@ -78,18 +79,18 @@ func TestStorage(t *testing.T) { t.Errorf("got %q children, want [inited]", got.Children) } - keeper.SetStorage(ctx, types.NewStorageEntry("key1", "value1")) + keeper.SetStorage(ctx, agoric.NewKVEntry("key1", "value1")) if got := keeper.GetChildren(ctx, ""); !childrenEqual(got.Children, []string{"inited", "key1"}) { t.Errorf("got %q children, want [inited,key1]", got.Children) } // Check alphabetical. - keeper.SetStorage(ctx, types.NewStorageEntry("alpha2", "value2")) + keeper.SetStorage(ctx, agoric.NewKVEntry("alpha2", "value2")) if got := keeper.GetChildren(ctx, ""); !childrenEqual(got.Children, []string{"alpha2", "inited", "key1"}) { t.Errorf("got %q children, want [alpha2,inited,key1]", got.Children) } - keeper.SetStorage(ctx, types.NewStorageEntry("beta3", "value3")) + keeper.SetStorage(ctx, agoric.NewKVEntry("beta3", "value3")) if got := keeper.GetChildren(ctx, ""); !childrenEqual(got.Children, []string{"alpha2", "beta3", "inited", "key1"}) { t.Errorf("got %q children, want [alpha2,beta3,inited,key1]", got.Children) } @@ -99,7 +100,7 @@ func TestStorage(t *testing.T) { } // Check adding children. - keeper.SetStorage(ctx, types.NewStorageEntry("key1.child1", "value1child")) + keeper.SetStorage(ctx, agoric.NewKVEntry("key1.child1", "value1child")) if got := keeper.GetEntry(ctx, "key1.child1").StringValue(); got != "value1child" { t.Errorf("got %q, want %q", got, "value1child") } @@ -109,7 +110,7 @@ func TestStorage(t *testing.T) { } // Add a grandchild. - keeper.SetStorage(ctx, types.NewStorageEntry("key1.child1.grandchild1", "value1grandchild")) + keeper.SetStorage(ctx, agoric.NewKVEntry("key1.child1.grandchild1", "value1grandchild")) if got := keeper.GetEntry(ctx, "key1.child1.grandchild1").StringValue(); got != "value1grandchild" { t.Errorf("got %q, want %q", got, "value1grandchild") } @@ -119,7 +120,7 @@ func TestStorage(t *testing.T) { } // Delete the child's contents. - keeper.SetStorage(ctx, types.NewStorageEntryWithNoData("key1.child1")) + keeper.SetStorage(ctx, agoric.NewKVEntryWithNoValue("key1.child1")) if got := keeper.GetChildren(ctx, "key1"); !childrenEqual(got.Children, []string{"child1"}) { t.Errorf("got %q children, want [child1]", got.Children) } @@ -129,7 +130,7 @@ func TestStorage(t *testing.T) { } // Delete the grandchild's contents. - keeper.SetStorage(ctx, types.NewStorageEntryWithNoData("key1.child1.grandchild1")) + keeper.SetStorage(ctx, agoric.NewKVEntryWithNoValue("key1.child1.grandchild1")) if got := keeper.GetChildren(ctx, "key1.child1"); !childrenEqual(got.Children, []string{}) { t.Errorf("got %q children, want []", got.Children) } @@ -139,13 +140,13 @@ func TestStorage(t *testing.T) { } // See about deleting the parent. - keeper.SetStorage(ctx, types.NewStorageEntryWithNoData("key1")) + keeper.SetStorage(ctx, agoric.NewKVEntryWithNoValue("key1")) if got := keeper.GetChildren(ctx, ""); !childrenEqual(got.Children, []string{"alpha2", "beta3", "inited"}) { t.Errorf("got %q children, want [alpha2,beta3,inited]", got.Children) } // Do a deep set. - keeper.SetStorage(ctx, types.NewStorageEntry("key2.child2.grandchild2", "value2grandchild")) + keeper.SetStorage(ctx, agoric.NewKVEntry("key2.child2.grandchild2", "value2grandchild")) if got := keeper.GetChildren(ctx, ""); !childrenEqual(got.Children, []string{"alpha2", "beta3", "inited", "key2"}) { t.Errorf("got %q children, want [alpha2,beta3,inited,key2]", got.Children) } @@ -157,7 +158,7 @@ func TestStorage(t *testing.T) { } // Do another deep set. - keeper.SetStorage(ctx, types.NewStorageEntry("key2.child2.grandchild2a", "value2grandchilda")) + keeper.SetStorage(ctx, agoric.NewKVEntry("key2.child2.grandchild2a", "value2grandchilda")) if got := keeper.GetChildren(ctx, "key2.child2"); !childrenEqual(got.Children, []string{"grandchild2", "grandchild2a"}) { t.Errorf("got %q children, want [grandchild2,grandchild2a]", got.Children) } @@ -191,12 +192,12 @@ func TestStorageNotify(t *testing.T) { tk := makeTestKit() ctx, keeper := tk.ctx, tk.vstorageKeeper - keeper.SetStorageAndNotify(ctx, types.NewStorageEntry("notify.noLegacy", "noLegacyValue")) - keeper.LegacySetStorageAndNotify(ctx, types.NewStorageEntry("notify.legacy", "legacyValue")) - keeper.SetStorageAndNotify(ctx, types.NewStorageEntry("notify.noLegacy2", "noLegacyValue2")) - keeper.SetStorageAndNotify(ctx, types.NewStorageEntry("notify.legacy2", "legacyValue2")) - keeper.LegacySetStorageAndNotify(ctx, types.NewStorageEntry("notify.legacy2", "legacyValue2b")) - keeper.SetStorageAndNotify(ctx, types.NewStorageEntry("notify.noLegacy2", "noLegacyValue2b")) + keeper.SetStorageAndNotify(ctx, agoric.NewKVEntry("notify.noLegacy", "noLegacyValue")) + keeper.LegacySetStorageAndNotify(ctx, agoric.NewKVEntry("notify.legacy", "legacyValue")) + keeper.SetStorageAndNotify(ctx, agoric.NewKVEntry("notify.noLegacy2", "noLegacyValue2")) + keeper.SetStorageAndNotify(ctx, agoric.NewKVEntry("notify.legacy2", "legacyValue2")) + keeper.LegacySetStorageAndNotify(ctx, agoric.NewKVEntry("notify.legacy2", "legacyValue2b")) + keeper.SetStorageAndNotify(ctx, agoric.NewKVEntry("notify.noLegacy2", "noLegacyValue2b")) // Check the batched events. expectedBeforeFlushEvents := sdk.Events{} diff --git a/golang/cosmos/x/vstorage/keeper/querier.go b/golang/cosmos/x/vstorage/keeper/querier.go index 698d61fac3b..44a8a8d40b4 100644 --- a/golang/cosmos/x/vstorage/keeper/querier.go +++ b/golang/cosmos/x/vstorage/keeper/querier.go @@ -35,7 +35,7 @@ func NewQuerier(keeper Keeper, legacyQuerierCdc *codec.LegacyAmino) sdk.Querier // nolint: unparam func queryData(ctx sdk.Context, path string, req abci.RequestQuery, keeper Keeper, legacyQuerierCdc *codec.LegacyAmino) (res []byte, err error) { entry := keeper.GetEntry(ctx, path) - if !entry.HasData() { + if !entry.HasValue() { return nil, sdkerrors.Wrap(sdkerrors.ErrUnknownRequest, "could not get vstorage path") } diff --git a/golang/cosmos/x/vstorage/types/types.go b/golang/cosmos/x/vstorage/types/types.go index b8cb3174c16..c65fe0948ea 100644 --- a/golang/cosmos/x/vstorage/types/types.go +++ b/golang/cosmos/x/vstorage/types/types.go @@ -1,10 +1,5 @@ package types -import ( - "encoding/json" - "fmt" -) - func NewData() *Data { return &Data{} } @@ -12,64 +7,3 @@ func NewData() *Data { func NewChildren() *Children { return &Children{} } - -type StorageEntry struct { - path string - value *string -} - -func NewStorageEntry(path string, value string) StorageEntry { - return StorageEntry{path, &value} -} - -func NewStorageEntryWithNoData(path string) StorageEntry { - return StorageEntry{path, nil} -} - -// UnmarshalStorageEntry interprets its argument as a [key: string, value?: string | null] -// JSON array and returns a corresponding StorageEntry. -// The key must be a string, and the value (if present) must be a string or null. -func UnmarshalStorageEntry(msg json.RawMessage) (entry StorageEntry, err error) { - var generic [2]interface{} - err = json.Unmarshal(msg, &generic) - - if err != nil { - return - } - - path, ok := generic[0].(string) - if !ok { - err = fmt.Errorf("invalid storage entry path: %q", generic[0]) - return - } - - switch generic[1].(type) { - case string: - entry = NewStorageEntry(path, generic[1].(string)) - case nil: - entry = NewStorageEntryWithNoData(path) - default: - err = fmt.Errorf("invalid storage entry value: %q", generic[1]) - } - return -} - -func (se StorageEntry) HasData() bool { - return se.value != nil -} - -func (se StorageEntry) Path() string { - return se.path -} - -func (se StorageEntry) Value() *string { - return se.value -} - -func (se StorageEntry) StringValue() string { - if se.value != nil { - return *se.value - } else { - return "" - } -} diff --git a/golang/cosmos/x/vstorage/vstorage.go b/golang/cosmos/x/vstorage/vstorage.go index b2120948a30..3df0da359de 100644 --- a/golang/cosmos/x/vstorage/vstorage.go +++ b/golang/cosmos/x/vstorage/vstorage.go @@ -7,8 +7,8 @@ import ( sdk "github.com/cosmos/cosmos-sdk/types" + agoric "github.com/Agoric/agoric-sdk/golang/cosmos/types" "github.com/Agoric/agoric-sdk/golang/cosmos/vm" - "github.com/Agoric/agoric-sdk/golang/cosmos/x/vstorage/types" ) type vstorageHandler struct { @@ -69,8 +69,8 @@ func (sh vstorageHandler) Receive(cctx *vm.ControllerContext, str string) (ret s switch msg.Method { case "set": for _, arg := range msg.Args { - var entry types.StorageEntry - entry, err = types.UnmarshalStorageEntry(arg) + var entry agoric.KVEntry + err = json.Unmarshal(arg, &entry) if err != nil { return } @@ -83,8 +83,8 @@ func (sh vstorageHandler) Receive(cctx *vm.ControllerContext, str string) (ret s // FIXME: Use just "set" and remove this case. case "legacySet": for _, arg := range msg.Args { - var entry types.StorageEntry - entry, err = types.UnmarshalStorageEntry(arg) + var entry agoric.KVEntry + err = json.Unmarshal(arg, &entry) if err != nil { return } @@ -95,8 +95,8 @@ func (sh vstorageHandler) Receive(cctx *vm.ControllerContext, str string) (ret s case "setWithoutNotify": for _, arg := range msg.Args { - var entry types.StorageEntry - entry, err = types.UnmarshalStorageEntry(arg) + var entry agoric.KVEntry + err = json.Unmarshal(arg, &entry) if err != nil { return } @@ -106,16 +106,16 @@ func (sh vstorageHandler) Receive(cctx *vm.ControllerContext, str string) (ret s case "append": for _, arg := range msg.Args { - var entry types.StorageEntry - entry, err = types.UnmarshalStorageEntry(arg) + var entry agoric.KVEntry + err = json.Unmarshal(arg, &entry) if err != nil { return } - if !entry.HasData() { - err = fmt.Errorf("no value for append entry with path: %q", entry.Path()) + if !entry.HasValue() { + err = fmt.Errorf("no value for append entry with path: %q", entry.Key()) return } - err = keeper.AppendStorageValueAndNotify(cctx.Context, entry.Path(), entry.StringValue()) + err = keeper.AppendStorageValueAndNotify(cctx.Context, entry.Key(), entry.StringValue()) if err != nil { return } @@ -131,10 +131,7 @@ func (sh vstorageHandler) Receive(cctx *vm.ControllerContext, str string) (ret s } entry := keeper.GetEntry(cctx.Context, path) - if !entry.HasData() { - return "null", nil - } - bz, err := json.Marshal(entry.StringValue()) + bz, err := json.Marshal(entry.Value()) if err != nil { return "", err } @@ -194,13 +191,13 @@ func (sh vstorageHandler) Receive(cctx *vm.ControllerContext, str string) (ret s return } children := keeper.GetChildren(cctx.Context, path) - entries := make([][]interface{}, len(children.Children)) + entries := make([]agoric.KVEntry, len(children.Children)) for i, child := range children.Children { entry := keeper.GetEntry(cctx.Context, fmt.Sprintf("%s.%s", path, child)) - if !entry.HasData() { - entries[i] = []interface{}{child} + if !entry.HasValue() { + entries[i] = agoric.NewKVEntryWithNoValue(child) } else { - entries[i] = []interface{}{child, entry.Value()} + entries[i] = agoric.NewKVEntry(child, entry.StringValue()) } } bytes, err := json.Marshal(entries) @@ -216,9 +213,9 @@ func (sh vstorageHandler) Receive(cctx *vm.ControllerContext, str string) (ret s return } children := keeper.GetChildren(cctx.Context, path) - vals := make([]string, len(children.Children)) + vals := make([]*string, len(children.Children)) for i, child := range children.Children { - vals[i] = keeper.GetEntry(cctx.Context, fmt.Sprintf("%s.%s", path, child)).StringValue() + vals[i] = keeper.GetEntry(cctx.Context, fmt.Sprintf("%s.%s", path, child)).Value() } bytes, err := json.Marshal(vals) if err != nil { diff --git a/golang/cosmos/x/vstorage/vstorage_test.go b/golang/cosmos/x/vstorage/vstorage_test.go index 02e478aea09..5817e1ade25 100644 --- a/golang/cosmos/x/vstorage/vstorage_test.go +++ b/golang/cosmos/x/vstorage/vstorage_test.go @@ -70,10 +70,10 @@ func TestGetAndHas(t *testing.T) { kit := makeTestKit() keeper, handler, ctx, cctx := kit.keeper, kit.handler, kit.ctx, kit.cctx - keeper.SetStorage(ctx, types.NewStorageEntry("foo", "bar")) - keeper.SetStorage(ctx, types.NewStorageEntry("empty", "")) - keeper.SetStorage(ctx, types.NewStorageEntry("top.empty-non-terminal.leaf", "")) - keeper.SetStorage(ctx, types.NewStorageEntryWithNoData("top.empty-non-terminal")) + keeper.SetStorage(ctx, agorictypes.NewKVEntry("foo", "bar")) + keeper.SetStorage(ctx, agorictypes.NewKVEntry("empty", "")) + keeper.SetStorage(ctx, agorictypes.NewKVEntry("top.empty-non-terminal.leaf", "")) + keeper.SetStorage(ctx, agorictypes.NewKVEntryWithNoValue("top.empty-non-terminal")) type testCase struct { label string @@ -153,7 +153,7 @@ func doTestSet(t *testing.T, method string, expectNotify bool) { // TODO: Fully validate input before making changes // args: []interface{}{[]string{"foo", "X"}, []interface{}{42, "new"}}, args: []interface{}{[]interface{}{42, "new"}}, - errContains: ptr("path"), + errContains: ptr("json"), }, {label: "non-string value", // TODO: Fully validate input before making changes @@ -259,15 +259,15 @@ func TestEntries(t *testing.T) { kit := makeTestKit() keeper, handler, ctx, cctx := kit.keeper, kit.handler, kit.ctx, kit.cctx - keeper.SetStorage(ctx, types.NewStorageEntry("key1", "value1")) - keeper.SetStorage(ctx, types.NewStorageEntry("key1.child1.grandchild1", "value1grandchild")) - keeper.SetStorage(ctx, types.NewStorageEntryWithNoData("key1.child1.grandchild2")) - keeper.SetStorage(ctx, types.NewStorageEntryWithNoData("key1.child1")) - keeper.SetStorage(ctx, types.NewStorageEntry("key1.child1.empty-non-terminal.leaf", "")) - keeper.SetStorage(ctx, types.NewStorageEntryWithNoData("key2")) - keeper.SetStorage(ctx, types.NewStorageEntryWithNoData("key2.child2")) - keeper.SetStorage(ctx, types.NewStorageEntry("key2.child2.grandchild2", "value2grandchild")) - keeper.SetStorage(ctx, types.NewStorageEntry("key2.child2.grandchild2a", "value2grandchilda")) + keeper.SetStorage(ctx, agorictypes.NewKVEntry("key1", "value1")) + keeper.SetStorage(ctx, agorictypes.NewKVEntry("key1.child1.grandchild1", "value1grandchild")) + keeper.SetStorage(ctx, agorictypes.NewKVEntryWithNoValue("key1.child1.grandchild2")) + keeper.SetStorage(ctx, agorictypes.NewKVEntryWithNoValue("key1.child1")) + keeper.SetStorage(ctx, agorictypes.NewKVEntry("key1.child1.empty-non-terminal.leaf", "")) + keeper.SetStorage(ctx, agorictypes.NewKVEntryWithNoValue("key2")) + keeper.SetStorage(ctx, agorictypes.NewKVEntryWithNoValue("key2.child2")) + keeper.SetStorage(ctx, agorictypes.NewKVEntry("key2.child2.grandchild2", "value2grandchild")) + keeper.SetStorage(ctx, agorictypes.NewKVEntry("key2.child2.grandchild2a", "value2grandchilda")) type testCase struct { path string