Skip to content

Commit 90198ba

Browse files
authored
CBG-4411: Improvements to Diagnostic Sync Function dry run endpoint (#7932)
1 parent 5aa5409 commit 90198ba

File tree

6 files changed

+414
-169
lines changed

6 files changed

+414
-169
lines changed

base/error.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,3 +361,28 @@ func (me *MultiError) ErrorOrNil() error {
361361
}
362362
return me
363363
}
364+
365+
const syncFnDryRunErrorPrefix = "Error returned from Sync Function"
366+
367+
// SyncFnDryRunError is returned when the sync function dry run returns an error.
368+
// It wraps the original error for errors.Is and the type supports errors.As
369+
type SyncFnDryRunError struct {
370+
Err error
371+
}
372+
373+
func (e *SyncFnDryRunError) Error() string {
374+
if e == nil {
375+
return syncFnDryRunErrorPrefix
376+
}
377+
if e.Err == nil {
378+
return syncFnDryRunErrorPrefix
379+
}
380+
return syncFnDryRunErrorPrefix + ": " + e.Err.Error()
381+
}
382+
383+
func (e *SyncFnDryRunError) Unwrap() error {
384+
if e == nil {
385+
return nil
386+
}
387+
return e.Err
388+
}

db/crud.go

Lines changed: 27 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1694,90 +1694,43 @@ func (db *DatabaseCollectionWithUser) PutExistingRevWithBody(ctx context.Context
16941694

16951695
}
16961696

