Make delivery asset outcomes disjoint

This commit is contained in:
2026-10-06 17:54:22 +00:00
parent 0eec4a30ed
commit c9afc8dd12
10 changed files with 322 additions and 32 deletions

View File

@@ -33,13 +33,21 @@ type AssetIdentity struct {
SHA256 string
}
// UploadResult reports the current asset observations from one upload attempt.
// Each identity appears in at most one category; early failures can leave some
// declared assets unreported.
type UploadResult struct {
State DeliveryState
Matching []AssetIdentity
Uploaded []AssetIdentity
Pending []AssetIdentity
State DeliveryState
// Matching contains existing attachments with the declared content.
Matching []AssetIdentity
// Uploaded contains assets whose upload was confirmed during this invocation.
Uploaded []AssetIdentity
// Pending contains assets known to be missing.
Pending []AssetIdentity
// Conflicting contains assets with detected content conflicts.
Conflicting []AssetIdentity
Uncertain []AssetIdentity
// Uncertain contains assets whose remote state could not be resolved.
Uncertain []AssetIdentity
}
// VerifyLocalAssets checks that every expected file still matches its declared
@@ -146,6 +154,7 @@ func (c Client) UploadMissing(ctx context.Context, destination Destination, rele
inspection, inspectErr := c.InspectAssets(ctx, destination, releaseID, []ExpectedAsset{item})
if inspectErr != nil || len(inspection.Assets) != 1 {
result.State = DeliveryUnknown
result.Pending = removeIdentity(result.Pending, item.Filename)
result.Uncertain = append(result.Uncertain, identity(item))
return result, failure(FailureUnknown, "resolve asset upload", status, errors.Join(uploadErr, inspectErr))
}
@@ -156,15 +165,18 @@ func (c Client) UploadMissing(ctx context.Context, destination Destination, rele
result.Pending = removeIdentity(result.Pending, item.Filename)
case AssetConflicting:
result.State = DeliveryConflict
result.Pending = removeIdentity(result.Pending, item.Filename)
result.Conflicting = append(result.Conflicting, identity(item))
return result, failure(FailureConflict, "resolve asset upload", status, uploadErr)
case AssetMissing:
if status == http.StatusConflict {
result.State = DeliveryConflict
result.Pending = removeIdentity(result.Pending, item.Filename)
result.Conflicting = append(result.Conflicting, identity(item))
return result, failure(FailureConflict, "resolve asset upload", status, uploadErr)
}
result.State = DeliveryUnknown
result.Pending = removeIdentity(result.Pending, item.Filename)
result.Uncertain = append(result.Uncertain, identity(item))
return result, failure(FailureUnknown, "resolve asset upload", status, uploadErr)
default:

View File

@@ -42,6 +42,7 @@ func TestUploadMissingPartialFailureThenRerunOnlyMissingAssets(t *testing.T) {
if got, want := names(first.Pending), []string{"zeta.bin", "beta.bin", "SHA256SUMS"}; !reflect.DeepEqual(got, want) {
t.Fatalf("pending = %v, want %v", got, want)
}
assertAssetCategoriesDisjoint(t, first)
secondInspection, err := client.InspectAssets(context.Background(), destination, 10, assets)
if err != nil {
@@ -80,6 +81,7 @@ func TestUploadMissingAcceptsConcurrentMatchingAttachmentWithoutReplacement(t *t
if len(result.Uploaded) != 0 || len(fixture.postNames()) != 1 {
t.Fatalf("result = %#v, POST names = %v", result, fixture.postNames())
}
assertAssetCategoriesDisjoint(t, result)
}
func TestUploadMissingReconcilesLostManifestAcknowledgment(t *testing.T) {
@@ -111,6 +113,7 @@ func TestUploadMissingReconcilesLostManifestAcknowledgment(t *testing.T) {
if len(result.Uploaded) != 0 || !reflect.DeepEqual(fixture.postNames(), []string{ManifestFilename}) {
t.Fatalf("result = %#v, POST names = %v", result, fixture.postNames())
}
assertAssetCategoriesDisjoint(t, result)
}
func TestUploadMissingReportsUnknownWhenReconciliationReadFails(t *testing.T) {
@@ -119,7 +122,7 @@ func TestUploadMissingReportsUnknownWhenReconciliationReadFails(t *testing.T) {
defer server.Close()
client := testClient(server)
destination := testDestination(server.URL)
assets := localAssets(t, []string{"app.bin"})
assets := localAssets(t, []string{"app.bin", "later.bin"})
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
if err != nil {
t.Fatal(err)
@@ -128,12 +131,13 @@ func TestUploadMissingReportsUnknownWhenReconciliationReadFails(t *testing.T) {
if err == nil || result.State != DeliveryUnknown {
t.Fatalf("result = %#v, error = %v", result, err)
}
if got, want := names(result.Pending), []string{"app.bin"}; !reflect.DeepEqual(got, want) {
if got, want := names(result.Pending), []string{"later.bin"}; !reflect.DeepEqual(got, want) {
t.Fatalf("pending = %v, want %v", got, want)
}
if got, want := names(result.Uncertain), []string{"app.bin"}; !reflect.DeepEqual(got, want) {
t.Fatalf("uncertain = %v, want %v", got, want)
}
assertAssetCategoriesDisjoint(t, result)
if got := fixture.postNames(); !reflect.DeepEqual(got, []string{"app.bin"}) {
t.Fatalf("POST names = %v", got)
}
@@ -163,17 +167,100 @@ func TestUploadMissingPreflightConflictPreventsEveryWrite(t *testing.T) {
if got, want := names(result.Pending), []string{"other.bin"}; !reflect.DeepEqual(got, want) {
t.Fatalf("pending = %v, want %v", got, want)
}
assertAssetCategoriesDisjoint(t, result)
}
func TestUploadMissingKeepsLaterPendingAfterAmbiguousMissingUpload(t *testing.T) {
fixture := &uploadFixture{files: make(map[string][]byte), dropAckMissingName: "alpha.bin"}
server := httptest.NewServer(fixture)
defer server.Close()
client := testClient(server)
destination := testDestination(server.URL)
assets := localAssets(t, []string{"alpha.bin", "zeta.bin"})
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
if err != nil {
t.Fatal(err)
}
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
if err == nil || result.State != DeliveryUnknown {
t.Fatalf("result = %#v, error = %v", result, err)
}
if got, want := names(result.Uncertain), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
t.Fatalf("uncertain = %v, want %v", got, want)
}
if got, want := names(result.Pending), []string{"zeta.bin"}; !reflect.DeepEqual(got, want) {
t.Fatalf("pending = %v, want %v", got, want)
}
assertAssetCategoriesDisjoint(t, result)
if got, want := fixture.postNames(), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
t.Fatalf("POST names = %v, want %v", got, want)
}
}
func TestUploadMissingMovesReconciledConflictOutOfPending(t *testing.T) {
fixture := &uploadFixture{
files: make(map[string][]byte),
conflictName: "alpha.bin",
conflictDifferentContent: true,
}
server := httptest.NewServer(fixture)
defer server.Close()
client := testClient(server)
destination := testDestination(server.URL)
assets := localAssets(t, []string{"alpha.bin", "zeta.bin"})
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
if err != nil {
t.Fatal(err)
}
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
if err == nil || result.State != DeliveryConflict {
t.Fatalf("result = %#v, error = %v", result, err)
}
if got, want := names(result.Conflicting), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
t.Fatalf("conflicting = %v, want %v", got, want)
}
if got, want := names(result.Pending), []string{"zeta.bin"}; !reflect.DeepEqual(got, want) {
t.Fatalf("pending = %v, want %v", got, want)
}
assertAssetCategoriesDisjoint(t, result)
}
func TestUploadMissingTreatsUnresolvedNameConflictAsConflict(t *testing.T) {
fixture := &uploadFixture{files: make(map[string][]byte), conflictMissingName: "alpha.bin"}
server := httptest.NewServer(fixture)
defer server.Close()
client := testClient(server)
destination := testDestination(server.URL)
assets := localAssets(t, []string{"alpha.bin", "zeta.bin"})
initial, err := client.InspectAssets(context.Background(), destination, 10, assets)
if err != nil {
t.Fatal(err)
}
result, err := client.UploadMissing(context.Background(), destination, 10, initial)
if err == nil || result.State != DeliveryConflict {
t.Fatalf("result = %#v, error = %v", result, err)
}
if got, want := names(result.Conflicting), []string{"alpha.bin"}; !reflect.DeepEqual(got, want) {
t.Fatalf("conflicting = %v, want %v", got, want)
}
if got, want := names(result.Pending), []string{"zeta.bin"}; !reflect.DeepEqual(got, want) {
t.Fatalf("pending = %v, want %v", got, want)
}
assertAssetCategoriesDisjoint(t, result)
}
type uploadFixture struct {
mu sync.Mutex
files map[string][]byte
posts []string
failName string
conflictName string
dropAckName string
failListAfter int
listCount int
mu sync.Mutex
files map[string][]byte
posts []string
failName string
conflictName string
conflictDifferentContent bool
conflictMissingName string
dropAckName string
dropAckMissingName string
failListAfter int
listCount int
}
func (fixture *uploadFixture) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -254,6 +341,30 @@ func (fixture *uploadFixture) receiveUpload(w http.ResponseWriter, r *http.Reque
http.Error(w, "write failed", http.StatusBadRequest)
return
}
if name == fixture.dropAckMissingName {
fixture.mu.Unlock()
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "hijacking unavailable", http.StatusInternalServerError)
return
}
connection, _, err := hijacker.Hijack()
if err == nil {
_ = connection.Close()
}
return
}
if name == fixture.conflictMissingName {
fixture.mu.Unlock()
w.WriteHeader(http.StatusConflict)
return
}
if name == fixture.conflictName && fixture.conflictDifferentContent {
fixture.files[name] = []byte("conflicting bytes")
fixture.mu.Unlock()
w.WriteHeader(http.StatusConflict)
return
}
fixture.files[name] = append([]byte(nil), content...)
fixture.mu.Unlock()
if name == fixture.conflictName {
@@ -318,6 +429,29 @@ func names(identities []AssetIdentity) []string {
return result
}
func assertAssetCategoriesDisjoint(t *testing.T, result UploadResult) {
t.Helper()
groups := []struct {
name string
identities []AssetIdentity
}{
{name: "matching", identities: result.Matching},
{name: "uploaded", identities: result.Uploaded},
{name: "pending", identities: result.Pending},
{name: "conflicting", identities: result.Conflicting},
{name: "uncertain", identities: result.Uncertain},
}
seen := make(map[string]string)
for _, group := range groups {
for _, item := range group.identities {
if previous, ok := seen[item.Filename]; ok {
t.Errorf("asset %q appears in both %s and %s", item.Filename, previous, group.name)
}
seen[item.Filename] = group.name
}
}
}
func urlPathEscape(value string) string {
return (&url.URL{Path: value}).EscapedPath()
}