Skip to content

Commit 39f459a

Browse files
authored
Handle reuploads (#1049)
Update the mirror to permit a small number of bundles to be re-uploaded by a client, as permitted by the tlog-mirror spec, and updates the POSIX mirror storage to write bundles in an idempotent fashion.
1 parent 4b91127 commit 39f459a

5 files changed

Lines changed: 211 additions & 6 deletions

File tree

mirror_lifecycle.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ var (
5454
ErrInvalidProof = errors.New("invalid proof")
5555
)
5656

57+
// maxExcessEntries is the maximum number of "excess" entries that can be
58+
// re-uploaded. This is intended to prevent mirror re-uploads from going too far
59+
// back in time.
60+
const maxExcessEntries = 2048
61+
5762
// MirrorOptions holds mirror lifecycle settings for all storage implementations.
5863
type MirrorOptions struct {
5964
signer note.Signer
@@ -113,6 +118,7 @@ func (o *MirrorOptions) valid() error {
113118
type MirrorWriter interface {
114119
// IntegrateBundles integrates bundles of log entries, starting at the given bundle index, into the local tree.
115120
// Bundles are _always_ aligned on bundle boundaries.
121+
// Implementations MUST NOT overwrite entries that are already integrated into the tree.
116122
//
117123
// Returns the size of the tree and its new root hash if successful.
118124
// If the provided iterator yields an error, the MirrorWriter MUST return it either directly, or wrapped so the caller can identify it.
@@ -224,11 +230,10 @@ func (mt *MirrorTarget) AddEntries(ctx context.Context, uploadStart, uploadEnd u
224230
// - upload_start:
225231
// * MUST NOT be greater than the mirror's next expected entry index.
226232
// * MUST NOT be too far below the mirror's next entry index.
227-
if (uploadStart == 0 && uploadEnd == 0 && !userTicketValid) ||
233+
if excessEntries := min(uploadEnd, nextEntry) - uploadStart; (uploadStart == 0 && uploadEnd == 0 && !userTicketValid) ||
228234
(uploadEnd != pendingSize || uploadEnd < nextEntry) ||
229-
(uploadStart > nextEntry) {
230-
// TODO(al): add flexibility about re-writing some entries
231-
slog.ErrorContext(ctx, "Returning conflict", slog.Uint64("nextEntry", nextEntry), slog.Uint64("pendingSize", pendingSize), slog.Uint64("uploadStart", uploadStart), slog.Uint64("uploadEnd", uploadEnd))
235+
(uploadStart > nextEntry || excessEntries > maxExcessEntries) {
236+
slog.ErrorContext(ctx, "Returning conflict", slog.Bool("ticket_valid", userTicketValid), slog.Uint64("next_entry", nextEntry), slog.Uint64("pending_size", pendingSize), slog.Uint64("upload_start", uploadStart), slog.Uint64("upload_end", uploadEnd), slog.Uint64("excess_entries", excessEntries))
232237
return nextEntry, pendingSize, ticketBytes, nil, ErrConflict
233238
}
234239

@@ -269,6 +274,7 @@ func (mt *MirrorTarget) bundleIterator(ctx context.Context, next func() (*Mirror
269274
crf := compact.RangeFactory{Hash: rfc6962.DefaultHasher.HashChildren}
270275
return func(yield func(*api.EntryBundle, error) bool) {
271276
// Check for unaligned upload start, and fetch entries from the start of the bundle to use to pad.
277+
// This is necessary for the subtree proof for such an unaligned first bundle to validate.
272278
var padEntries [][]byte
273279
if p := start % layout.EntryBundleWidth; p != 0 {
274280
// non-aligned starting bundle

mirror_lifecycle_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -629,3 +629,82 @@ func TestMirrorTarget_AddEntries_NoPendingCheckpoint(t *testing.T) {
629629
t.Errorf("got error %v, want ErrNoPendingCheckpoint", err)
630630
}
631631
}
632+
633+
func TestMirrorTarget_AddEntries_UploadStartConflicts(t *testing.T) {
634+
const (
635+
testIntegratedSize = uint64(3072)
636+
testUploadEnd = uint64(4096)
637+
)
638+
639+
ctx := t.Context()
640+
testPendingCPCustom := mustSignCP(testPendingCPOrigin, testUploadEnd, testPendingCPRoot, testLogSigner)
641+
642+
for _, tc := range []struct {
643+
name string
644+
uploadStart uint64
645+
wantConflict bool
646+
}{
647+
{
648+
name: "exact start",
649+
uploadStart: testIntegratedSize,
650+
wantConflict: false,
651+
},
652+
{
653+
name: "re-upload within limit",
654+
uploadStart: testIntegratedSize - maxExcessEntries,
655+
wantConflict: false,
656+
},
657+
{
658+
name: "re-upload too far back",
659+
uploadStart: testIntegratedSize - maxExcessEntries - 1,
660+
wantConflict: true,
661+
},
662+
{
663+
name: "uploadStart > nextEntry",
664+
uploadStart: testIntegratedSize + 256,
665+
wantConflict: true,
666+
},
667+
} {
668+
t.Run(tc.name, func(t *testing.T) {
669+
mt := &MirrorTarget{
670+
logVerifier: testLogVerifier,
671+
signer: testMirrorSigner,
672+
writer: &fakeMirrorWriter{
673+
integrateFunc: func(ctx context.Context, fromBundleIdx uint64, bundles iter.Seq2[*api.EntryBundle, error]) (uint64, []byte, error) {
674+
for _, err := range bundles {
675+
if err != nil {
676+
return 0, nil, err
677+
}
678+
}
679+
pendingCPRoot, err := base64.StdEncoding.DecodeString(testPendingCPRoot)
680+
return testUploadEnd, pendingCPRoot, err
681+
},
682+
updateCheckpointFunc: func(ctx context.Context, f func(oldCP []byte) (newCP []byte, err error)) error {
683+
return nil
684+
},
685+
},
686+
reader: &fakeLogReader{
687+
sizeFunc: func(ctx context.Context) (uint64, error) { return testIntegratedSize, nil },
688+
},
689+
cpSource: func(ctx context.Context) ([]byte, error) { return []byte(testPendingCPCustom), nil },
690+
}
691+
692+
validTicket, err := mt.seal([]byte(testPendingCPCustom))
693+
if err != nil {
694+
t.Fatalf("seal failed: %v", err)
695+
}
696+
697+
_, _, _, _, err = mt.AddEntries(ctx, tc.uploadStart, testUploadEnd, validTicket, func() (*MirrorPackage, error) {
698+
return nil, io.EOF
699+
})
700+
701+
if gotConflict := errors.Is(err, ErrConflict); gotConflict != tc.wantConflict {
702+
t.Fatalf("AddEntries got conflict %v, want conflict %v (err: %v)", gotConflict, tc.wantConflict, err)
703+
}
704+
if !tc.wantConflict && err != nil {
705+
t.Fatalf("AddEntries unexpected error: %v", err)
706+
}
707+
708+
})
709+
}
710+
}

storage/posix/file_ops.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package posix
1616

1717
import (
18+
"bytes"
1819
"context"
1920
"errors"
2021
"fmt"
@@ -127,13 +128,34 @@ func createEx(name string, d []byte) error {
127128
}()
128129

129130
if err := os.Link(tmpName, name); err != nil {
130-
// Wrap the error here because we need to know if it's os.ErrExists at higher levels.
131+
// Wrap the error here because we need to know if it's os.ErrExist at higher levels.
131132
return fmt.Errorf("failed to link temporary file to target %q: %w", name, err)
132133
}
133134
return nil
134135
})
135136
}
136137

138+
// createIdempotent atomically creates a file at the given path containing the provided data, and syncs the
139+
// directory containing the newly created file.
140+
//
141+
// Returns an error if a file already exists at the specified location and it contents are not identical to
142+
// the provided data, or if it's unable to fully write the data & close the file.
143+
func createIdempotent(name string, d []byte) error {
144+
if err := createEx(name, d); err != nil {
145+
if !errors.Is(err, os.ErrExist) {
146+
return err
147+
}
148+
e, err := os.ReadFile(name)
149+
if err != nil {
150+
return fmt.Errorf("failed to read existing file: %w", err)
151+
}
152+
if !bytes.Equal(e, d) {
153+
return fmt.Errorf("existing file %q has different contents", name)
154+
}
155+
}
156+
return nil
157+
}
158+
137159
// overwrite atomically creates/overwrites a file at the given path containing the provided data, and syncs
138160
// the directory containing the overwritten/created file.
139161
func overwrite(name string, d []byte) error {

storage/posix/files.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,17 @@ func (lrs *logResourceStorage) writeBundle(ctx context.Context, index uint64, pa
528528
})
529529
}
530530

531+
// writeBundleIdempotent takes care of writing out the serialised entry bundle file if it doesn't already exist.
532+
func (lrs *logResourceStorage) writeBundleIdempotent(ctx context.Context, index uint64, partial uint8, bundle []byte) error {
533+
return otel.TraceErr(ctx, "tessera.storage.posix.writeBundleIdempotent", tracer, func(ctx context.Context, span trace.Span) error {
534+
bf := lrs.entriesPath(index, partial)
535+
if err := lrs.s.createIdempotent(bf, bundle); err != nil {
536+
return err
537+
}
538+
return nil
539+
})
540+
}
541+
531542
// initialise ensures that the storage location is valid by loading the checkpoint from this location, or
532543
// creating a zero-sized one if it doesn't already exist.
533544
func (a *appender) initialise(ctx context.Context) error {
@@ -915,6 +926,12 @@ func (s *Storage) createOverwrite(p string, d []byte) error {
915926
return overwrite(filepath.Join(s.cfg.Path, p), d)
916927
}
917928

929+
// createIdempotent atomically creates a file at the given path with the provided data, if it doesn't already exist.
930+
// Returns an error if the target path already exists but contains different data.
931+
func (s *Storage) createIdempotent(p string, d []byte) error {
932+
return createIdempotent(filepath.Join(s.cfg.Path, p), d)
933+
}
934+
918935
func (s *Storage) readAll(p string) ([]byte, error) {
919936
p = filepath.Join(s.cfg.Path, p)
920937
return os.ReadFile(p)
@@ -1236,7 +1253,7 @@ func (m *MirrorWriter) IntegrateBundles(ctx context.Context, bundleIdx uint64, b
12361253
}
12371254

12381255
// Now write the bundle out.
1239-
if err := m.logStorage.writeBundle(ctx, bundleIdx, uint8(p), bundleData); err != nil {
1256+
if err := m.logStorage.writeBundleIdempotent(ctx, bundleIdx, uint8(p), bundleData); err != nil {
12401257
return 0, nil, err
12411258
}
12421259

storage/posix/files_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,3 +676,84 @@ func TestOverwrite_CleanupOnRenameFailure(t *testing.T) {
676676
}
677677
}
678678
}
679+
680+
func TestMirrorWriter_IntegrateBundles(t *testing.T) {
681+
baseBundle := &api.EntryBundle{
682+
Entries: [][]byte{[]byte("entry1"), []byte("entry2")},
683+
}
684+
685+
bundlesFunc := func(b *api.EntryBundle) func(yield func(*api.EntryBundle, error) bool) {
686+
return func(yield func(*api.EntryBundle, error) bool) {
687+
yield(b, nil)
688+
}
689+
}
690+
691+
for _, tc := range []struct {
692+
name string
693+
bundleIdx uint64
694+
bundle *api.EntryBundle
695+
expectedSize uint64
696+
wantErr bool
697+
errSubstring string
698+
}{
699+
{
700+
name: "integrate bundle with common prefix (extend)",
701+
bundleIdx: 0,
702+
bundle: &api.EntryBundle{Entries: append(append([][]byte{}, baseBundle.Entries...), []byte("entry3"))},
703+
expectedSize: 3,
704+
},
705+
{
706+
name: "re-integrate base bundle (idempotency)",
707+
bundleIdx: 0,
708+
bundle: baseBundle,
709+
expectedSize: 2,
710+
},
711+
{
712+
name: "re-integrate modified basebundle (conflict error)",
713+
bundleIdx: 0,
714+
bundle: &api.EntryBundle{Entries: append(append([][]byte{}, baseBundle.Entries[:len(baseBundle.Entries)-1]...), []byte("entry2_modified"))},
715+
wantErr: true,
716+
errSubstring: "different contents",
717+
},
718+
} {
719+
t.Run(tc.name, func(t *testing.T) {
720+
s := &Storage{
721+
cfg: Config{
722+
HTTPClient: http.DefaultClient,
723+
Path: t.TempDir(),
724+
},
725+
}
726+
opts := tessera.NewMirrorOptions()
727+
ctx := t.Context()
728+
mw, _, err := s.MirrorWriter(ctx, opts)
729+
if err != nil {
730+
t.Fatalf("MirrorWriter: %v", err)
731+
}
732+
733+
// Set up the storage with the base bundle.
734+
if size, _, err := mw.IntegrateBundles(ctx, 0, bundlesFunc(baseBundle)); err != nil {
735+
t.Fatalf("Failed to set up test, IntegrateBundles: %v", err)
736+
} else if size != uint64(len(baseBundle.Entries)) {
737+
t.Fatalf("Failed to set up test, expected size %d, got %d", len(baseBundle.Entries), size)
738+
}
739+
740+
// Now run the test case.
741+
size, _, err := mw.IntegrateBundles(ctx, tc.bundleIdx, bundlesFunc(tc.bundle))
742+
if tc.wantErr {
743+
if err == nil {
744+
t.Fatalf("expected error containing %q, got nil", tc.errSubstring)
745+
}
746+
if !strings.Contains(err.Error(), tc.errSubstring) {
747+
t.Fatalf("expected error containing %q, got %v", tc.errSubstring, err)
748+
}
749+
return
750+
}
751+
if err != nil {
752+
t.Fatalf("unexpected error: %v", err)
753+
}
754+
if size != tc.expectedSize {
755+
t.Fatalf("got size %d, want %d", size, tc.expectedSize)
756+
}
757+
})
758+
}
759+
}

0 commit comments

Comments
 (0)