1697-
// SyncFnDryrun Runs a document through the sync function and returns expiry, channels doc was placed in, access map for users, roles, handler errors and sync fn exceptions
1698-
func (db *DatabaseCollectionWithUser) SyncFnDryrun(ctx context.Context, body Body, docID string) (*channels.ChannelMapperOutput, error, error) {
1699-
doc := &Document{
1700-
ID: docID,
1701-
_body: body,
1702-
}
1703-
oldDoc := doc
1704-
if docID != "" {
1705-
if docInBucket, err := db.GetDocument(ctx, docID, DocUnmarshalAll); err == nil {
1706-
oldDoc = docInBucket
1707-
if doc._body == nil {
1708-
body = oldDoc.Body(ctx)
1709-
doc._body = body
1710-
// If no body is given, use doc in bucket as doc with no old doc
1711-
oldDoc._body = nil
1712-
}
1713-
doc._body[BodyRev] = oldDoc.SyncData.GetRevTreeID()
1714-
} else {
1715-
return nil, err, nil
1716-
}
1717-
} else {
1718-
oldDoc._body = nil
1719-
}
1720-
1721-
delete(body, BodyId)
1722-
1723-
// Get the revision ID to match, and the new generation number:
1724-
matchRev, _ := body[BodyRev].(string)
1725-
generation, _ := ParseRevID(ctx, matchRev)
1726-
if generation < 0 {
1727-
return nil, base.HTTPErrorf(http.StatusBadRequest, "Invalid revision ID"), nil
1728-
}
1729-
generation++
1730-
1731-
// Create newDoc which will be used to pass around Body
1732-
newDoc := &Document{
1733-
ID: docID,
1734-
}
1735-
// Pull out attachments
1736-
newDoc.SetAttachments(GetBodyAttachments(body))
1737-
delete(body, BodyAttachments)
1738-
1739-
delete(body, BodyRevisions)
1697+
// SyncFnDryrun Runs the given document body through a sync function and returns expiry, channels doc was placed in,
1698+
// access map for users, roles, handler errors and sync fn exceptions.
1699+
// If syncFn is provided, it will be used instead of the one configured on the database.
1700+
func (db *DatabaseCollectionWithUser) SyncFnDryrun(ctx context.Context, newDoc, oldDoc *Document, syncFn string) (*channels.ChannelMapperOutput, error) {
17401701

1741-
err := validateAPIDocUpdate(body)
1742-
if err != nil {
1743-
return nil, err, nil
1744-
}
1745-
bodyWithoutInternalProps, wasStripped := StripInternalProperties(body)
1746-
canonicalBytesForRevID, err := base.JSONMarshalCanonical(bodyWithoutInternalProps)
1747-
if err != nil {
1748-
return nil, err, nil
1749-
}
1750-
1751-
// We needed to keep _deleted around in the body until we generated a rev ID, but now we can ditch it.
1752-
_, isDeleted := body[BodyDeleted]
1753-
if isDeleted {
1754-
delete(body, BodyDeleted)
1755-
}
1756-
1757-
// and now we can finally update the newDoc body to be without any special properties
1758-
newDoc.UpdateBody(body)
1759-
1760-
// If no special properties were stripped and document wasn't deleted, the canonical bytes represent the current
1761-
// body. In this scenario, store canonical bytes as newDoc._rawBody
1762-
if !wasStripped && !isDeleted {
1763-
newDoc._rawBody = canonicalBytesForRevID
1764-
}
1765-
1766-
newRev := CreateRevIDWithBytes(generation, matchRev, canonicalBytesForRevID)
1767-
newDoc.RevID = newRev
17681702
mutableBody, metaMap, _, err := db.prepareSyncFn(oldDoc, newDoc)
17691703
if err != nil {
17701704
base.InfofCtx(ctx, base.KeyDiagnostic, "Failed to prepare to run sync function: %v", err)
1771-
return nil, err, nil
1705+
return nil, err
17721706
}
17731707

17741708
syncOptions, err := MakeUserCtx(db.user, db.ScopeName, db.Name)
17751709
if err != nil {
1776-
return nil, err, nil
1710+
return nil, err
1711+
}
1712+
var output *channels.ChannelMapperOutput
1713+
var syncErr error
1714+
if syncFn == "" {
1715+
output, err = db.ChannelMapper.MapToChannelsAndAccess(ctx, mutableBody, string(oldDoc._rawBody), metaMap, syncOptions)
1716+
if err != nil {
1717+
return nil, &base.SyncFnDryRunError{Err: err}
1718+
}
1719+
} else {
1720+
jsTimeout := time.Duration(base.DefaultJavascriptTimeoutSecs) * time.Second
1721+
syncRunner, err := channels.NewSyncRunner(ctx, syncFn, jsTimeout)
1722+
if err != nil {
1723+
return nil, fmt.Errorf("failed to create sync runner: %v", err)
1724+
}
1725+
jsOutput, err := syncRunner.Call(ctx, mutableBody, string(oldDoc._rawBody), metaMap, syncOptions)
1726+
if err != nil {
1727+
1728+
return nil, &base.SyncFnDryRunError{Err: err}
1729+
}
1730+
output = jsOutput.(*channels.ChannelMapperOutput)
17771731
}
1778-
output, err := db.ChannelMapper.MapToChannelsAndAccess(ctx, mutableBody, string(oldDoc._rawBody), metaMap, syncOptions)
17791732

1780-
return output, nil, err
1733+
return output, syncErr
17811734
}
17821735

17831736
// revTreeConflictCheck checks for conflicts in the rev tree history and returns the parent revid, currentRevIndex

docs/api/paths/diagnostic/keyspace-sync.yaml

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,40 @@
77
# the file licenses/APL2.txt.
88
parameters:
99
- $ref: ../../components/parameters.yaml#/keyspace
10-
get:
10+
- $ref: ../../components/parameters.yaml#/doc_id
11+
post:
1112
summary: Run a doc body through the sync function and return sync data.
1213
description: |-
13-
Run a document body through the sync function and return document sync data.
14+
Runs a document body through the sync function and returns document sync
15+
data. If no custom sync function is provided in the request body, the
16+
default or user-defined sync function for the collection is used.
17+
| Document | DocID | Behaviour |
18+
| -------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
19+
| Yes | No | The document passed will be considered as newDoc and oldDoc will be empty |
20+
| Yes | Yes | The document passed in the body will be newDoc and DocID will be read from the bucket/collection and will be passed as the oldDoc. If DocID doesn't exist, then oldDoc will be empty |
21+
| No | No | Will throw an error |
22+
| No | Yes | The docID will be passed in as the newDoc and oldDoc will be empty. If the document is not found, an error will be returned |
23+
1424
* Sync Gateway Application Read Only
1525
requestBody:
1626
content:
1727
application/json:
1828
schema:
19-
$ref: ../../components/schemas.yaml#/Document
29+
type: object
30+
properties:
31+
sync_function:
32+
description: |-
33+
A JavaScript function that defines custom access, channel, and
34+
validation logic for documents. This function will be evaluated
35+
by the Sync Gateway to determine document routing, access
36+
grants, and validation outcomes during synchronization.
37+
type: string
38+
example: |-
39+
function (doc, oldDoc) {
40+
channel(doc.channels);
41+
}
42+
doc:
43+
$ref: ../../components/schemas.yaml#/Document
2044
responses:
2145
'200':
2246
description: Document Processed by sync function successfully

rest/diagnostic_doc_api.go

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ licenses/APL2.txt.
1111
package rest
1212

1313
import (
14+
"errors"
1415
"fmt"
1516
"net/http"
1617

@@ -33,6 +34,11 @@ type ImportFilterDryRun struct {
3334
Error string `json:"error"`
3435
}
3536

37+
type SyncFnDryRunPayload struct {
38+
Function string `json:"sync_function"`
39+
Doc db.Body `json:"doc,omitempty"`
40+
}
41+
3642
func populateDocChannelInfo(doc db.Document) map[string][]auth.GrantHistorySequencePair {
3743
resp := make(map[string][]auth.GrantHistorySequencePair, len(doc.Channels))
3844

@@ -70,20 +76,84 @@ func (h *handler) handleGetDocChannels() error {
7076
func (h *handler) handleSyncFnDryRun() error {
7177
docid := h.getQuery("doc_id")
7278

73-
body, err := h.readDocument()
79+
var syncDryRunPayload SyncFnDryRunPayload
80+
err := h.readJSONInto(&syncDryRunPayload)
7481
if err != nil {
75-
if docid == "" {
76-
return fmt.Errorf("no doc id provided for dry run and error reading body: %s", err)
82+
return base.HTTPErrorf(http.StatusBadRequest, "Error reading sync function payload: %v", err)
83+
}
84+
85+
if syncDryRunPayload.Doc == nil && docid == "" {
86+
return base.HTTPErrorf(http.StatusBadRequest, "no docid or document provided")
87+
}
88+
89+
oldDoc := &db.Document{ID: docid}
90+
oldDoc.UpdateBody(syncDryRunPayload.Doc)
91+
if docid != "" {
92+
if docInbucket, err := h.collection.GetDocument(h.ctx(), docid, db.DocUnmarshalAll); err == nil {
93+
oldDoc = docInbucket
94+
if len(syncDryRunPayload.Doc) == 0 {
95+
syncDryRunPayload.Doc = oldDoc.Body(h.ctx())
96+
oldDoc.UpdateBody(nil)
97+
}
98+
} else {
99+
return base.HTTPErrorf(http.StatusNotFound, "Error reading document: %v", err)
77100
}
101+
} else {
102+
oldDoc.UpdateBody(nil)
103+
}
104+
105+
delete(syncDryRunPayload.Doc, db.BodyId)
106+
107+
// Get the revision ID to match, and the new generation number:
108+
matchRev, _ := syncDryRunPayload.Doc[db.BodyRev].(string)
109+
generation, _ := db.ParseRevID(h.ctx(), matchRev)
110+
if generation < 0 {
111+
return base.HTTPErrorf(http.StatusBadRequest, "Invalid revision ID")
112+
}
113+
generation++
114+
115+
// Create newDoc which will be used to pass around Body
116+
newDoc := &db.Document{
117+
ID: docid,
78118
}
119+
// Pull attachments
120+
newDoc.SetAttachments(db.GetBodyAttachments(syncDryRunPayload.Doc))
121+
delete(syncDryRunPayload.Doc, db.BodyAttachments)
122+
delete(syncDryRunPayload.Doc, db.BodyRevisions)
79123

80-
output, err, syncFnErr := h.collection.SyncFnDryrun(h.ctx(), body, docid)
124+
if _, ok := syncDryRunPayload.Doc[base.SyncPropertyName]; ok {
125+
return base.HTTPErrorf(http.StatusBadRequest, "document-top level property '_sync' is a reserved internal property")
126+
}
127+
128+
db.StripInternalProperties(syncDryRunPayload.Doc)
129+
130+
// We needed to keep _deleted around in the body until we generate rev ID, but now it can be removed
131+
_, isDeleted := syncDryRunPayload.Doc[db.BodyDeleted]
132+
if isDeleted {
133+
delete(syncDryRunPayload.Doc, db.BodyDeleted)
134+
}
135+
136+
//update the newDoc body to be without any special properties
137+
newDoc.UpdateBody(syncDryRunPayload.Doc)
138+
139+
rawDocBytes, err := newDoc.BodyBytes(h.ctx())
81140
if err != nil {
82-
return err
141+
return base.HTTPErrorf(http.StatusBadRequest, "Error marshalling document: %v", err)
83142
}
84-
if syncFnErr != nil {
143+
144+
newRev := db.CreateRevIDWithBytes(generation, matchRev, rawDocBytes)
145+
newDoc.RevID = newRev
146+
147+
output, err := h.collection.SyncFnDryrun(h.ctx(), newDoc, oldDoc, syncDryRunPayload.Function)
148+
if err != nil {
149+
var syncFnDryRunErr *base.SyncFnDryRunError
150+
if !errors.As(err, &syncFnDryRunErr) {
151+
return err
152+
}
153+
154+
errMsg := syncFnDryRunErr.Error()
85155
resp := SyncFnDryRun{
86-
Exception: syncFnErr.Error(),
156+
Exception: errMsg,
87157
}
88158
h.writeJSON(resp)
89159
return nil

0 commit comments

Comments
 (0)