Skip to content

Commit 9284908

Browse files
authored
fix(llmobs): stop failing dataset pushes that delete and insert together (#5180)
### What does this PR do? Fixes `Dataset.Push` returning an error for any push that deletes and inserts in the same batch, even though the backend applied the change. Deleting every record and appending a replacement set, which is the natural way to overwrite a dataset, always failed: ``` push dataset: received a different number of new records than what it was sent (want: 7, got :0) ``` Inserted records now carry the id the client already generates in `Append`, and that id survives the push, so callers can keep using it to update or delete the record later. ### Motivation The client asserted that the batch_update response contains exactly `len(insert) + len(update)` entries. The response actually contains one entry per affected record, deletions included, so 7 inserts plus 7 deletes came back as 14. The assertion failed, the new record ids were dropped, and the push reported failure after the server had already committed it. A delete-only batch would have failed the same check from the other direction. The same assumption also made the code read `data[len(update):]` as the inserted records, which on a mixed batch is a slice of deletion entries. Neither of the other clients for this endpoint validates that count. The Python tracer takes the ids straight from the response and matches them by lookup, never by position, and `ddeval` reads only the new version and ignores the rest. Both send a client-generated id on inserts, which is what removes the need to correlate the response at all; this change does the same. `Append` already minted a UUID for local tracking, so the id simply stops being thrown away. Verified against the API: a dataset created through this path comes back with exactly the ids the client sent, and a mixed delete-plus-insert push now succeeds. ### Reviewer's Checklist - [ ] Changed code has unit tests for its functionality at or near 100% coverage. - [ ] [System-Tests](https://github.com/DataDog/system-tests/) covering this feature have been added and enabled with the va.b.c-dev version tag. - [ ] There is a benchmark for any new code, or changes to existing code. - [ ] If this interacts with the agent in a new way, a system test has been added. - [ ] New code is free of linting errors. You can check this by running `make lint` locally. - [ ] New code doesn't break existing tests. You can check this by running `make test` locally. - [ ] Add an appropriate team label so this PR gets put in the right place for the release notes. - [ ] All generated files are up to date. You can check this by running `make generate` locally. - [ ] Non-trivial go.mod changes, e.g. adding new modules, are reviewed by @DataDog/dd-trace-go-guild. Make sure all nested modules are up to date by running `make fix-modules` locally. Co-authored-by: rodrigo.arguello <rodrigo.arguello@datadoghq.com>
1 parent 472fe69 commit 9284908

3 files changed

Lines changed: 98 additions & 60 deletions

File tree

internal/llmobs/transport/dne.go

Lines changed: 26 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,13 @@ type ExperimentView struct {
6767
}
6868

6969
type DatasetRecordCreate struct {
70-
Input any `json:"input,omitempty"`
71-
ExpectedOutput any `json:"expected_output,omitempty"`
72-
Metadata any `json:"metadata,omitempty"`
70+
// ID is supplied by the client and persisted by the backend as the record's
71+
// id. Sending it means the caller already knows the id of everything it
72+
// inserted and does not have to recover it from the response.
73+
ID string `json:"id,omitempty"`
74+
Input any `json:"input,omitempty"`
75+
ExpectedOutput any `json:"expected_output,omitempty"`
76+
Metadata any `json:"metadata,omitempty"`
7377
}
7478

7579
type DatasetRecordUpdate struct {
@@ -303,7 +307,7 @@ func (c *Transport) BatchUpdateDataset(
303307
insert []DatasetRecordCreate,
304308
update []DatasetRecordUpdate,
305309
delete []string,
306-
) (int, []string, error) {
310+
) (int, error) {
307311
path := fmt.Sprintf("%s/datasets/%s/batch_update", endpointPrefixDNE, url.PathEscape(datasetID))
308312
method := http.MethodPost
309313
body := BatchUpdateDatasetRequest{
@@ -320,37 +324,30 @@ func (c *Transport) BatchUpdateDataset(
320324

321325
result, err := c.jsonRequest(ctx, method, path, subdomainDNE, body, payloadLimits)
322326
if err != nil {
323-
return -1, nil, err
327+
return -1, err
324328
}
325329
if result.statusCode != http.StatusOK {
326-
return -1, nil, fmt.Errorf("unexpected status %d: %s", result.statusCode, string(result.body))
330+
return -1, fmt.Errorf("unexpected status %d: %s", result.statusCode, string(result.body))
327331
}
328332

329333
var resp BatchUpdateDatasetResponse
330334
if err := json.Unmarshal(result.body, &resp); err != nil {
331-
return -1, nil, fmt.Errorf("failed to decode json response: %w", err)
332-
}
333-
334-
// FIXME: we don't get version numbers in responses to deletion requests
335-
// TODO(rarguelloF): the backend could return a better response here...
336-
var (
337-
newDatasetVersion = -1
338-
newRecordIDs []string
339-
)
340-
if len(resp.Data) > 0 {
341-
if resp.Data[0].Attributes.Version > 0 {
342-
newDatasetVersion = resp.Data[0].Attributes.Version
343-
}
344-
}
345-
if len(resp.Data) == len(insert)+len(update) {
346-
// new records are at the end of the slice
347-
for _, rec := range resp.Data[len(update):] {
348-
newRecordIDs = append(newRecordIDs, rec.ID)
349-
}
350-
} else {
351-
log.Warn("llmobs/internal/transport: BatchUpdateDataset: expected %d records in response, got %d", len(insert)+len(update), len(resp.Data))
352-
}
353-
return newDatasetVersion, newRecordIDs, nil
335+
return -1, fmt.Errorf("failed to decode json response: %w", err)
336+
}
337+
338+
// The response carries one entry per affected record, deletions included:
339+
// those come back with deleted_at and ttl set. Nothing here correlates
340+
// entries with what was sent, because inserts already carry a client-supplied
341+
// id, so the only thing worth reading is the new version. Every entry in a
342+
// batch shares it, so the first will do.
343+
//
344+
// A deletes-only batch returns no version, in which case the caller falls
345+
// back to incrementing.
346+
newDatasetVersion := -1
347+
if len(resp.Data) > 0 && resp.Data[0].Attributes.Version > 0 {
348+
newDatasetVersion = resp.Data[0].Attributes.Version
349+
}
350+
return newDatasetVersion, nil
354351
}
355352

356353
// GetDatasetRecordsPage fetches a single page of records for the given dataset.

llmobs/dataset/dataset.go

Lines changed: 9 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -379,8 +379,8 @@ func (d *Dataset) Append(records ...Record) {
379379
d.initialize()
380380

381381
for _, rec := range records {
382-
// This id will be discarded after push, since the backend will generate a new one.
383-
// It is used for tracking new records locally before the push.
382+
// The id tracks the record locally before the push, and Push sends it as
383+
// the record's id, so it stays valid afterwards.
384384
id := uuid.New().String()
385385
rec.id = id
386386

@@ -484,11 +484,13 @@ func (d *Dataset) Push(ctx context.Context) error {
484484
}
485485

486486
// Build slices for inserts, updates, and deletes from the pending maps.
487-
insertOldIDs := make([]string, 0, len(d.appendRecords))
487+
// Inserts carry the id Append already minted, and the backend persists it.
488+
// That is what makes the response uninteresting: nothing has to be matched
489+
// back to what was sent.
488490
insert := make([]transport.DatasetRecordCreate, 0, len(d.appendRecords))
489491
for id, rec := range d.appendRecords {
490-
insertOldIDs = append(insertOldIDs, id)
491492
insert = append(insert, transport.DatasetRecordCreate{
493+
ID: id,
492494
Input: rec.Input,
493495
ExpectedOutput: rec.ExpectedOutput,
494496
Metadata: rec.Metadata,
@@ -522,7 +524,6 @@ func (d *Dataset) Push(ctx context.Context) error {
522524
for i := 0; i < len(insert); i += chunkSize {
523525
end := min(i+chunkSize, len(insert))
524526
chunkInsert := insert[i:end]
525-
chunkOldIDs := insertOldIDs[i:end]
526527
chunkNum := (i / chunkSize) + 1
527528

528529
log.Debug("llmobs: uploading dataset chunk %d/%d (%d records)", chunkNum, numBatches, len(chunkInsert))
@@ -535,17 +536,11 @@ func (d *Dataset) Push(ctx context.Context) error {
535536
chunkDel = del
536537
}
537538

538-
newVersion, newRecordIDs, err := ll.Transport.BatchUpdateDataset(ctx, d.id, chunkInsert, chunkUpdate, chunkDel)
539+
newVersion, err := ll.Transport.BatchUpdateDataset(ctx, d.id, chunkInsert, chunkUpdate, chunkDel)
539540
if err != nil {
540541
return fmt.Errorf("failed to batch update dataset (chunk %d): %w", chunkNum, err)
541542
}
542543
log.Debug("llmobs: successfully uploaded dataset chunk %d/%d", chunkNum, numBatches)
543-
if len(chunkOldIDs) != len(newRecordIDs) {
544-
return fmt.Errorf("received a different number of new records than what it was sent (want: %d, got: %d)", len(chunkOldIDs), len(newRecordIDs))
545-
}
546-
for j, newID := range newRecordIDs {
547-
d.appendRecords[chunkOldIDs[j]].id = newID
548-
}
549544
if newVersion > 0 {
550545
lastVersion = newVersion
551546
}
@@ -565,29 +560,17 @@ func (d *Dataset) Push(ctx context.Context) error {
565560
// Small delta: a single batch_update request is sufficient.
566561
log.Debug("llmobs: dataset delta is %d bytes, using batch update", deltaSize)
567562

568-
// newRecordIDs should go in the same order
569-
newVersion, newRecordIDs, err := ll.Transport.BatchUpdateDataset(ctx, d.id, insert, update, del)
563+
newVersion, err := ll.Transport.BatchUpdateDataset(ctx, d.id, insert, update, del)
570564
if err != nil {
571565
return fmt.Errorf("failed to batch update dataset: %w", err)
572566
}
573567

574-
// TODO(rarguelloF): migrate to new backend response format so this is not necessary
575-
if len(insertOldIDs) != len(newRecordIDs) {
576-
return fmt.Errorf("received a different number of new records than what it was sent (want: %d, got :%d)", len(insertOldIDs), len(newRecordIDs))
577-
}
578-
579-
// FIXME(rarguelloF): we don't get version numbers in responses to deletion requests
568+
// A batch containing only deletions comes back without a version.
580569
if newVersion > 0 {
581570
d.version = newVersion
582571
} else {
583572
d.version++
584573
}
585-
586-
// update the inserted records with the new IDs generated by the backend
587-
for i, newID := range newRecordIDs {
588-
oldID := insertOldIDs[i]
589-
d.appendRecords[oldID].id = newID
590-
}
591574
d.appendRecords = make(map[string]*Record)
592575
d.updateRecords = make(map[string]*RecordUpdate)
593576
d.deleteRecords = make(map[string]struct{})

llmobs/dataset/dataset_test.go

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1559,21 +1559,35 @@ func handleMockDatasetBatchUpdate(w http.ResponseWriter, r *http.Request) {
15591559
})
15601560
}
15611561

1562-
// Add inserted records (these get new IDs)
1563-
for i, insertRec := range attrs.InsertRecords {
1564-
newID := fmt.Sprintf("new-record-id-%d", i+1)
1562+
// Inserted records keep the id the client supplied, which is what the
1563+
// backend does with it.
1564+
for _, insertRec := range attrs.InsertRecords {
15651565
response.Data = append(response.Data, llmobstransport.ResponseData[llmobstransport.DatasetRecordView]{
1566-
ID: newID,
1566+
ID: insertRec.ID,
15671567
Type: "dataset_records",
15681568
Attributes: llmobstransport.DatasetRecordView{
1569-
ID: newID,
1569+
ID: insertRec.ID,
15701570
Input: insertRec.Input,
15711571
ExpectedOutput: insertRec.ExpectedOutput,
15721572
Version: 2,
15731573
},
15741574
})
15751575
}
15761576

1577+
// Deletions come back as entries too, carrying the new version. Emitting
1578+
// them is what makes this mock able to reproduce a response longer than
1579+
// insert+update.
1580+
for _, deletedID := range attrs.DeleteRecords {
1581+
response.Data = append(response.Data, llmobstransport.ResponseData[llmobstransport.DatasetRecordView]{
1582+
ID: deletedID,
1583+
Type: "dataset_records",
1584+
Attributes: llmobstransport.DatasetRecordView{
1585+
ID: deletedID,
1586+
Version: 2,
1587+
},
1588+
})
1589+
}
1590+
15771591
respData, _ := json.Marshal(response)
15781592
w.Header().Set("Content-Type", "application/json")
15791593
w.WriteHeader(http.StatusOK)
@@ -1716,3 +1730,47 @@ func TestLargeDatasetPushChunking(t *testing.T) {
17161730
"each batch_update request body (%d bytes) must be within the %d byte limit to avoid EVP proxy rejection", size, batchUpdateThreshold)
17171731
}
17181732
}
1733+
1734+
// A push that deletes and inserts in the same batch gets back one entry per
1735+
// affected record, deletions included, so the response is longer than
1736+
// insert+update. Counting it and requiring a match failed the push even though
1737+
// the backend had applied it.
1738+
func TestDatasetPushDeleteAndInsertTogether(t *testing.T) {
1739+
testTracer(t)
1740+
ctx := context.Background()
1741+
1742+
ds, err := Create(ctx, "test-dataset", []Record{
1743+
{Input: map[string]any{"question": "keep"}, ExpectedOutput: "1"},
1744+
{Input: map[string]any{"question": "drop"}, ExpectedOutput: "2"},
1745+
})
1746+
require.NoError(t, err)
1747+
1748+
ds.Delete(1)
1749+
ds.Append(Record{Input: map[string]any{"question": "added"}, ExpectedOutput: "3"})
1750+
1751+
require.NoError(t, ds.Push(ctx))
1752+
assert.Equal(t, 2, ds.Len())
1753+
}
1754+
1755+
// Records keep the id they were given locally, because Push sends it and the
1756+
// backend persists it. Callers hold onto these ids to update or delete later,
1757+
// so they have to stay valid across a push.
1758+
func TestDatasetPushKeepsClientRecordIDs(t *testing.T) {
1759+
testTracer(t)
1760+
ctx := context.Background()
1761+
1762+
ds, err := Create(ctx, "test-dataset", nil)
1763+
require.NoError(t, err)
1764+
1765+
ds.Append(Record{Input: map[string]any{"question": "one"}, ExpectedOutput: "1"})
1766+
rec, ok := ds.Record(0)
1767+
require.True(t, ok)
1768+
idBefore := rec.ID()
1769+
require.NotEmpty(t, idBefore)
1770+
1771+
require.NoError(t, ds.Push(ctx))
1772+
1773+
rec, ok = ds.Record(0)
1774+
require.True(t, ok)
1775+
assert.Equal(t, idBefore, rec.ID(), "the record id must survive the push")
1776+
}

0 commit comments

Comments
 (0)