Skip to content

Commit c16e23c

Browse files
authored
CBG-4751: Allow CV to be used as OCC value in REST API for document writes (updates/deletes) (#7693)
1 parent c0c6d38 commit c16e23c

32 files changed

+506
-190
lines changed

base/util.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1825,3 +1825,18 @@ func KeysPresent[K comparable, V any](m map[K]V, keys []K) []K {
18251825
}
18261826
return result
18271827
}
1828+
1829+
// IsRevTreeID checks if the string looks like a RevTree ID.
1830+
func IsRevTreeID(s string) bool {
1831+
// If we scan forwards past each digit until we hit `-`, we know this is a RevTree ID.
1832+
for i, r := range s {
1833+
if r >= '0' && r <= '9' {
1834+
continue
1835+
}
1836+
if r == '-' && i > 0 {
1837+
return true
1838+
}
1839+
break
1840+
}
1841+
return false
1842+
}

base/util_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1854,3 +1854,29 @@ func TestKeysPresent(t *testing.T) {
18541854
})
18551855
}
18561856
}
1857+
1858+
func TestIsRevTreeID(t *testing.T) {
1859+
tests := []struct {
1860+
value string
1861+
expected bool
1862+
}{
1863+
{"1-abc", true},
1864+
{"1-abc123", true},
1865+
{"1-abc123def456", true},
1866+
{"1-abc123def456ghi789", true},
1867+
{"1-abc123def456ghi789jkl012", true},
1868+
{"123-abc123", true},
1869+
{"1234567890-a", true},
1870+
{"0-a", true},
1871+
{"1", false},
1872+
{"abc", false},
1873+
{"a-abc", false},
1874+
{"-abc", false},
1875+
{"1a@bc", false},
1876+
}
1877+
for _, tt := range tests {
1878+
t.Run(tt.value, func(t *testing.T) {
1879+
assert.Equalf(t, tt.expected, IsRevTreeID(tt.value), "IsRevTreeID(%v)", tt.value)
1880+
})
1881+
}
1882+
}

