[ResultDB] Implement workunits.ReadRealms and ReadBatch. Clean-up.

Provide a way to batch read realms and work units in preparation
for implementing BatchGetWorkUnits.

Cleans-up handling of instruction names in test code and in creation.

BUG=b:422274371

Change-Id: I8be35d6b428e33bdc1f27914c32be83c80911b1b
Reviewed-on: https://chromium-review.googlesource.com/c/infra/luci/luci-go/+/6726494
Commit-Queue: Patrick Meiring <meiring@google.com>
Reviewed-by: Beining Chen <beining@google.com>
diff --git a/resultdb/internal/rootinvocations/read_test.go b/resultdb/internal/rootinvocations/read_test.go
index 66948ca..b25b55a 100644
--- a/resultdb/internal/rootinvocations/read_test.go
+++ b/resultdb/internal/rootinvocations/read_test.go
@@ -33,15 +33,30 @@
 		ctx := testutil.SpannerTestContext(t)
 
 		const realm = "testproject:testrealm"
+
+		// Prepare a root invocation with all fields set.
 		const id = ID("root-inv-id")
 		testData := NewBuilder(id).WithRealm(realm).Build()
-		testutil.MustApply(ctx, t, InsertForTesting(testData)...)
+		ms := InsertForTesting(testData)
+
+		// Prepare a root invocation with minimal fields set.
+		const idMinimal = ID("root-inv-id-minimal")
+		testDataMinimal := NewBuilder("root-inv-id-minimal").WithRealm(realm).WithMinimalFields().Build()
+		ms = append(ms, InsertForTesting(testDataMinimal)...)
+		testutil.MustApply(ctx, t, ms...)
 
 		t.Run("Read", func(t *ftt.Test) {
 			t.Run("happy path", func(t *ftt.Test) {
-				row, err := Read(span.Single(ctx), id)
-				assert.Loosely(t, err, should.BeNil)
-				assert.That(t, row, should.Match(&testData))
+				t.Run("maximal fields", func(t *ftt.Test) {
+					row, err := Read(span.Single(ctx), id)
+					assert.Loosely(t, err, should.BeNil)
+					assert.That(t, row, should.Match(testData))
+				})
+				t.Run("minimal fields", func(t *ftt.Test) {
+					row, err := Read(span.Single(ctx), idMinimal)
+					assert.Loosely(t, err, should.BeNil)
+					assert.That(t, row, should.Match(testDataMinimal))
+				})
 			})
 
 			t.Run("not found", func(t *ftt.Test) {
diff --git a/resultdb/internal/rootinvocations/span.go b/resultdb/internal/rootinvocations/span.go
index cd2710f..3afc383 100644
--- a/resultdb/internal/rootinvocations/span.go
+++ b/resultdb/internal/rootinvocations/span.go
@@ -20,6 +20,7 @@
 	"time"
 
 	"cloud.google.com/go/spanner"
+	"google.golang.org/protobuf/proto"
 	"google.golang.org/protobuf/types/known/structpb"
 
 	"go.chromium.org/luci/resultdb/internal/invocations"
@@ -97,6 +98,24 @@
 	Submitted                               bool
 }
 
+// Clone makes a deep copy of the row.
+func (r *RootInvocationRow) Clone() *RootInvocationRow {
+	ret := *r
+	if r.Tags != nil {
+		ret.Tags = make([]*pb.StringPair, len(r.Tags))
+		for i, tp := range r.Tags {
+			ret.Tags[i] = proto.Clone(tp).(*pb.StringPair)
+		}
+	}
+	if r.Properties != nil {
+		ret.Properties = proto.Clone(r.Properties).(*structpb.Struct)
+	}
+	if r.Sources != nil {
+		ret.Sources = proto.Clone(r.Sources).(*pb.Sources)
+	}
+	return &ret
+}
+
 // Convert the root invocation row to the canonical form.
 func (r *RootInvocationRow) Normalize() {
 	pbutil.SortStringPairs(r.Tags)
diff --git a/resultdb/internal/rootinvocations/span_test.go b/resultdb/internal/rootinvocations/span_test.go
index bd54b1e..ab6b580 100644
--- a/resultdb/internal/rootinvocations/span_test.go
+++ b/resultdb/internal/rootinvocations/span_test.go
@@ -21,7 +21,6 @@
 
 	"cloud.google.com/go/spanner"
 	"google.golang.org/protobuf/proto"
-	"google.golang.org/protobuf/types/known/structpb"
 	"google.golang.org/protobuf/types/known/timestamppb"
 
 	"go.chromium.org/luci/common/clock/testclock"
@@ -43,37 +42,9 @@
 		now := testclock.TestRecentTimeUTC
 		ctx, _ = testclock.UseTime(ctx, now)
 
-		properties := &structpb.Struct{
-			Fields: map[string]*structpb.Value{
-				"key": structpb.NewStringValue("value"),
-			},
-		}
-		sources := &pb.Sources{
-			GitilesCommit: &pb.GitilesCommit{
-				Host:       "chromium.googlesource.com",
-				Project:    "chromium/src",
-				Ref:        "refs/heads/main",
-				CommitHash: "1234567890abcdef1234567890abcdef12345678",
-				Position:   123,
-			},
-		}
 		id := "root-inv-id"
-		row := &RootInvocationRow{
-			RootInvocationID:                        ID(id),
-			State:                                   pb.RootInvocation_ACTIVE,
-			Realm:                                   "testproject:testrealm",
-			CreatedBy:                               "user:test@example.com",
-			Deadline:                                now.Add(2 * 24 * time.Hour),
-			UninterestingTestVerdictsExpirationTime: spanner.NullTime{Valid: true, Time: now.Add(2 * 24 * time.Hour)},
-			CreateRequestID:                         "test-request-id",
-			ProducerResource:                        "//builds.example.com/builds/123",
-			Tags:                                    pbutil.StringPairs("k2", "v2", "k1", "v1"),
-			Properties:                              properties,
-			Sources:                                 sources,
-			IsSourcesFinal:                          true,
-			BaselineID:                              "try:linux-rel",
-			Submitted:                               false,
-		}
+		row := NewBuilder("root-inv-id").WithState(pb.RootInvocation_ACTIVE).Build()
+
 		commitTime, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
 			mutations := Create(row)
 			span.BufferWrite(ctx, mutations...)
@@ -90,7 +61,7 @@
 		assert.Loosely(t, err, should.BeNil)
 		row.CreateTime = commitTime
 		row.SecondaryIndexShardID = row.RootInvocationID.shardID(secondaryIndexShardCount)
-		assert.Loosely(t, readRootInv, should.Match(row))
+		assert.That(t, readRootInv, should.Match(row))
 
 		// Validate Legacy Invocations table entry.
 		legacyInvID := invocations.ID(fmt.Sprintf("root:%s", id))
diff --git a/resultdb/internal/rootinvocations/testdata.go b/resultdb/internal/rootinvocations/testdata.go
index bf0aa0a..dd9c455 100644
--- a/resultdb/internal/rootinvocations/testdata.go
+++ b/resultdb/internal/rootinvocations/testdata.go
@@ -18,7 +18,6 @@
 	"time"
 
 	"cloud.google.com/go/spanner"
-	"google.golang.org/protobuf/proto"
 	"google.golang.org/protobuf/types/known/structpb"
 
 	"go.chromium.org/luci/resultdb/internal/spanutil"
@@ -36,6 +35,7 @@
 func NewBuilder(id ID) *Builder {
 	return &Builder{
 		row: RootInvocationRow{
+			// Set all fields by default. This helps optimise test coverage.
 			RootInvocationID:                        id,
 			SecondaryIndexShardID:                   id.shardID(secondaryIndexShardCount),
 			State:                                   pb.RootInvocation_FINALIZED,
@@ -65,11 +65,34 @@
 			},
 			IsSourcesFinal: true,
 			BaselineID:     "baseline",
-			Submitted:      false,
+			Submitted:      true,
 		},
 	}
 }
 
+// WithMinimalFields clears as many fields as possible on the root invocation while
+// keeping it valid. This is useful for testing null and empty value handling.
+func (b *Builder) WithMinimalFields() *Builder {
+	b.row = RootInvocationRow{
+		RootInvocationID:      b.row.RootInvocationID,
+		SecondaryIndexShardID: b.row.SecondaryIndexShardID,
+		// Means the finalized time and start time will be cleared in Build() unless state is
+		// subsequently overridden.
+		State:             pb.RootInvocation_ACTIVE,
+		Realm:             b.row.Realm,
+		CreateTime:        b.row.CreateTime,
+		CreatedBy:         b.row.CreatedBy,
+		FinalizeStartTime: b.row.FinalizeStartTime,
+		FinalizeTime:      b.row.FinalizeTime,
+		Deadline:          b.row.Deadline,
+		CreateRequestID:   b.row.CreateRequestID,
+		// Prefer to use empty slice rather than nil (even though semantically identical)
+		// as this what we always report in reads.
+		Tags: []*pb.StringPair{},
+	}
+	return b
+}
+
 // WithRootInvocationID sets the root invocation ID.
 func (b *Builder) WithRootInvocationID(id ID) *Builder {
 	b.row.RootInvocationID = id
@@ -174,18 +197,11 @@
 }
 
 // Build returns the constructed RootInvocationRow.
-func (b *Builder) Build() RootInvocationRow {
-	// Return a copy.
-	r := b.row
-	if r.Tags != nil {
-		r.Tags = append([]*pb.StringPair(nil), r.Tags...)
-	}
-	if r.Properties != nil {
-		r.Properties = proto.Clone(r.Properties).(*structpb.Struct)
-	}
-	if r.Sources != nil {
-		r.Sources = proto.Clone(r.Sources).(*pb.Sources)
-	}
+func (b *Builder) Build() *RootInvocationRow {
+	// Return a copy to avoid changes to the returned row
+	// flowing back into the builder.
+	r := b.row.Clone()
+
 	if r.State == pb.RootInvocation_ACTIVE {
 		r.FinalizeStartTime = spanner.NullTime{}
 		r.FinalizeTime = spanner.NullTime{}
@@ -198,7 +214,7 @@
 
 // InsertForTesting inserts the rootInvocation record and all the
 // RootInvocationShards records for a root invocation for testing purposes.
-func InsertForTesting(r RootInvocationRow) []*spanner.Mutation {
+func InsertForTesting(r *RootInvocationRow) []*spanner.Mutation {
 	ms := make([]*spanner.Mutation, 0, 16+1) // 16 shard and 1 root invocation
 	ms = append(ms, spanutil.InsertMap("RootInvocations", map[string]any{
 		"RootInvocationId":      r.RootInvocationID,
diff --git a/resultdb/internal/services/resultdb/get_work_unit.go b/resultdb/internal/services/resultdb/get_work_unit.go
index ca64453..02464f1 100644
--- a/resultdb/internal/services/resultdb/get_work_unit.go
+++ b/resultdb/internal/services/resultdb/get_work_unit.go
@@ -67,8 +67,13 @@
 		return nil, appstatus.BadRequest(err)
 	}
 
+	readMask := workunits.ExcludeExtendedProperties
+	if in.View == pb.WorkUnitView_WORK_UNIT_VIEW_FULL && accessLevel == permissions.FullAccess {
+		readMask = workunits.AllFields
+	}
+
 	// Read the work unit.
-	wu, err := workunits.Read(ctx, id)
+	wu, err := workunits.Read(ctx, id, readMask)
 	if err != nil {
 		return nil, err
 	}
diff --git a/resultdb/internal/services/resultdb/get_work_unit_test.go b/resultdb/internal/services/resultdb/get_work_unit_test.go
index ba157d6..f467867 100644
--- a/resultdb/internal/services/resultdb/get_work_unit_test.go
+++ b/resultdb/internal/services/resultdb/get_work_unit_test.go
@@ -26,7 +26,6 @@
 	"go.chromium.org/luci/server/auth"
 	"go.chromium.org/luci/server/auth/authtest"
 
-	"go.chromium.org/luci/resultdb/internal/instructionutil"
 	"go.chromium.org/luci/resultdb/internal/rootinvocations"
 	"go.chromium.org/luci/resultdb/internal/testutil"
 	"go.chromium.org/luci/resultdb/internal/testutil/insert"
@@ -64,6 +63,7 @@
 		childWu := workunits.NewBuilder(rootInvID, childWuID.WorkUnitID).
 			WithRealm(wuRealm).
 			WithParentWorkUnitID(rootWorkUnitID.WorkUnitID).
+			WithMinimalFields().
 			Build()
 		testutil.MustApply(ctx, t, insert.WorkUnit(childWu)...)
 
@@ -97,7 +97,7 @@
 				ProducerResource:  wu.ProducerResource,
 				Tags:              wu.Tags,
 				Properties:        wu.Properties,
-				Instructions:      instructionutil.InstructionsWithNames(wu.Instructions, rootWorkUnitID.Name()),
+				Instructions:      wu.Instructions,
 				IsMasked:          false,
 			}
 
diff --git a/resultdb/internal/spanutil/init_db.sql b/resultdb/internal/spanutil/init_db.sql
index e38b331..cbb71d2 100644
--- a/resultdb/internal/spanutil/init_db.sql
+++ b/resultdb/internal/spanutil/init_db.sql
@@ -82,11 +82,11 @@
   -- A serialized then compressed google.protobuf.Struct that stores structured,
   -- domain-specific properties of the root invocation.
   -- See spanutil.Compressed type for details of compression.
-  Properties BYTES(MAX) NOT NULL,
+  Properties BYTES(MAX),
 
   -- A serialized luci.resultdb.v1.Sources message describing the source information for the
   -- root invocation.
-  Sources BYTES(MAX) NOT NULL,
+  Sources BYTES(MAX),
 
   -- Whether the root invocation's source information (denoted by 'Sources') is immutable.
   -- Setting this early is desirable as it enables test result exports from work units
@@ -214,7 +214,7 @@
   -- exports.
   --
   -- See RootInvocations.Sources for more information.
-  Sources BYTES(MAX) NOT NULL,
+  Sources BYTES(MAX),
 
   -- Replica of the root invocation field, to avoid hotspotting the root invocation
   -- in operations that don't need the whole root invocation, such as low-latency
@@ -304,16 +304,16 @@
   -- A serialized then compressed google.protobuf.Struct that stores structured,
   -- domain-specific properties of the invocation.
   -- See spanutil.Compressed type for details of compression.
-  Properties BYTES(MAX) NOT NULL,
+  Properties BYTES(MAX),
 
   -- A serialized luci.resultdb.v1.Instructions describing instructions for this invocation.
   -- It may contains instructions for steps (for build-level invocation) and test results.
   -- It may contain instructions to test results directly contained in this invocation,
   -- and test results in included invocations.
-  Instructions BYTES(MAX) NOT NULL,
+  Instructions BYTES(MAX),
 
   -- A compressed, serialized luci.resultdb.internal.invocations.ExtendedProperties message.
-  ExtendedProperties BYTES(MAX) NOT NULL,
+  ExtendedProperties BYTES(MAX),
 ) PRIMARY KEY (RootInvocationShardId, WorkUnitId),
   INTERLEAVE IN PARENT RootInvocationShards ON DELETE CASCADE;
 
diff --git a/resultdb/internal/testutil/insert/insert.go b/resultdb/internal/testutil/insert/insert.go
index ed4e12a..a673837 100644
--- a/resultdb/internal/testutil/insert/insert.go
+++ b/resultdb/internal/testutil/insert/insert.go
@@ -489,11 +489,11 @@
 }
 
 // RootInvocation returns Spanner mtuations to create the given root invocation.
-func RootInvocation(row rootinvocations.RootInvocationRow) []*spanner.Mutation {
+func RootInvocation(row *rootinvocations.RootInvocationRow) []*spanner.Mutation {
 	return rootinvocations.InsertForTesting(row)
 }
 
 // WorkUnit returns Spanner mutations to create the given work unit.
-func WorkUnit(row workunits.WorkUnitRow) []*spanner.Mutation {
+func WorkUnit(row *workunits.WorkUnitRow) []*spanner.Mutation {
 	return workunits.InsertForTesting(row)
 }
diff --git a/resultdb/internal/testvariants/query.go b/resultdb/internal/testvariants/query.go
index 3ed4e5d..e829cf4 100644
--- a/resultdb/internal/testvariants/query.go
+++ b/resultdb/internal/testvariants/query.go
@@ -1176,7 +1176,7 @@
 		"passedOrSkippedVerdictEff":  int(statusV2EffectivePassedOrSkipped),
 
 		"exoneratedOverride": int(pb.TestVerdict_EXONERATED),
-		"notOverriden":       int(pb.TestVerdict_NOT_OVERRIDDEN),
+		"notOverridden":      int(pb.TestVerdict_NOT_OVERRIDDEN),
 
 		"status": status,
 	}
@@ -1306,11 +1306,11 @@
 				ELSE @precludedVerdict
 			END TvStatusV2,
 			CASE
-				WHEN num_passed > 0 THEN @notOverriden -- Flaky and passed verdicts cannot be exonerated.
-				WHEN num_failed = 0 AND num_passed = 0 AND num_skipped > 0 THEN @notOverriden -- Skipped verdicts cannot be exonerated.
+				WHEN num_passed > 0 THEN @notOverridden -- Flaky and passed verdicts cannot be exonerated.
+				WHEN num_failed = 0 AND num_passed = 0 AND num_skipped > 0 THEN @notOverridden -- Skipped verdicts cannot be exonerated.
 				-- The verdict is eligible for exoneration, i.e. one of failed, execution errored or precluded.
 				WHEN exonerated.TestId IS NOT NULL THEN @exoneratedOverride
-				ELSE @notOverriden
+				ELSE @notOverridden
 			END TvStatusOverride,
 		{{if .OrderByStatusV2Effective}}
 			CASE
diff --git a/resultdb/internal/workunits/read.go b/resultdb/internal/workunits/read.go
index fdab875..75e0cd9 100644
--- a/resultdb/internal/workunits/read.go
+++ b/resultdb/internal/workunits/read.go
@@ -29,6 +29,7 @@
 	"go.chromium.org/luci/resultdb/internal/instructionutil"
 	"go.chromium.org/luci/resultdb/internal/invocations/invocationspb"
 	"go.chromium.org/luci/resultdb/internal/spanutil"
+	"go.chromium.org/luci/resultdb/internal/tracing"
 	pb "go.chromium.org/luci/resultdb/proto/v1"
 )
 
@@ -37,11 +38,8 @@
 // NotFound GRPC code.
 // For ptrMap see ReadRow comment in span/util.go.
 func readColumns(ctx context.Context, id ID, ptrMap map[string]any) error {
-	if id.RootInvocationID == "" {
-		return errors.New("rootInvocationID is unspecified")
-	}
-	if id.WorkUnitID == "" {
-		return errors.New("workUnitID is unspecified")
+	if err := validateID(id); err != nil {
+		return err
 	}
 
 	err := spanutil.ReadRow(ctx, "WorkUnits", id.key(), ptrMap)
@@ -58,11 +56,12 @@
 }
 
 // ReadRealm reads the realm of the given work unit. If the work unit
-// is not found, returns a NotFound appstatus error. Otherwise returns the internal
-// error.
-func ReadRealm(ctx context.Context, id ID) (string, error) {
-	var realm string
-	err := readColumns(ctx, id, map[string]any{
+// is not found, returns a NotFound appstatus error.
+func ReadRealm(ctx context.Context, id ID) (realm string, err error) {
+	ctx, ts := tracing.Start(ctx, "resultdb.workunits.ReadRealm")
+	defer func() { tracing.End(ts, err) }()
+
+	err = readColumns(ctx, id, map[string]any{
 		"Realm": &realm,
 	})
 	if err != nil {
@@ -71,8 +70,93 @@
 	return realm, nil
 }
 
-// readMulti reads multiple work units from Spanner.
-func readMulti(ctx context.Context, ids []ID, f func(wu *WorkUnitRow) error) error {
+func validateID(id ID) error {
+	if id.RootInvocationID == "" {
+		return errors.New("rootInvocationID: unspecified")
+	}
+	if id.WorkUnitID == "" {
+		return errors.New("workUnitID: unspecified")
+	}
+	return nil
+}
+
+func validateIDs(ids []ID) error {
+	for i, id := range ids {
+		if err := validateID(id); err != nil {
+			return errors.Fmt("ids[%d]: %w", i, err)
+		}
+	}
+	return nil
+}
+
+// ReadRealms reads the realm of the given work units. If any of the work
+// units are not found, returns a NotFound appstatus error. Returned realms
+// match 1:1 with the requested ids, i.e. result[i] corresponds to ids[i].
+// Duplicate IDs are allowed.
+func ReadRealms(ctx context.Context, ids []ID) (realms []string, err error) {
+	ctx, ts := tracing.Start(ctx, "resultdb.workunits.ReadRealms")
+	defer func() { tracing.End(ts, err) }()
+
+	if err := validateIDs(ids); err != nil {
+		return nil, err
+	}
+	if len(ids) == 0 {
+		return nil, nil
+	}
+
+	realms = make([]string, len(ids))
+
+	// No need to dedup keys going into Spanner, Cloud Spanner always behaves
+	// as if the key is only specified once.
+	var keys []spanner.Key
+	for _, id := range ids {
+		keys = append(keys, id.key())
+	}
+
+	resultMap := make(map[ID]string, len(ids))
+
+	var b spanutil.Buffer
+	columns := []string{"RootInvocationShardId", "WorkUnitId", "Realm"}
+	err = span.Read(ctx, "WorkUnits", spanner.KeySetFromKeys(keys...), columns).Do(func(r *spanner.Row) error {
+		var rootInvocationShardID string
+		var workUnitID string
+		var realm string
+		err := b.FromSpanner(r, &rootInvocationShardID, &workUnitID, &realm)
+		if err != nil {
+			return errors.Fmt("read spanner row for work unit: %w", err)
+		}
+		id := IDFromRowID(rootInvocationShardID, workUnitID)
+		resultMap[id] = realm
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	for i, id := range ids {
+		realm, ok := resultMap[id]
+		if !ok {
+			return nil, appstatus.Errorf(codes.NotFound, "%s not found", id.Name())
+		}
+		realms[i] = realm
+	}
+	return realms, nil
+}
+
+// ReadMask controls what fields to read.
+type ReadMask int
+
+const (
+	// Read all work unit properties.
+	AllFields ReadMask = iota
+	// Read all work unit fields, except extended properties.
+	// As extended properties can be quite large (megabytes per row), when
+	// reading many rows this can be too much data to query.
+	ExcludeExtendedProperties
+)
+
+// readBatchInternal reads multiple work units from Spanner.
+func readBatchInternal(ctx context.Context, ids []ID, mask ReadMask, f func(wu *WorkUnitRow) error) error {
 	if len(ids) == 0 {
 		return nil
 	}
@@ -94,7 +178,9 @@
 		"Tags",
 		"Properties",
 		"Instructions",
-		"ExtendedProperties",
+	}
+	if mask == AllFields {
+		cols = append(cols, "ExtendedProperties")
 	}
 
 	keys := spanner.KeySets()
@@ -131,33 +217,36 @@
 			&wu.Tags,
 			&properties,
 			&instructions,
-			&extendedProperties,
+		}
+		if mask == AllFields {
+			dest = append(dest, &extendedProperties)
 		}
 
 		if err := b.FromSpanner(row, dest...); err != nil {
-			return err
+			return errors.Fmt("read spanner row for work unit: %w", err)
 		}
 		wu.ID = IDFromRowID(rootInvocationShardID, workUnitID)
 
 		if len(properties) > 0 {
 			wu.Properties = &structpb.Struct{}
 			if err := proto.Unmarshal(properties, wu.Properties); err != nil {
-				return err
+				return errors.Fmt("unmarshal properties for work unit %s: %w", wu.ID.Name(), err)
 			}
 		}
 
 		if len(instructions) > 0 {
 			wu.Instructions = &pb.Instructions{}
 			if err := proto.Unmarshal(instructions, wu.Instructions); err != nil {
-				return err
+				return errors.Fmt("unmarshal instructions for work unit %s: %w", wu.ID.Name(), err)
 			}
+			// Populate output-only fields.
 			wu.Instructions = instructionutil.InstructionsWithNames(wu.Instructions, wu.ID.Name())
 		}
 
 		if len(extendedProperties) > 0 {
 			internalExtendedProperties := &invocationspb.ExtendedProperties{}
 			if err := proto.Unmarshal(extendedProperties, internalExtendedProperties); err != nil {
-				return errors.Fmt("unmarshal ExtendedProperties for work unit %s: %w", wu.ID.Name(), err)
+				return errors.Fmt("unmarshal extended properties for work unit %s: %w", wu.ID.Name(), err)
 			}
 			wu.ExtendedProperties = internalExtendedProperties.ExtendedProperties
 		}
@@ -169,9 +258,14 @@
 // Read reads one work unit from Spanner.
 // If the work unit does not exist, the returned error is annotated with
 // NotFound GRPC code.
-func Read(ctx context.Context, id ID) (*WorkUnitRow, error) {
-	var ret *WorkUnitRow
-	err := readMulti(ctx, []ID{id}, func(wu *WorkUnitRow) error {
+func Read(ctx context.Context, id ID, mask ReadMask) (ret *WorkUnitRow, err error) {
+	ctx, ts := tracing.Start(ctx, "resultdb.workunits.Read")
+	defer func() { tracing.End(ts, err) }()
+	if err := validateID(id); err != nil {
+		return nil, err
+	}
+
+	err = readBatchInternal(ctx, []ID{id}, mask, func(wu *WorkUnitRow) error {
 		ret = wu
 		return nil
 	})
@@ -185,3 +279,37 @@
 		return ret, nil
 	}
 }
+
+// ReadBatch reads the given work units. If any of the work units are not found,
+// returns a NotFound appstatus error. Returned realms match 1:1 with the requested
+// ids, i.e. result[i] corresponds to ids[i].
+// Duplicate IDs are allowed.
+func ReadBatch(ctx context.Context, ids []ID, mask ReadMask) (ret []*WorkUnitRow, err error) {
+	ctx, ts := tracing.Start(ctx, "resultdb.workunits.ReadBatch")
+	defer func() { tracing.End(ts, err) }()
+	if err := validateIDs(ids); err != nil {
+		return nil, err
+	}
+
+	resultMap := make(map[ID]*WorkUnitRow, len(ids))
+	err = readBatchInternal(ctx, ids, mask, func(wu *WorkUnitRow) error {
+		resultMap[wu.ID] = wu
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	ret = make([]*WorkUnitRow, len(ids))
+	for i, id := range ids {
+		row, ok := resultMap[id]
+		if !ok {
+			return nil, appstatus.Errorf(codes.NotFound, "%s not found", id.Name())
+		}
+		// Clone the row to avoid aliasing the same result row object in
+		// result twice (in case of the same ID being requested twice),
+		// as the caller might not expect aliasing.
+		ret[i] = row.Clone()
+	}
+	return ret, err
+}
diff --git a/resultdb/internal/workunits/read_test.go b/resultdb/internal/workunits/read_test.go
index e96bb9f..b2cf207 100644
--- a/resultdb/internal/workunits/read_test.go
+++ b/resultdb/internal/workunits/read_test.go
@@ -25,7 +25,6 @@
 	"go.chromium.org/luci/grpc/appstatus"
 	"go.chromium.org/luci/server/span"
 
-	"go.chromium.org/luci/resultdb/internal/instructionutil"
 	"go.chromium.org/luci/resultdb/internal/rootinvocations"
 	"go.chromium.org/luci/resultdb/internal/testutil"
 )
@@ -34,32 +33,50 @@
 	ftt.Run("Read functions", t, func(t *ftt.Test) {
 		ctx := testutil.SpannerTestContext(t)
 
-		const realm = "testproject:testrealm"
 		rootInvID := rootinvocations.ID("root-inv-id")
+
+		// Insert a root invocation.
+		rootInv := rootinvocations.NewBuilder(rootInvID).WithRealm("testproject:root").Build()
+		ms := rootinvocations.InsertForTesting(rootInv)
+
+		// Insert a work unit with all fields set.
+		testData := NewBuilder(rootInvID, "work-unit-id").WithRealm("testproject:wu1").Build()
+		ms = append(ms, InsertForTesting(testData)...)
 		id := ID{
 			RootInvocationID: rootInvID,
 			WorkUnitID:       "work-unit-id",
 		}
 
-		// Insert a root invocation.
-		rootInv := rootinvocations.NewBuilder(rootInvID).WithRealm(realm).Build()
-		mutations := rootinvocations.InsertForTesting(rootInv)
+		// Insert a work unit with minimal fields set.
+		testDataMinimal := NewBuilder(rootInvID, "work-unit-id-minimal").WithRealm("testproject:wu1").WithMinimalFields().Build()
+		idMinimal := ID{
+			RootInvocationID: rootInvID,
+			WorkUnitID:       "work-unit-id-minimal",
+		}
+		ms = append(ms, InsertForTesting(testDataMinimal)...)
 
-		// Insert a work unit.
-		testData := NewBuilder(rootInvID, "work-unit-id").WithRealm(realm).Build()
-		mutations = append(mutations, InsertForTesting(testData)...)
-		testutil.MustApply(ctx, t, mutations...)
+		testutil.MustApply(ctx, t, ms...)
 
 		t.Run("Read", func(t *ftt.Test) {
 			t.Run("happy path", func(t *ftt.Test) {
-				row, err := Read(span.Single(ctx), id)
-				assert.Loosely(t, err, should.BeNil)
+				t.Run("mask: all fields", func(t *ftt.Test) {
+					row, err := Read(span.Single(ctx), id, AllFields)
+					assert.Loosely(t, err, should.BeNil)
+					assert.That(t, row, should.Match(testData))
+				})
+				t.Run("mask: exclude extended properties", func(t *ftt.Test) {
+					assert.Loosely(t, testData.ExtendedProperties, should.NotBeNil)
+					testData.ExtendedProperties = nil
 
-				// Read populates some fields that the builder doesn't.
-				expected := testData
-				expected.Instructions = instructionutil.InstructionsWithNames(expected.Instructions, id.Name())
-
-				assert.That(t, row, should.Match(&expected))
+					row, err := Read(span.Single(ctx), id, ExcludeExtendedProperties)
+					assert.Loosely(t, err, should.BeNil)
+					assert.That(t, row, should.Match(testData))
+				})
+				t.Run("row: minimal fields", func(t *ftt.Test) {
+					row, err := Read(span.Single(ctx), idMinimal, AllFields)
+					assert.Loosely(t, err, should.BeNil)
+					assert.That(t, row, should.Match(testDataMinimal))
+				})
 			})
 
 			t.Run("not found", func(t *ftt.Test) {
@@ -67,17 +84,103 @@
 					RootInvocationID: rootInvID,
 					WorkUnitID:       "non-existent-id",
 				}
-				_, err := Read(span.Single(ctx), nonExistentID)
+				_, err := Read(span.Single(ctx), nonExistentID, AllFields)
 				assert.That(t, appstatus.Code(err), should.Equal(codes.NotFound))
 				assert.That(t, err, should.ErrLike("rootInvocations/root-inv-id/workUnits/non-existent-id not found"))
 			})
+			t.Run("empty root invocation ID", func(t *ftt.Test) {
+				id.RootInvocationID = ""
+				_, err := Read(span.Single(ctx), id, AllFields)
+				assert.That(t, err, should.ErrLike("rootInvocationID: unspecified"))
+			})
+			t.Run("empty work unit ID", func(t *ftt.Test) {
+				id.WorkUnitID = ""
+				_, err := Read(span.Single(ctx), id, AllFields)
+				assert.That(t, err, should.ErrLike("workUnitID: unspecified"))
+			})
+		})
+		t.Run("ReadBatch", func(t *ftt.Test) {
+			// Insert additional root invocation and work units.
+			rootInv2 := rootinvocations.NewBuilder("root-inv-id2").WithRealm("testproject:root2").Build()
+			wu21 := NewBuilder("root-inv-id2", "work-unit-id").WithRealm("testproject:wu21").Build()
+			wu22 := NewBuilder("root-inv-id2", "work-unit-id2").WithRealm("testproject:wu22").Build()
+			ms := rootinvocations.InsertForTesting(rootInv2)
+			ms = append(ms, InsertForTesting(wu21)...)
+			ms = append(ms, InsertForTesting(wu22)...)
+			testutil.MustApply(ctx, t, ms...)
+
+			t.Run("happy path", func(t *ftt.Test) {
+				ids := []ID{
+					{RootInvocationID: rootInvID, WorkUnitID: "work-unit-id-minimal"},
+					{RootInvocationID: rootInvID, WorkUnitID: "work-unit-id"},
+					{RootInvocationID: "root-inv-id2", WorkUnitID: "work-unit-id2"},
+					{RootInvocationID: "root-inv-id2", WorkUnitID: "work-unit-id"},
+					{RootInvocationID: rootInvID, WorkUnitID: "work-unit-id"}, // Duplicates are allowed.
+				}
+				t.Run("all fields", func(t *ftt.Test) {
+					rows, err := ReadBatch(span.Single(ctx), ids, AllFields)
+					assert.Loosely(t, err, should.BeNil)
+
+					// Check that the returned rows match the expected data.
+					assert.That(t, rows, should.Match([]*WorkUnitRow{
+						testDataMinimal,
+						testData,
+						wu22,
+						wu21,
+						testData,
+					}))
+				})
+				t.Run("exclude extended properties", func(t *ftt.Test) {
+					rows, err := ReadBatch(span.Single(ctx), ids, ExcludeExtendedProperties)
+					assert.Loosely(t, err, should.BeNil)
+
+					// Check that the returned rows match the inserted rows,
+					// minus extended properties.
+					expectedRows := []*WorkUnitRow{
+						testDataMinimal.Clone(),
+						testData.Clone(),
+						wu22.Clone(),
+						wu21.Clone(),
+						testData.Clone(),
+					}
+					for _, r := range expectedRows {
+						r.ExtendedProperties = nil
+					}
+					assert.That(t, rows, should.Match(expectedRows))
+				})
+			})
+			t.Run("not found", func(t *ftt.Test) {
+				ids := []ID{
+					{RootInvocationID: rootInvID, WorkUnitID: "work-unit-id"},
+					{RootInvocationID: rootInvID, WorkUnitID: "non-existent-id"},
+				}
+				_, err := ReadBatch(span.Single(ctx), ids, AllFields)
+				assert.That(t, appstatus.Code(err), should.Equal(codes.NotFound))
+				assert.That(t, err, should.ErrLike("rootInvocations/root-inv-id/workUnits/non-existent-id not found"))
+			})
+			t.Run("empty root invocation ID", func(t *ftt.Test) {
+				ids := []ID{
+					id,
+					{WorkUnitID: "work-unit-id2"},
+				}
+				_, err := ReadBatch(span.Single(ctx), ids, AllFields)
+				assert.That(t, err, should.ErrLike("ids[1]: rootInvocationID: unspecified"))
+			})
+			t.Run("empty work unit ID", func(t *ftt.Test) {
+				ids := []ID{
+					id,
+					{RootInvocationID: rootInvID},
+				}
+				_, err := ReadBatch(span.Single(ctx), ids, AllFields)
+				assert.That(t, err, should.ErrLike("ids[1]: workUnitID: unspecified"))
+			})
 		})
 
 		t.Run("ReadRealm", func(t *ftt.Test) {
 			t.Run("happy path", func(t *ftt.Test) {
 				r, err := ReadRealm(span.Single(ctx), id)
 				assert.Loosely(t, err, should.BeNil)
-				assert.That(t, r, should.Equal(realm))
+				assert.That(t, r, should.Equal("testproject:wu1"))
 			})
 
 			t.Run("not found", func(t *ftt.Test) {
@@ -94,12 +197,76 @@
 
 			t.Run("empty root invocation ID", func(t *ftt.Test) {
 				_, err := ReadRealm(span.Single(ctx), ID{WorkUnitID: "work-unit-id"})
-				assert.That(t, err, should.ErrLike("rootInvocationID is unspecified"))
+				assert.That(t, err, should.ErrLike("rootInvocationID: unspecified"))
 			})
 
 			t.Run("empty work unit ID", func(t *ftt.Test) {
 				_, err := ReadRealm(span.Single(ctx), ID{RootInvocationID: rootInvID})
-				assert.That(t, err, should.ErrLike("workUnitID is unspecified"))
+				assert.That(t, err, should.ErrLike("workUnitID: unspecified"))
+			})
+		})
+		t.Run("ReadRealms", func(t *ftt.Test) {
+			// Insert an additional work unit in the existing root invocation.
+			wu2 := NewBuilder(rootInvID, "work-unit-id2").WithRealm("testproject:wu2").Build()
+			ms := InsertForTesting(wu2)
+
+			// Create a further root invocation with work units.
+			rootInv2 := rootinvocations.NewBuilder("root-inv-id2").WithRealm("testproject:root2").Build()
+			wu21 := NewBuilder("root-inv-id2", "work-unit-id").WithRealm("testproject:wu21").Build()
+			wu22 := NewBuilder("root-inv-id2", "work-unit-id2").WithRealm("testproject:wu22").Build()
+			ms = append(ms, rootinvocations.InsertForTesting(rootInv2)...)
+			ms = append(ms, InsertForTesting(wu21)...)
+			ms = append(ms, InsertForTesting(wu22)...)
+
+			testutil.MustApply(ctx, t, ms...)
+
+			t.Run("happy path", func(t *ftt.Test) {
+				ids := []ID{
+					{RootInvocationID: rootInvID, WorkUnitID: "work-unit-id"},
+					{RootInvocationID: rootInvID, WorkUnitID: "work-unit-id2"},
+					{RootInvocationID: "root-inv-id2", WorkUnitID: "work-unit-id2"},
+					{RootInvocationID: "root-inv-id2", WorkUnitID: "work-unit-id"},
+					{RootInvocationID: rootInvID, WorkUnitID: "work-unit-id2"}, // Duplicates are allowed.
+				}
+				realms, err := ReadRealms(span.Single(ctx), ids)
+				assert.Loosely(t, err, should.BeNil)
+				assert.That(t, realms, should.Match([]string{
+					"testproject:wu1",
+					"testproject:wu2",
+					"testproject:wu22",
+					"testproject:wu21",
+					"testproject:wu2",
+				}))
+			})
+
+			t.Run("not found", func(t *ftt.Test) {
+				ids := []ID{
+					{RootInvocationID: rootInvID, WorkUnitID: "work-unit-id"},
+					{RootInvocationID: rootInvID, WorkUnitID: "non-existent-id"},
+				}
+				_, err := ReadRealms(span.Single(ctx), ids)
+				st, ok := appstatus.Get(err)
+				assert.Loosely(t, ok, should.BeTrue)
+				assert.Loosely(t, st.Code(), should.Equal(codes.NotFound))
+				assert.Loosely(t, st.Message(), should.ContainSubstring("rootInvocations/root-inv-id/workUnits/non-existent-id not found"))
+			})
+
+			t.Run("empty root invocation ID", func(t *ftt.Test) {
+				ids := []ID{
+					id,
+					{WorkUnitID: "work-unit-id2"},
+				}
+				_, err := ReadRealms(span.Single(ctx), ids)
+				assert.That(t, err, should.ErrLike("ids[1]: rootInvocationID: unspecified"))
+			})
+
+			t.Run("empty work unit ID", func(t *ftt.Test) {
+				ids := []ID{
+					id,
+					{RootInvocationID: rootInvID},
+				}
+				_, err := ReadRealms(span.Single(ctx), ids)
+				assert.That(t, err, should.ErrLike("ids[1]: workUnitID: unspecified"))
 			})
 		})
 	})
diff --git a/resultdb/internal/workunits/span.go b/resultdb/internal/workunits/span.go
index 2bb53ab..29595ba 100644
--- a/resultdb/internal/workunits/span.go
+++ b/resultdb/internal/workunits/span.go
@@ -19,8 +19,10 @@
 	"time"
 
 	"cloud.google.com/go/spanner"
+	"google.golang.org/protobuf/proto"
 	"google.golang.org/protobuf/types/known/structpb"
 
+	"go.chromium.org/luci/resultdb/internal/instructionutil"
 	"go.chromium.org/luci/resultdb/internal/invocations"
 	"go.chromium.org/luci/resultdb/internal/invocations/invocationspb"
 	"go.chromium.org/luci/resultdb/internal/spanutil"
@@ -94,9 +96,31 @@
 	ExtendedProperties    map[string]*structpb.Struct
 }
 
-func (w *WorkUnitRow) toMutation() *spanner.Mutation {
-	// TODO: Normalise instruction names before writing.
+// Clone makes a deep copy of the row.
+func (w *WorkUnitRow) Clone() *WorkUnitRow {
+	ret := *w
+	if w.Tags != nil {
+		ret.Tags = make([]*pb.StringPair, len(w.Tags))
+		for i, tp := range w.Tags {
+			ret.Tags[i] = proto.Clone(tp).(*pb.StringPair)
+		}
+	}
+	if w.Properties != nil {
+		ret.Properties = proto.Clone(w.Properties).(*structpb.Struct)
+	}
+	if w.Instructions != nil {
+		ret.Instructions = proto.Clone(w.Instructions).(*pb.Instructions)
+	}
+	if w.ExtendedProperties != nil {
+		ret.ExtendedProperties = make(map[string]*structpb.Struct, len(w.ExtendedProperties))
+		for k, v := range w.ExtendedProperties {
+			ret.ExtendedProperties[k] = proto.Clone(v).(*structpb.Struct)
+		}
+	}
+	return &ret
+}
 
+func (w *WorkUnitRow) toMutation() *spanner.Mutation {
 	row := map[string]interface{}{
 		"RootInvocationShardId": w.ID.RootInvocationShardID(),
 		"WorkUnitId":            w.ID.WorkUnitID,
@@ -111,7 +135,7 @@
 		"ProducerResource":      w.ProducerResource,
 		"Tags":                  w.Tags,
 		"Properties":            spanutil.Compressed(pbutil.MustMarshal(w.Properties)),
-		"Instructions":          spanutil.Compressed(pbutil.MustMarshal(w.Instructions)),
+		"Instructions":          spanutil.Compressed(pbutil.MustMarshal(instructionutil.RemoveInstructionsName(w.Instructions))),
 	}
 	// Wrap into luci.resultdb.internal.invocations.ExtendedProperties so that
 	// it can be serialized as a single value to spanner.
@@ -127,8 +151,6 @@
 }
 
 func (w *WorkUnitRow) toLegacyInvocationMutation(opts LegacyCreateOptions) *spanner.Mutation {
-	// TODO: Normalise instruction names before writing.
-
 	row := map[string]interface{}{
 		"InvocationId":                      w.ID.LegacyInvocationID(),
 		"Type":                              invocations.WorkUnit,
@@ -148,7 +170,7 @@
 		"InheritSources": spanner.NullBool{Valid: false},
 		// Work units are not export roots.
 		"IsExportRoot": spanner.NullBool{Bool: false, Valid: true},
-		"Instructions": spanutil.Compressed(pbutil.MustMarshal(w.Instructions)),
+		"Instructions": spanutil.Compressed(pbutil.MustMarshal(instructionutil.RemoveInstructionsName(w.Instructions))),
 	}
 
 	// Wrap into luci.resultdb.internal.invocations.ExtendedProperties so that
diff --git a/resultdb/internal/workunits/span_test.go b/resultdb/internal/workunits/span_test.go
index 5abb7b8..65dd960 100644
--- a/resultdb/internal/workunits/span_test.go
+++ b/resultdb/internal/workunits/span_test.go
@@ -20,7 +20,6 @@
 	"time"
 
 	"cloud.google.com/go/spanner"
-	"google.golang.org/protobuf/types/known/structpb"
 	"google.golang.org/protobuf/types/known/timestamppb"
 
 	"go.chromium.org/luci/common/clock/testclock"
@@ -42,53 +41,11 @@
 		now := testclock.TestRecentTimeUTC
 		ctx, _ = testclock.UseTime(ctx, now)
 
-		properties := &structpb.Struct{
-			Fields: map[string]*structpb.Value{
-				"key": structpb.NewStringValue("value"),
-			},
-		}
-		instructions := &pb.Instructions{
-			Instructions: []*pb.Instruction{
-				{
-					Id:   "step",
-					Type: pb.InstructionType_STEP_INSTRUCTION,
-					TargetedInstructions: []*pb.TargetedInstruction{
-						{
-							Targets: []pb.InstructionTarget{
-								pb.InstructionTarget_LOCAL,
-								pb.InstructionTarget_REMOTE,
-							},
-							Content: "step instruction",
-						},
-					},
-				},
-			},
-		}
-		extendedProperties := map[string]*structpb.Struct{
-			"mykey": {
-				Fields: map[string]*structpb.Value{
-					"@type":       structpb.NewStringValue("foo.bar.com/x/_some.package.MyMessage"),
-					"child_key_1": structpb.NewStringValue("child_value_1"),
-				},
-			},
-		}
 		id := ID{
 			RootInvocationID: "root-inv-id",
 			WorkUnitID:       "work-unit-id",
 		}
-		row := &WorkUnitRow{
-			ID:                 id,
-			State:              pb.WorkUnit_ACTIVE,
-			Realm:              "testproject:testrealm",
-			CreatedBy:          "user:test@example.com",
-			Deadline:           now.Add(2 * 24 * time.Hour),
-			CreateRequestID:    "test-request-id",
-			ProducerResource:   "//builds.example.com/builds/123",
-			Tags:               pbutil.StringPairs("k2", "v2", "k1", "v1"),
-			Properties:         properties,
-			Instructions:       instructions,
-			ExtendedProperties: extendedProperties,
-		}
+		row := NewBuilder("root-inv-id", "work-unit-id").WithState(pb.WorkUnit_ACTIVE).Build()
 
 		LegacyCreateOptions := LegacyCreateOptions{
 			ExpectedTestResultsExpirationTime: now.Add(2 * 24 * time.Hour),
@@ -100,7 +57,7 @@
 			rootinvocations.InsertForTesting(rootinvocations.NewBuilder("root-inv-id").Build())...,
 		)
 		commitTime, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
-			mutations := Create(row, LegacyCreateOptions)
+			mutations := Create(row.Clone(), LegacyCreateOptions)
 			span.BufferWrite(ctx, mutations...)
 			return nil
 		})
@@ -111,11 +68,10 @@
 		defer cancel()
 
 		// Validate WorkUnits table entry.
-		readWorkUnit, err := Read(ctx, id)
+		readWorkUnit, err := Read(ctx, id, AllFields)
 		assert.Loosely(t, err, should.BeNil)
-		row.CreateTime = commitTime
+		row.CreateTime = commitTime.In(time.UTC)
 		row.SecondaryIndexShardID = id.shardID(secondaryIndexShardCount)
-		row.Instructions.Instructions[0].Name = "rootInvocations/root-inv-id/workUnits/work-unit-id/instructions/step"
 		assert.Loosely(t, readWorkUnit, should.Match(row))
 
 		// Validate Legacy Invocations table entry.
diff --git a/resultdb/internal/workunits/testdata.go b/resultdb/internal/workunits/testdata.go
index f57601c..8e3d923 100644
--- a/resultdb/internal/workunits/testdata.go
+++ b/resultdb/internal/workunits/testdata.go
@@ -18,9 +18,9 @@
 	"time"
 
 	"cloud.google.com/go/spanner"
-	"google.golang.org/protobuf/proto"
 	"google.golang.org/protobuf/types/known/structpb"
 
+	"go.chromium.org/luci/resultdb/internal/instructionutil"
 	"go.chromium.org/luci/resultdb/internal/invocations"
 	"go.chromium.org/luci/resultdb/internal/invocations/invocationspb"
 	"go.chromium.org/luci/resultdb/internal/rootinvocations"
@@ -39,6 +39,8 @@
 func NewBuilder(rootInvocationID rootinvocations.ID, workUnitID string) *Builder {
 	id := ID{RootInvocationID: rootInvocationID, WorkUnitID: workUnitID}
 	return &Builder{
+		// Default to populating all fields (except parent work unit ID as it is not known),
+		// this helps maximise test coverage.
 		row: WorkUnitRow{
 			ID:                    id,
 			SecondaryIndexShardID: id.shardID(secondaryIndexShardCount),
@@ -77,6 +79,30 @@
 	}
 }
 
+// WithMinimalFields clears as many fields as possible on the work unit while keeping
+// it a valid work unit. This helps test code handles unset fields.
+func (b *Builder) WithMinimalFields() *Builder {
+	b.row = WorkUnitRow{
+		ID:                    b.row.ID,
+		ParentWorkUnitID:      b.row.ParentWorkUnitID,
+		SecondaryIndexShardID: b.row.SecondaryIndexShardID,
+		// Means the finalized time and start time will be cleared in Build() unless state is
+		// subsequently overridden.
+		State:             pb.WorkUnit_ACTIVE,
+		Realm:             b.row.Realm,
+		CreateTime:        b.row.CreateTime,
+		CreatedBy:         b.row.CreatedBy,
+		FinalizeStartTime: b.row.FinalizeStartTime,
+		FinalizeTime:      b.row.FinalizeTime,
+		Deadline:          b.row.Deadline,
+		CreateRequestID:   b.row.CreateRequestID,
+		// Prefer to use empty slice rather than nil (even though semantically identical)
+		// as this what we always report in reads.
+		Tags: []*pb.StringPair{},
+	}
+	return b
+}
+
 // WithID sets the work unit ID.
 func (b *Builder) WithID(id ID) *Builder {
 	b.row.ID = id
@@ -169,25 +195,14 @@
 }
 
 // Build returns the constructed WorkUnitRow.
-func (b *Builder) Build() WorkUnitRow {
-	// Return a copy.
-	r := b.row
-	if r.Tags != nil {
-		r.Tags = append([]*pb.StringPair(nil), r.Tags...)
-	}
-	if r.Properties != nil {
-		r.Properties = proto.Clone(r.Properties).(*structpb.Struct)
-	}
-	if r.Instructions != nil {
-		r.Instructions = proto.Clone(r.Instructions).(*pb.Instructions)
-	}
-	if r.ExtendedProperties != nil {
-		newEP := make(map[string]*structpb.Struct, len(r.ExtendedProperties))
-		for k, v := range r.ExtendedProperties {
-			newEP[k] = proto.Clone(v).(*structpb.Struct)
-		}
-		r.ExtendedProperties = newEP
-	}
+func (b *Builder) Build() *WorkUnitRow {
+	// Return a copy to avoid changes to the returned object
+	// flowing back into the builder.
+	r := b.row.Clone()
+
+	// Populate output-only fields on instructions.
+	r.Instructions = instructionutil.InstructionsWithNames(r.Instructions, r.ID.Name())
+
 	if r.State == pb.WorkUnit_ACTIVE {
 		r.FinalizeStartTime = spanner.NullTime{}
 		r.FinalizeTime = spanner.NullTime{}
@@ -200,7 +215,7 @@
 
 // InsertForTesting inserts the work unit record and its corresponding
 // legacy invocation record for testing purposes.
-func InsertForTesting(w WorkUnitRow) []*spanner.Mutation {
+func InsertForTesting(w *WorkUnitRow) []*spanner.Mutation {
 	workUnitMutation := spanutil.InsertMap("WorkUnits", map[string]any{
 		"RootInvocationShardId": w.ID.RootInvocationShardID(),
 		"WorkUnitId":            w.ID.WorkUnitID,
@@ -217,7 +232,7 @@
 		"ProducerResource":      w.ProducerResource,
 		"Tags":                  w.Tags,
 		"Properties":            spanutil.Compressed(pbutil.MustMarshal(w.Properties)),
-		"Instructions":          spanutil.Compressed(pbutil.MustMarshal(w.Instructions)),
+		"Instructions":          spanutil.Compressed(pbutil.MustMarshal(instructionutil.RemoveInstructionsName(w.Instructions))),
 		"ExtendedProperties":    spanutil.Compressed(pbutil.MustMarshal(&invocationspb.ExtendedProperties{ExtendedProperties: w.ExtendedProperties})),
 	})
 
@@ -240,7 +255,7 @@
 		"Properties":                        spanutil.Compressed(pbutil.MustMarshal(w.Properties)),
 		"InheritSources":                    spanner.NullBool{Valid: false},
 		"IsExportRoot":                      spanner.NullBool{Bool: false, Valid: true},
-		"Instructions":                      spanutil.Compressed(pbutil.MustMarshal(w.Instructions)),
+		"Instructions":                      spanutil.Compressed(pbutil.MustMarshal(instructionutil.RemoveInstructionsName(w.Instructions))),
 		"ExtendedProperties":                spanutil.Compressed(pbutil.MustMarshal(&invocationspb.ExtendedProperties{ExtendedProperties: w.ExtendedProperties})),
 	})