Skip to content

Commit 6ada504

Browse files
authored
feat: Add filtered collection truncate (#5097)
## Relevant issue(s) Resolves #5117 ## Description Adds an optional filter to the existing collection truncate operation so callers can permanently remove matching documents from the local node without introducing another delete command. Truncating without a filter keeps the existing collection-wide behavior. Filtered truncate removes the selected document data and associated local state, including heads, indexes, ID mappings, signatures, encryption blocks, and searchable-encryption records. The optional `pruneHistory` setting also removes history blocks that are not shared by another document. The filter is supported by the Go, HTTP, CLI, and C APIs, and GraphQL now exposes `truncate_<Collection>` mutations. No new dependencies are added. Limitations: - Truncate is local and does not replicate. Propagate soft-delete tombstones first when peers must not reintroduce the data. - History pruning is rejected for branchable collections until collection-DAG pruning semantics are defined. - Filtered truncate runs in bounded chunks and may be partially applied if a later chunk fails. - Filtered truncate cannot run inside a caller-supplied transaction. GraphQL truncate must be a standalone mutation. ## Tasks - [x] I made sure the code is well commented, particularly hard-to-understand areas. - [x] I made sure the repository-held documentation is changed accordingly. - [x] I made sure the pull request title adheres to the conventional commit style (the subset used in the project can be found in [tools/configs/chglog/config.yml](tools/configs/chglog/config.yml)). - [x] I made sure to discuss its limitations such as threats to validity, vulnerability to mistake and misuse, robustness to invalidation of assumptions, resource requirements, ... ## How has this been tested? - Unit coverage for filtering, storage cleanup, shared history, indexes, ID mappings, signatures, encryption, searchable encryption, transaction chunking, and LevelDB behavior. - Integration coverage for collection and GraphQL truncate flows, DAC authorization, and searchable encryption. - Race tests for the changed database and GraphQL paths. - Repository lint and generated documentation checks. Specify the platform(s) on which this was tested: - MacOS
1 parent 3e7f8ee commit 6ada504

47 files changed

Lines changed: 2459 additions & 201 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cbindings/collection_truncate.go

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,49 @@ import (
2121

2222
"github.com/sourcenetwork/defradb/client/options"
2323
acpIdentity "github.com/sourcenetwork/defradb/internal/identity"
24+
"github.com/sourcenetwork/defradb/internal/utils"
2425
)
2526

2627
//export TruncateCollection
2728
func TruncateCollection(
2829
nodePtr C.uintptr_t,
2930
opts C.CollectionOptions,
3031
identityPtr C.uintptr_t,
32+
) C.Result {
33+
return truncateCollection(nodePtr, opts, identityPtr, nil)
34+
}
35+
36+
// TruncateCollectionWithFilter preserves TruncateCollection's v1 C ABI.
37+
//
38+
// Deprecated: This compatibility function will be removed in v2, when TruncateCollection
39+
// accepts filtered-truncate options.
40+
//
41+
//export TruncateCollectionWithFilter
42+
func TruncateCollectionWithFilter(
43+
nodePtr C.uintptr_t,
44+
opts C.CollectionOptions,
45+
identityPtr C.uintptr_t,
46+
filterJSON *C.char,
47+
) C.Result {
48+
if filterJSON == nil {
49+
return returnC(returnGoC(1, "filter is required", ""))
50+
}
51+
filter, err := utils.DecodeJSONFilter([]byte(C.GoString(filterJSON)))
52+
if err != nil {
53+
return returnC(returnGoC(1, err.Error(), ""))
54+
}
55+
// JSON null must not fall through to an unfiltered collection truncate.
56+
if filter == nil {
57+
return returnC(returnGoC(1, "filter cannot be null", ""))
58+
}
59+
return truncateCollection(nodePtr, opts, identityPtr, filter)
60+
}
61+
62+
func truncateCollection(
63+
nodePtr C.uintptr_t,
64+
opts C.CollectionOptions,
65+
identityPtr C.uintptr_t,
66+
filter any,
3167
) C.Result {
3268
ctx := context.Background()
3369

@@ -54,7 +90,11 @@ func TruncateCollection(
5490
return returnC(returnGoC(1, err.Error(), ""))
5591
}
5692

57-
err = col.Truncate(ctx, options.WithIdentity(options.TruncateCollection(), ident))
93+
truncateOpts := options.WithIdentity(options.TruncateCollection(), ident)
94+
if filter != nil {
95+
truncateOpts.SetFilter(filter)
96+
}
97+
err = col.Truncate(ctx, truncateOpts)
5898
if err != nil {
5999
return returnC(returnGoC(1, err.Error(), ""))
60100
}

cbindings/wrapper_collection.go

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ extern Result NewEncryptedIndex(uintptr_t nodePtr, char* collectionName, char* f
2323
extern Result ListEncryptedIndexes(uintptr_t nodePtr, char* collectionName, uintptr_t identityPtr);
2424
extern Result DeleteEncryptedIndex(uintptr_t nodePtr, char* collectionName, char* fieldName, uintptr_t identity);
2525
extern Result TruncateCollection(uintptr_t nodePtr, CollectionOptions options, uintptr_t identityPtr);
26+
extern Result TruncateCollectionWithFilter(uintptr_t nodePtr, CollectionOptions options, uintptr_t identityPtr,
27+
char* filterJSON);
2628
extern void FreeIdentity(uintptr_t identityPtr);
2729
*/
2830
import "C"
@@ -311,11 +313,12 @@ func (c *Collection) Truncate(
311313
ctx context.Context, opts ...options.Enumerable[options.TruncateCollectionOptions],
312314
) error {
313315
ctx = setCtxTxnFromCollection(ctx, c)
316+
opt := utils.NewOptions(opts...)
314317

315318
cName := C.CString(c.def.Name)
316319
cVersion := C.CString("")
317320
cCollectionID := C.CString("")
318-
cIdentity := optionToUintptr(utils.NewOptions(opts...).GetIdentity())
321+
cIdentity := optionToUintptr(opt.GetIdentity())
319322

320323
defer C.free(unsafe.Pointer(cName))
321324
defer C.free(unsafe.Pointer(cVersion))
@@ -329,13 +332,19 @@ func (c *Collection) Truncate(
329332
copts.getInactive = 0
330333

331334
callHandle := getNodeOrTxnHandle(c.w.handle, ctx)
332-
res := ConvertAndFreeCResult(
333-
C.TruncateCollection(
334-
callHandle,
335-
copts,
336-
cIdentity,
337-
),
338-
)
335+
var result C.Result
336+
if opt.Filter == nil {
337+
result = C.TruncateCollection(callHandle, copts, cIdentity)
338+
} else {
339+
filterJSON, err := json.Marshal(opt.Filter)
340+
if err != nil {
341+
return err
342+
}
343+
cFilter := C.CString(string(filterJSON))
344+
defer C.free(unsafe.Pointer(cFilter))
345+
result = C.TruncateCollectionWithFilter(callHandle, copts, cIdentity, cFilter)
346+
}
347+
res := ConvertAndFreeCResult(result)
339348
if res.Status != 0 {
340349
return errors.New(res.Error)
341350
}

cli/collection_truncate.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,24 @@ package cli
1212

1313
import (
1414
"context"
15+
"errors"
1516

1617
"github.com/spf13/cobra"
1718

1819
"github.com/sourcenetwork/defradb/client"
1920
"github.com/sourcenetwork/defradb/client/options"
2021
"github.com/sourcenetwork/defradb/internal/identity"
22+
"github.com/sourcenetwork/defradb/internal/utils"
2123
)
2224

2325
func MakeCollectionTruncateCommand(ctx context.Context) *cobra.Command {
26+
var filter string
2427
var cmd = &cobra.Command{
2528
Use: "truncate",
2629
Short: "Truncate the given collection",
27-
Long: `Truncate the given collection, removing all document data within it from the local node.
28-
Does not propagate the deletion to other Defra nodes in the peer network.`,
30+
Long: `Truncate the given collection, removing document data from the local node.
31+
Without a filter all documents are removed. With a filter only matching documents and their
32+
unshared history are removed. Changes do not propagate to other nodes.`,
2933
Args: cobra.ExactArgs(0),
3034
RunE: func(cmd *cobra.Command, args []string) error {
3135
col, ok := tryGetContextCollection(cmd)
@@ -34,9 +38,28 @@ func MakeCollectionTruncateCommand(ctx context.Context) *cobra.Command {
3438
}
3539

3640
opt := options.WithIdentity(options.TruncateCollection(), identity.FromContext(cmd.Context()))
41+
if filter != "" {
42+
filterValue, err := parseTruncateFilter(filter)
43+
if err != nil {
44+
return NewErrParsingArgument("filter", err)
45+
}
46+
opt.SetFilter(filterValue)
47+
}
3748
return col.Truncate(cmd.Context(), opt)
3849
},
3950
}
51+
cmd.Flags().StringVar(&filter, "filter", "", "Document filter")
4052
setCollectionSelectorFlags(cmd)
4153
return cmd
4254
}
55+
56+
func parseTruncateFilter(value string) (any, error) {
57+
filter, err := utils.DecodeJSONFilter([]byte(value))
58+
if err != nil {
59+
return nil, err
60+
}
61+
if filter == nil {
62+
return nil, errors.New("filter cannot be null")
63+
}
64+
return filter, nil
65+
}

cli/collection_truncate_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright 2026 Democratized Data Foundation
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the file licenses/BSL.txt.
5+
//
6+
// As of the Change Date specified in that file, in accordance with
7+
// the Business Source License, use of this software will be governed
8+
// by the Apache License, Version 2.0, included in the file
9+
// licenses/APL.txt.
10+
11+
package cli
12+
13+
import (
14+
"testing"
15+
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
func TestParseTruncateFilter(t *testing.T) {
20+
filter, err := parseTruncateFilter(`{"name":{"_eq":"Alice"}}`)
21+
require.NoError(t, err)
22+
require.Equal(t, map[string]any{"name": map[string]any{"_eq": "Alice"}}, filter)
23+
24+
filter, err = parseTruncateFilter(`{"age":{"_eq":9007199254740993}}`)
25+
require.NoError(t, err)
26+
require.Equal(t, map[string]any{"age": map[string]any{"_eq": int64(9007199254740993)}}, filter)
27+
28+
_, err = parseTruncateFilter("null")
29+
require.EqualError(t, err, "filter cannot be null")
30+
31+
_, err = parseTruncateFilter("{")
32+
require.Error(t, err)
33+
}

client/collection.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,10 @@ type Collection interface {
5959
// will be created.
6060
SaveDocument(ctx context.Context, doc *Document, opts ...options.Enumerable[options.SaveDocumentOptions]) error
6161

62-
// DeleteDocument will attempt to delete a document by DocID.
62+
// DeleteDocument soft-deletes a document by DocID and publishes the deletion for replication.
6363
//
64-
// Will return true if a deletion is successful, and return false along with an error
65-
// if it cannot. If the document doesn't exist, then it will return false and a ErrDocumentNotFound error.
66-
// This operation will hard-delete all state relating to the given DocID.
67-
// This includes data, block, and head storage.
64+
// The document data and commit history remain available locally. If the document does not exist,
65+
// this returns false and an ErrDocumentNotFound error.
6866
DeleteDocument(
6967
ctx context.Context,
7068
docID DocID,
@@ -167,7 +165,9 @@ type Collection interface {
167165
opts ...options.Enumerable[options.ListCollectionEncryptedIndexesOptions],
168166
) ([]EncryptedIndexDescription, error)
169167

170-
// Truncate this collection, permanently deleting all document state on this node.
168+
// Truncate permanently deletes document state from this collection on this node.
169+
// Set a filter in the options to target matching documents; without one, all document state is deleted.
170+
// Filtered truncation can also remove unshared document history.
171171
//
172172
// Changes made by this call will not impact other nodes, and cannot be synced to them over the P2P
173173
// system.

client/options/collection.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,8 @@ func (b *ExistsDocumentOptionsBuilder) SetIdentity(id identity.Identity) *Exists
410410
type TruncateCollectionOptions struct {
411411
// Identity is the identity of the actor performing the operation.
412412
Identity immutable.Option[identity.Identity]
413+
// Filter limits the truncate to matching documents. A nil filter truncates the full collection.
414+
Filter any
413415
}
414416

415417
// GetIdentity returns the identity for the operation.
@@ -435,6 +437,14 @@ func (b *TruncateCollectionOptionsBuilder) SetIdentity(id identity.Identity) *Tr
435437
return b
436438
}
437439

440+
// SetFilter limits the truncate to matching documents.
441+
func (b *TruncateCollectionOptionsBuilder) SetFilter(filter any) *TruncateCollectionOptionsBuilder {
442+
b.append(func(opts *TruncateCollectionOptions) {
443+
opts.Filter = filter
444+
})
445+
return b
446+
}
447+
438448
// NewEncryptedIndexOptions contains options for NewEncryptedIndex operation.
439449
type NewEncryptedIndexOptions struct {
440450
Identity immutable.Option[identity.Identity]

client/request/mutation.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const (
1818
UpdateObjects
1919
DeleteObjects
2020
UpsertObjects
21+
TruncateObjects
2122
)
2223

2324
// ObjectMutation is a field on the `mutation` operation of a graphql request. It includes

docs/website/references/cli/defradb_client_collection_truncate.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ Truncate the given collection
44

55
### Synopsis
66

7-
Truncate the given collection, removing all document data within it from the local node.
8-
Does not propagate the deletion to other Defra nodes in the peer network.
7+
Truncate the given collection, removing document data from the local node.
8+
Without a filter all documents are removed. With a filter only matching documents and their
9+
unshared history are removed. Changes do not propagate to other nodes.
910

1011
```
1112
defradb client collection truncate [flags]
@@ -16,6 +17,7 @@ defradb client collection truncate [flags]
1617
```
1718
--collection-id string Collection ID
1819
--collection-name string Collection name
20+
--filter string Document filter
1921
--get-inactive Get inactive collections as well as active
2022
-h, --help help for truncate
2123
--version-id string Collection version ID

docs/website/references/http/openapi.json

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,12 @@
677677
},
678678
"type": "object"
679679
},
680+
"truncate_collection": {
681+
"properties": {
682+
"filter": {}
683+
},
684+
"type": "object"
685+
},
680686
"update_collection": {
681687
"properties": {
682688
"filter": {},
@@ -1925,7 +1931,7 @@
19251931
},
19261932
"/collections/{name}/truncate": {
19271933
"delete": {
1928-
"description": "Truncate a collection, removing all document data within it from the server. Does not propagate the deletion to other Defra nodes in the network.",
1934+
"description": "Permanently remove all or filtered document data from a collection on this server. Does not propagate the deletion to other Defra nodes in the network.",
19291935
"operationId": "truncate",
19301936
"parameters": [
19311937
{
@@ -1938,6 +1944,15 @@
19381944
}
19391945
}
19401946
],
1947+
"requestBody": {
1948+
"content": {
1949+
"application/json": {
1950+
"schema": {
1951+
"$ref": "#/components/schemas/truncate_collection"
1952+
}
1953+
}
1954+
}
1955+
},
19411956
"responses": {
19421957
"200": {
19431958
"$ref": "#/components/responses/success"

http/client_collection.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,20 @@ func (c *Collection) Truncate(
212212

213213
methodURL := c.http.apiURL.JoinPath("collections", c.Version().Name, "truncate")
214214

215-
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, methodURL.String(), nil)
215+
var body *bytes.Buffer
216+
if opt.Filter != nil {
217+
data, err := json.Marshal(TruncateCollectionRequest{
218+
Filter: opt.Filter,
219+
})
220+
if err != nil {
221+
return err
222+
}
223+
body = bytes.NewBuffer(data)
224+
} else {
225+
body = bytes.NewBuffer(nil)
226+
}
227+
228+
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, methodURL.String(), body)
216229
if err != nil {
217230
return err
218231
}

0 commit comments

Comments
 (0)