db/blip_handler.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,7 @@ func (bh *blipHandler) handleProposeChanges(rq *blip.Message) error {
852852

853853
changeIsVector := false
854854
if versionVectorProtocol {
855+
// TODO: CBG-4812 Use base.IsRevTreeID
855856
changeIsVector = strings.Contains(rev, "@")
856857
}
857858
if versionVectorProtocol && changeIsVector {
@@ -1094,6 +1095,7 @@ func (bh *blipHandler) processRev(rq *blip.Message, stats *processRevStats) (err
10941095
var incomingHLV *HybridLogicalVector
10951096
// Build history/HLV
10961097
var legacyRevList []string
1098+
// TODO: CBG-4812 Use base.IsRevTreeID
10971099
changeIsVector := strings.Contains(rev, "@")
10981100
if !bh.useHLV() || !changeIsVector {
10991101
newDoc.RevID = rev

db/changes_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,7 @@ func TestActiveOnlyCacheUpdate(t *testing.T) {
418418
// Tombstone 5 documents
419419
for i := 2; i <= 6; i++ {
420420
key := fmt.Sprintf("%s_%d", t.Name(), i)
421-
_, _, err = collection.DeleteDoc(ctx, key, revId)
421+
_, _, err = collection.DeleteDoc(ctx, key, DocVersion{RevTreeID: revId})
422422
require.NoError(t, err, "Couldn't delete document")
423423
}
424424

db/crud.go

Lines changed: 49 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -310,13 +310,13 @@ func (db *DatabaseCollectionWithUser) Get1xBody(ctx context.Context, docid strin
310310
}
311311

312312
// Get Rev with all-or-none history based on specified 'history' flag
313-
func (db *DatabaseCollectionWithUser) Get1xRevBody(ctx context.Context, docid, revid string, history bool, attachmentsSince []string) (Body, error) {
313+
func (db *DatabaseCollectionWithUser) Get1xRevBody(ctx context.Context, docid, revOrCV string, history bool, attachmentsSince []string) (Body, error) {
314314
maxHistory := 0
315315
if history {
316316
maxHistory = math.MaxInt32
317317
}
318318

319-
return db.Get1xRevBodyWithHistory(ctx, docid, revid, Get1xRevBodyOptions{
319+
return db.Get1xRevBodyWithHistory(ctx, docid, revOrCV, Get1xRevBodyOptions{
320320
MaxHistory: maxHistory,
321321
HistoryFrom: nil,
322322
AttachmentsSince: attachmentsSince,
@@ -333,8 +333,8 @@ type Get1xRevBodyOptions struct {
333333
}
334334

335335
// Retrieves rev with request history specified as collection of revids (historyFrom)
336-
func (db *DatabaseCollectionWithUser) Get1xRevBodyWithHistory(ctx context.Context, docid, revtreeid string, opts Get1xRevBodyOptions) (Body, error) {
337-
rev, err := db.getRev(ctx, docid, revtreeid, opts.MaxHistory, opts.HistoryFrom)
336+
func (db *DatabaseCollectionWithUser) Get1xRevBodyWithHistory(ctx context.Context, docid, revOrCV string, opts Get1xRevBodyOptions) (Body, error) {
337+
rev, err := db.getRev(ctx, docid, revOrCV, opts.MaxHistory, opts.HistoryFrom)
338338
if err != nil {
339339
return nil, err
340340
}
@@ -1093,7 +1093,11 @@ func (db *DatabaseCollectionWithUser) Put(ctx context.Context, docid string, bod
10931093
generation++
10941094
delete(body, BodyRev)
10951095

1096-
// Not extracting it yet because we need this property around to generate a rev ID
1096+
// remove CV before RevTreeID generation
1097+
matchCV, _ := body[BodyCV].(string)
1098+
delete(body, BodyCV)
1099+
1100+
// Not extracting it yet because we need this property around to generate a RevTreeID
10971101
deleted, _ := body[BodyDeleted].(bool)
10981102

10991103
expiry, err := body.ExtractExpiry()
@@ -1146,21 +1150,42 @@ func (db *DatabaseCollectionWithUser) Put(ctx context.Context, docid string, bod
11461150
}
11471151

11481152
var conflictErr error
1149-
// Make sure matchRev matches an existing leaf revision:
1150-
if matchRev == "" {
1151-
matchRev = doc.CurrentRev
1152-
if matchRev != "" {
1153-
// PUT with no parent rev given, but there is an existing current revision.
1154-
// This is OK as long as the current one is deleted.
1155-
if !doc.History[matchRev].Deleted {
1156-
conflictErr = base.HTTPErrorf(http.StatusConflict, "Document exists")
1157-
} else {
1158-
generation, _ = ParseRevID(ctx, matchRev)
1159-
generation++
1153+
1154+
// OCC check of matchCV against CV on the doc
1155+
if matchCV != "" {
1156+
if matchCV == doc.HLV.GetCurrentVersionString() {
1157+
// set matchRev to the current revision ID and allow existing codepaths to perform RevTree-based update.
1158+
matchRev = doc.CurrentRev
1159+
// bump generation based on retrieved RevTree ID
1160+
generation, _ = ParseRevID(ctx, matchRev)
1161+
generation++
1162+
} else if doc.hasFlag(channels.Conflict | channels.Hidden) {
1163+
// Can't use CV as an OCC Value when a document is in conflict, or we're updating the non-winning leaf
1164+
// There's no way to get from a given old CV to a RevTreeID to perform the update correctly, since we don't maintain linear history for a given SourceID.
1165+
// Reject the request and force the user to resolve the conflict using RevTree IDs which does have linear history available.
1166+
conflictErr = base.HTTPErrorf(http.StatusBadRequest, "Cannot use CV to modify a document in conflict - resolve first with RevTree ID")
1167+
} else {
1168+
conflictErr = base.HTTPErrorf(http.StatusConflict, "Document revision conflict")
1169+
}
1170+
}
1171+
1172+
if conflictErr == nil {
1173+
// Make sure matchRev matches an existing leaf revision:
1174+
if matchRev == "" {
1175+
matchRev = doc.CurrentRev
1176+
if matchRev != "" {
1177+
// PUT with no parent rev given, but there is an existing current revision.
1178+
// This is OK as long as the current one is deleted.
1179+
if !doc.History[matchRev].Deleted {
1180+
conflictErr = base.HTTPErrorf(http.StatusConflict, "Document exists")
1181+
} else {
1182+
generation, _ = ParseRevID(ctx, matchRev)
1183+
generation++
1184+
}
11601185
}
1186+
} else if !doc.History.isLeaf(matchRev) || db.IsIllegalConflict(ctx, doc, matchRev, deleted, false, nil) {
1187+
conflictErr = base.HTTPErrorf(http.StatusConflict, "Document revision conflict")
11611188
}
1162-
} else if !doc.History.isLeaf(matchRev) || db.IsIllegalConflict(ctx, doc, matchRev, deleted, false, nil) {
1163-
conflictErr = base.HTTPErrorf(http.StatusConflict, "Document revision conflict")
11641189
}
11651190

11661191
// Make up a new _rev, and add it to the history:
@@ -2893,7 +2918,8 @@ func (db *DatabaseCollectionWithUser) MarkPrincipalsChanged(ctx context.Context,
28932918

28942919
// Creates a new document, assigning it a random doc ID.
28952920
func (db *DatabaseCollectionWithUser) Post(ctx context.Context, body Body) (docid string, rev string, doc *Document, err error) {
2896-
if body[BodyRev] != nil {
2921+
// This error isn't very accurate, you just _cannot_ use POST to update an existing document - even if it does exist. We don't even bother checking for existence.
2922+
if body[BodyRev] != nil || body[BodyCV] != nil {
28972923
return "", "", nil, base.HTTPErrorf(http.StatusNotFound, "No previous revision to replace")
28982924
}
28992925

@@ -2914,8 +2940,9 @@ func (db *DatabaseCollectionWithUser) Post(ctx context.Context, body Body) (doci
29142940
}
29152941

29162942
// Deletes a document, by adding a new revision whose _deleted property is true.
2917-
func (db *DatabaseCollectionWithUser) DeleteDoc(ctx context.Context, docid string, revid string) (string, *Document, error) {
2918-
body := Body{BodyDeleted: true, BodyRev: revid}
2943+
func (db *DatabaseCollectionWithUser) DeleteDoc(ctx context.Context, docid string, docVersion DocVersion) (string, *Document, error) {
2944+
versionKey, versionStr := docVersion.Body1xKVPair()
2945+
body := Body{BodyDeleted: true, versionKey: versionStr}
29192946
newRevID, doc, err := db.Put(ctx, docid, body)
29202947
return newRevID, doc, err
29212948
}
@@ -3342,6 +3369,7 @@ func (db *DatabaseCollectionWithUser) CheckProposedVersion(ctx context.Context,
33423369
// previousRev may be revTreeID or version
33433370
var previousVersion Version
33443371
previousRevFormat := "version"
3372+
// TODO: CBG-4812 Use base.IsRevTreeID
33453373
if !strings.Contains(previousRev, "@") {
33463374
previousRevFormat = "revTreeID"
33473375
}

db/crud_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1158,7 +1158,7 @@ func TestGet1xRevAndChannels(t *testing.T) {
11581158
assert.Equal(t, []interface{}{"a"}, revisions[RevisionsIds])
11591159

11601160
// Delete the document, creating tombstone revision rev3
1161-
rev3, _, err := collection.DeleteDoc(ctx, docId, rev2)
1161+
rev3, _, err := collection.DeleteDoc(ctx, docId, DocVersion{RevTreeID: rev2})
11621162
require.NoError(t, err)
11631163
bodyBytes, removed, err = collection.get1xRevFromDoc(ctx, doc2, rev3, true)
11641164
assert.False(t, removed)

db/database_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -731,7 +731,7 @@ func TestGetDeleted(t *testing.T) {
731731
rev1id, _, err := collection.Put(ctx, "doc1", body)
732732
assert.NoError(t, err, "Put")
733733

734-
rev2id, _, err := collection.DeleteDoc(ctx, "doc1", rev1id)
734+
rev2id, _, err := collection.DeleteDoc(ctx, "doc1", DocVersion{RevTreeID: rev1id})
735735
assert.NoError(t, err, "DeleteDoc")
736736

737737
// Get the deleted doc with its history; equivalent to GET with ?revs=true
@@ -1395,7 +1395,7 @@ func TestAllDocsOnly(t *testing.T) {
13951395
}
13961396

13971397
// Now delete one document and try again:
1398-
_, _, err = collection.DeleteDoc(ctx, ids[23].DocID, ids[23].RevID)
1398+
_, _, err = collection.DeleteDoc(ctx, ids[23].DocID, DocVersion{RevTreeID: ids[23].RevID})
13991399
assert.NoError(t, err, "Couldn't delete doc 23")
14001400

14011401
alldocs, err = allDocIDs(ctx, collection.DatabaseCollection)
@@ -1726,7 +1726,7 @@ func TestConflicts(t *testing.T) {
17261726
)
17271727

17281728
// Delete 2-b; verify this makes 2-a current:
1729-
rev3, _, err := collection.DeleteDoc(ctx, "doc", "2-b")
1729+
rev3, _, err := collection.DeleteDoc(ctx, "doc", DocVersion{RevTreeID: "2-b"})
17301730
assert.NoError(t, err, "delete 2-b")
17311731

17321732
rawBody, _, _ = collection.dataStore.GetRaw("doc")
@@ -3369,7 +3369,7 @@ func TestTombstoneCompactionStopWithManager(t *testing.T) {
33693369
docID := fmt.Sprintf("doc%d", i)
33703370
rev, _, err := collection.Put(ctx, docID, Body{})
33713371
assert.NoError(t, err)
3372-
_, _, err = collection.DeleteDoc(ctx, docID, rev)
3372+
_, _, err = collection.DeleteDoc(ctx, docID, DocVersion{RevTreeID: rev})
33733373
assert.NoError(t, err)
33743374
}
33753375

@@ -3780,7 +3780,7 @@ func TestImportCompactPanic(t *testing.T) {
37803780
// Create a document, then delete it, to create a tombstone
37813781
rev, doc, err := collection.Put(ctx, "test", Body{})
37823782
require.NoError(t, err)
3783-
_, _, err = collection.DeleteDoc(ctx, doc.ID, rev)
3783+
_, _, err = collection.DeleteDoc(ctx, doc.ID, DocVersion{RevTreeID: rev})
37843784
require.NoError(t, err)
37853785
require.NoError(t, collection.WaitForPendingChanges(ctx))
37863786

db/document.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1484,3 +1484,38 @@ func (doc *Document) ExtractDocVersion() DocVersion {
14841484
CV: *doc.HLV.ExtractCurrentVersionFromHLV(),
14851485
}
14861486
}
1487+
1488+
// DocVersion represents a specific version of a document in an revID/HLV agnostic manner.
1489+
// Users should access the properties via the CV and RevTreeID methods, to ensure there's a check that the specific version type is not empty.
1490+
type DocVersion struct {
1491+
RevTreeID string
1492+
CV Version
1493+
}
1494+
1495+
// String implements fmt.Stringer
1496+
func (v DocVersion) String() string {
1497+
return fmt.Sprintf("RevTreeID:%s,CV:%#v", v.RevTreeID, v.CV)
1498+
}
1499+
1500+
// GoString implements fmt.GoStringer
1501+
func (v DocVersion) GoString() string {
1502+
return fmt.Sprintf("DocVersion{RevTreeID:%s,CV:%#v}", v.RevTreeID, v.CV)
1503+
}
1504+
1505+
// GetRevTreeID returns the Revision Tree ID of the document version, and a bool indicating whether it is present.
1506+
func (v DocVersion) GetRevTreeID() (string, bool) {
1507+
return v.RevTreeID, v.RevTreeID != ""
1508+
}
1509+
1510+
// GetCV returns the Current Version of the document, and a bool indicating whether it is present.
1511+
func (v DocVersion) GetCV() (Version, bool) {
1512+
return v.CV, !v.CV.IsEmpty()
1513+
}
1514+
1515+
// Body1xKVPair returns the key and value to use in a 1.x-style document body for the given DocVersion.
1516+
func (d DocVersion) Body1xKVPair() (bodyVersionKey, bodyVersionStr string) {
1517+
if cv, ok := d.GetCV(); ok {
1518+
return BodyCV, cv.String()
1519+
}
1520+
return BodyRev, d.RevTreeID
1521+
}

db/utilities_hlv_testing.go

Lines changed: 0 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ package db
1212

1313
import (
1414
"context"
15-
"fmt"
1615
"strconv"
1716
"strings"
1817
"testing"
@@ -22,59 +21,6 @@ import (
2221
"github.com/stretchr/testify/require"
2322
)
2423

25-
// DocVersion represents a specific version of a document in an revID/HLV agnostic manner.
26-
type DocVersion struct {
27-
RevTreeID string
28-
CV Version
29-
}
30-
31-
func (v DocVersion) String() string {
32-
return fmt.Sprintf("RevTreeID:%s,CV:%#v", v.RevTreeID, v.CV)
33-
}
34-
35-
func (v DocVersion) GoString() string {
36-
return fmt.Sprintf("DocVersion{RevTreeID:%s,CV:%#v}", v.RevTreeID, v.CV)
37-
}
38-
39-
func (v DocVersion) DocVersionRevTreeEqual(o DocVersion) bool {
40-
if v.RevTreeID != o.RevTreeID {
41-
return false
42-
}
43-
return true
44-
}
45-
46-
func (v DocVersion) GetRev(useHLV bool) string {
47-
if useHLV {
48-
if v.CV.SourceID == "" {
49-
return ""
50-
}
51-
return v.CV.String()
52-
} else {
53-
return v.RevTreeID
54-
}
55-
}
56-
57-
// RevIDGeneration returns the Rev ID generation for the current version
58-
func (v *DocVersion) RevIDGeneration() int {
59-
if v == nil {
60-
return 0
61-
}
62-
gen, err := strconv.ParseInt(strings.Split(v.RevTreeID, "-")[0], 10, 64)
63-
if err != nil {
64-
base.AssertfCtx(context.TODO(), "Error parsing generation from rev ID %q: %v", v.RevTreeID, err)
65-
return 0
66-
}
67-
return int(gen)
68-
}
69-
70-
// RevIDDigest returns the Rev ID digest for the current version
71-
func (v *DocVersion) RevIDDigest() string {
72-
if v == nil {
73-
return ""
74-
}
75-
return strings.Split(v.RevTreeID, "-")[1]
76-
}
77-
7824
// HLVAgent performs HLV updates directly (not via SG) for simulating/testing interaction with non-SG HLV agents
7925
type HLVAgent struct {
8026
t *testing.T

docs/api/components/parameters.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ DB-config-If-Match:
1313
schema:
1414
type: string
1515
description: 'If set to a configuration''s Etag value, enables optimistic concurrency control for the request. Returns HTTP 412 if another update happened underneath this one.'
16-
If-Match:
16+
Document-If-Match:
1717
name: If-Match
1818
in: header
1919
required: false
2020
schema:
2121
type: string
22-
description: The revision ID to target.
22+
description: An optimistic concurrency control (OCC) value used to prevent conflicts. Use the value returned in the ETag response header of the GET request for the resource being updated, or the latest known Revision Tree ID or Current Version of the document.
2323
Include-channels:
2424
name: channels
2525
in: query
@@ -342,7 +342,7 @@ rev:
342342
schema:
343343
type: string
344344
example: 2-5145e1086bb8d1d71a531e9f6b543c58
345-
description: The document revision to target.
345+
description: The document revision to target. If this is a CV value, ensure the query parameter is URL encoded (`+`->`%2B`, `@`->`%40`, etc.)
346346
revs_from:
347347
name: revs_from
348348
in: query
@@ -393,7 +393,7 @@ show_cv:
393393
required: false
394394
schema:
395395
type: boolean
396-
description: Output the current version of the version vector in the response as property `_cv`.
396+
description: Output the Current Version in the response as property `_cv`.
397397
startkey:
398398
name: startkey
399399
in: query

0 commit comments

Comments
 (0)