Skip to content

Commit 2bf4268

Browse files
authored
Improve conflict handling in mirror client (#1055)
* Replace initial status query with state tracking and improve conflict handling in mirror client * Address comment
1 parent fb4eb6c commit 2bf4268

2 files changed

Lines changed: 95 additions & 56 deletions

File tree

client/mirror/client.go

Lines changed: 63 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@ import (
2727
"errors"
2828
"fmt"
2929
"io"
30+
"log/slog"
3031
"net/http"
3132
"net/url"
3233
"strconv"
34+
"strings"
3335

3436
"github.com/transparency-dev/tessera/api/layout"
3537
"github.com/transparency-dev/tessera/client"
@@ -128,6 +130,11 @@ func (o *Options) validate() error {
128130
// TODO(roger2hk): Should multiple mirrors in one client be supported?
129131
type Client struct {
130132
opts *Options
133+
134+
// TODO(roger2hk): Add a mutex just in case client is used across multiple goroutines.
135+
// State of the mirror.
136+
oldSize uint64
137+
ticket []byte
131138
}
132139

133140
// NewClient creates a new Client with the provided options.
@@ -346,7 +353,19 @@ func (c *Client) streamEntries(ctx context.Context, uploadStart, uploadEnd uint6
346353
}
347354

348355
// pushCheckpoint sends a new checkpoint and its consistency proof to the mirror's /add-checkpoint endpoint.
349-
func (c *Client) pushCheckpoint(ctx context.Context, oldSize uint64, proof [][]byte, checkpointRaw []byte) error {
356+
func (c *Client) pushCheckpoint(ctx context.Context, oldSize, targetSize uint64, checkpointRaw []byte) error {
357+
var proof [][]byte
358+
if oldSize > 0 {
359+
pb, err := client.NewProofBuilder(ctx, targetSize, c.opts.tileFetcher)
360+
if err != nil {
361+
return fmt.Errorf("failed to create ProofBuilder: %v", err)
362+
}
363+
proof, err = pb.ConsistencyProof(ctx, oldSize, targetSize)
364+
if err != nil {
365+
return fmt.Errorf("failed to generate consistency proof: %v", err)
366+
}
367+
}
368+
350369
reqBody := c.buildCheckpointRequestBody(oldSize, proof, checkpointRaw)
351370

352371
u, err := c.opts.mirrorURL.Parse("add-checkpoint")
@@ -369,9 +388,32 @@ func (c *Client) pushCheckpoint(ctx context.Context, oldSize uint64, proof [][]b
369388
_, _ = io.Copy(io.Discard, resp.Body)
370389
_ = resp.Body.Close()
371390
}()
391+
respBody, err := io.ReadAll(resp.Body)
392+
if err != nil {
393+
return fmt.Errorf("failed to read body from witness at %q: %v", u.String(), err)
394+
}
395+
396+
if resp.StatusCode == http.StatusConflict {
397+
// The witness MUST check that the old size matches the size of the latest checkpoint it cosigned
398+
// for the checkpoint's origin (or zero if it never cosigned a checkpoint for that origin).
399+
// If it doesn't match, the witness MUST respond with a "409 Conflict" HTTP status code.
400+
// The response body MUST consist of the tree size of the latest cosigned checkpoint in decimal,
401+
// followed by a newline (U+000A). The response MUST have a Content-Type of text/x.tlog.size
402+
if resp.Header.Get("Content-Type") == "text/x.tlog.size" {
403+
bodyStr := strings.TrimSpace(string(respBody))
404+
newWitSize, err := strconv.ParseUint(bodyStr, 10, 64)
405+
if err != nil {
406+
return fmt.Errorf("witness at %q replied with x.tlog.size but body %q could not be parsed as decimal", u.String(), bodyStr)
407+
}
408+
slog.InfoContext(ctx, "Witness replied with x.tlog.size different than our hint", slog.String("url", u.String()), slog.Uint64("reply", newWitSize), slog.Uint64("hinted", oldSize))
409+
410+
return ErrConflict{
411+
PendingSize: newWitSize,
412+
}
413+
}
414+
}
372415

373416
if resp.StatusCode != http.StatusOK {
374-
respBody, _ := io.ReadAll(resp.Body)
375417
return fmt.Errorf("add-checkpoint failed with status %d: %s", resp.StatusCode, string(respBody))
376418
}
377419

@@ -404,47 +446,31 @@ func (c *Client) buildCheckpointRequestBody(oldSize uint64, proof [][]byte, chec
404446
// Sync synchronizes all entries and the checkpoint from the source log to the mirror
405447
// up to the specified targetSize. It returns the mirror's cosignatures on success.
406448
func (c *Client) Sync(ctx context.Context, targetCheckpointRaw []byte, targetSize uint64) ([]byte, error) {
407-
// Get the mirror's current state by querying it with upload_start=0, upload_end=0 (guaranteed to conflict).
408-
_, err := c.pushEntries(ctx, 0, 0, nil)
409-
if err == nil {
410-
return nil, errors.New("unexpected success when querying mirror status with size 0")
411-
}
412-
413449
var conflict ErrConflict
414-
if !errors.As(err, &conflict) {
415-
return nil, fmt.Errorf("failed to retrieve mirror status: %v", err)
416-
}
450+
nextEntry := c.oldSize
417451

418-
oldSize := conflict.PendingSize
419-
nextEntry := conflict.NextEntry
420-
ticket := conflict.Ticket
421-
422-
// If the mirror's pending checkpoint is smaller than target size, update it first.
423-
if oldSize < targetSize {
424-
var proof [][]byte
425-
if oldSize > 0 {
426-
pb, err := client.NewProofBuilder(ctx, targetSize, c.opts.tileFetcher)
427-
if err != nil {
428-
return nil, fmt.Errorf("failed to create ProofBuilder: %v", err)
429-
}
430-
proof, err = pb.ConsistencyProof(ctx, oldSize, targetSize)
431-
if err != nil {
432-
return nil, fmt.Errorf("failed to generate consistency proof: %v", err)
452+
// Push the checkpoint with the old size (0 if not provided).
453+
for c.oldSize < targetSize {
454+
err := c.pushCheckpoint(ctx, c.oldSize, targetSize, targetCheckpointRaw)
455+
if err != nil {
456+
if !errors.As(err, &conflict) {
457+
return nil, fmt.Errorf("failed to push checkpoint: %v", err)
433458
}
459+
c.oldSize = max(c.oldSize, conflict.PendingSize)
460+
continue
434461
}
435462

436-
if err := c.pushCheckpoint(ctx, oldSize, proof, targetCheckpointRaw); err != nil {
437-
return nil, fmt.Errorf("failed to push new checkpoint: %v", err)
438-
}
463+
nextEntry = c.oldSize
464+
c.oldSize = targetSize
439465
}
440466

441467
// Push entries up to target size in packages of 256, handling concurrent conflicts and retries.
442-
uploadEnd := max(targetSize, oldSize)
468+
uploadEnd := max(targetSize, c.oldSize)
443469

444470
var cosigs []byte
445471
for {
446472
var err error
447-
cosigs, err = c.pushEntries(ctx, nextEntry, uploadEnd, ticket)
473+
cosigs, err = c.pushEntries(ctx, nextEntry, uploadEnd, c.ticket)
448474
if err == nil {
449475
break
450476
}
@@ -453,11 +479,14 @@ func (c *Client) Sync(ctx context.Context, targetCheckpointRaw []byte, targetSiz
453479
return nil, fmt.Errorf("sync failed during entry upload: %v", err)
454480
}
455481

456-
nextEntry = conflict.NextEntry
457-
ticket = conflict.Ticket
458482
uploadEnd = conflict.PendingSize
483+
nextEntry = conflict.NextEntry
484+
c.ticket = conflict.Ticket
485+
c.oldSize = conflict.PendingSize
459486

460-
// TODO(roger2hk): Handle the case uploadEnd is updated to a value smaller than targetSize.
487+
if uploadEnd < targetSize {
488+
return nil, fmt.Errorf("mirror size reverted to %d, which is smaller than target %d", uploadEnd, targetSize)
489+
}
461490
}
462491

463492
return cosigs, nil

client/mirror/client_test.go

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -633,8 +633,9 @@ func (fm *fakeMirror) ServeHTTP(w http.ResponseWriter, r *http.Request) {
633633
return
634634
}
635635
if oldSize != fm.initialPendingSize {
636-
fm.t.Errorf("oldSize = %d, want %d", oldSize, fm.initialPendingSize)
637-
w.WriteHeader(http.StatusBadRequest)
636+
w.Header().Set("Content-Type", "text/x.tlog.size")
637+
w.WriteHeader(http.StatusConflict)
638+
_, _ = fmt.Fprintf(w, "%d\n", fm.initialPendingSize)
638639
return
639640
}
640641
if oldSize > 0 && len(headerLines) <= 1 {
@@ -665,6 +666,14 @@ func (fm *fakeMirror) ServeHTTP(w http.ResponseWriter, r *http.Request) {
665666
func TestSync(t *testing.T) {
666667
origin := "test-origin"
667668
fm := newFakeMirror(t, origin)
669+
fm.addEntriesExpectations = []addEntriesExpectation{
670+
{
671+
start: 0,
672+
end: uint64(fm.numEntries),
673+
ticket: nil,
674+
status: http.StatusOK,
675+
},
676+
}
668677
server := httptest.NewServer(fm)
669678
defer server.Close()
670679

@@ -731,20 +740,6 @@ func TestSync_ErrorsAndEdgeCases(t *testing.T) {
731740
tileFetcher client.TileFetcherFunc
732741
wantErr string
733742
}{
734-
{
735-
desc: "initial status query returns unexpected success",
736-
initialPendingSize: 0,
737-
initialNextEntry: 0,
738-
initialStatus: http.StatusOK,
739-
wantErr: "unexpected success when querying mirror status with size 0",
740-
},
741-
{
742-
desc: "initial status query returns generic error",
743-
initialPendingSize: 0,
744-
initialNextEntry: 0,
745-
initialStatus: http.StatusInternalServerError,
746-
wantErr: "failed to retrieve mirror status",
747-
},
748743
{
749744
desc: "consistency proof fails",
750745
initialPendingSize: 1,
@@ -768,7 +763,7 @@ func TestSync_ErrorsAndEdgeCases(t *testing.T) {
768763
{
769764
start: 1,
770765
end: 5,
771-
ticket: []byte("ticket-value"),
766+
ticket: nil,
772767
status: http.StatusOK,
773768
},
774769
},
@@ -784,15 +779,15 @@ func TestSync_ErrorsAndEdgeCases(t *testing.T) {
784779
}
785780
return nil, fmt.Errorf("unexpected tile request: level=%d, index=%d, p=%d", level, index, p)
786781
},
787-
wantErr: "failed to push new checkpoint",
782+
wantErr: "failed to push checkpoint",
788783
},
789784
{
790785
desc: "entry upload fails with generic error",
791786
addEntriesExpectations: []addEntriesExpectation{
792787
{
793788
start: 0,
794789
end: 5,
795-
ticket: []byte("ticket-value"),
790+
ticket: nil,
796791
status: http.StatusInternalServerError,
797792
},
798793
},
@@ -806,9 +801,9 @@ func TestSync_ErrorsAndEdgeCases(t *testing.T) {
806801
{
807802
start: 0,
808803
end: 5,
809-
ticket: []byte("ticket-value"),
804+
ticket: nil,
810805
status: http.StatusConflict,
811-
body: "5\n2\ndGlja2V0LW5ldw==\n", // pending size = 5, next entry = 2, ticket = "ticket-new"
806+
body: "5\n2\ndGlja2V0LW5ldw==\n",
812807
},
813808
{
814809
start: 2,
@@ -819,7 +814,7 @@ func TestSync_ErrorsAndEdgeCases(t *testing.T) {
819814
},
820815
},
821816
{
822-
desc: "initial status query returns no pending checkpoint (bootstrap)",
817+
desc: "bootstrap when mirror has no pending checkpoint",
823818
initialPendingSize: 0,
824819
initialNextEntry: 0,
825820
initialStatus: http.StatusUnprocessableEntity,
@@ -832,6 +827,21 @@ func TestSync_ErrorsAndEdgeCases(t *testing.T) {
832827
},
833828
},
834829
},
830+
{
831+
desc: "mirror size reverts during entry upload",
832+
initialPendingSize: 0,
833+
initialNextEntry: 0,
834+
addEntriesExpectations: []addEntriesExpectation{
835+
{
836+
start: 0,
837+
end: 5,
838+
ticket: nil,
839+
status: http.StatusConflict,
840+
body: "2\n2\ndGlja2V0LW5ldw==\n",
841+
},
842+
},
843+
wantErr: "mirror size reverted to 2, which is smaller than target 5",
844+
},
835845
}
836846

837847
for _, tc := range tests {

0 commit comments

Comments
 (0)