Throw ({ forbidden: doc }); purges existing document if updated document has incorrect values

Hi

if a document was created with a valid content per business logic and some time later it is updated with incorrect content, then the SG fn runs:

throw ({
                forbidden: doc
            });

This in turn purges the document. It can no longer be found on Couchbase Server. It also deletes the document on all connected mobile devices. Only the original device (Couchbase lite client) which wanted to upload the edited document with invalid contents keeps the document.

This was unexpected behaviour to me. I assumed that:

  • The original device keeps the document with the wrong data
  • All connected devices will never be notified about the updated document. They keep the original document
  • The changes will not be persisted on Couchbase Server
  • The document will not be purged on Couchbase Server

I run SG 2.1, Couchbase lite 1.4.4 on Android and Couchbase Server 6 CE

// Example document with valid contents
{ "age" : 20 }
// Updated document with invalid contents
{ "age": "hello world"}

Thanks!

That should not be happening, and we can’t reproduce it in a quick test. Can you give us steps to reproduce this?

Hi Jens,

I was able to reproduce it today again. I’m still unsure what exactly happens. I’ll write up a detailed report of my findings.

Thanks!

Prerequesites:

  • two Android devices running the same Android app and are signed in to the same account (optionally?) and use continuous push and pull sync. Omitted is account creation, creating sessions, initializing push and pull sync, etc.
  • following SG fn is used
function sync_gateway_fn(doc, oldDoc) {

    function printDoc(doc) {
        for (var key in doc) {
            if (doc.hasOwnProperty(key)) {
                console.log(new Date() + ": printDoc = " + key + " -> " + doc[key]);
            }
        }
    }
    console.log(new Date() + ": start of SG fn");
    if (doc) {
        console.log(new Date() + ": print doc");
        printDoc(doc);
    }
    if (oldDoc) {
        console.log(new Date() + ": print old doc");
        printDoc(oldDoc);
    }
    if (doc.type === 'test-type') {
        console.log(new Date() + ": in if-clause");
        if (oldDoc && !oldDoc._deleted) {
            if (doc.age == null) {
                console.log(new Date() + ": wrong value clause 1 and an exception is thrown");
                throw ({
                    forbidden: doc
                });
            }
            requireAccess(oldDoc.channels);
            channel(doc.channels);
        } else if (oldDoc && oldDoc._deleted) {
            if (doc.age == null) {
                console.log(new Date() + ": wrong value clause 2 and an exception is thrown");
                throw ({
                    forbidden: doc
                });
            }
            channel(doc.channels);
        } else if (!oldDoc) {
            if (doc.age == null) {
                console.log(new Date() + ": wrong value clause 3 and an exception is thrown");
                throw ({
                    forbidden: doc
                });
            }
            channel(doc.channels);
        }
    } else {
        console.log(new Date() + ": in else-clause");
        if (oldDoc) {
            console.log(new Date() + ": in else-clause ... if (oldDoc)");
            requireAccess(oldDoc.channels);
        }
        channel(doc.channels)
    }
}

Activity

public class ActTestType extends AppCompatActivity {

    private final String MY_DOCUMENT_NAME = "my_doc_test_type_2";
    private final String DEBUG = "DEBUG";

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.act_test_type);
        ((TextView) findViewById(R.id.act_test_type_tv_1)).setText("Document name is " + MY_DOCUMENT_NAME);
        findViewById(R.id.act_test_type_btn_create).setOnClickListener(view -> createTestTypeDocumentIfNotExists());
        findViewById(R.id.act_test_type_btn_delete).setOnClickListener(view -> {
            try {
                boolean isDeleted = CouchbaseUtil.getInstance(ActTestType.this).getDatabase().getDocument(MY_DOCUMENT_NAME).delete();
                Log.d(DEBUG, "Document is deleted = " + isDeleted);
            } catch (CouchbaseLiteException e) {
                e.printStackTrace();
            }
        });
        findViewById(R.id.act_test_type_btn_1).setOnClickListener(view -> {
            Document document = CouchbaseUtil.getInstance(ActTestType.this).getDatabase().getDocument(MY_DOCUMENT_NAME);
            try {
                document.update(newRevision -> {
                    Map<String, Object> properties = newRevision.getUserProperties();
                    properties.put("age", "twenty");
                    properties.put("type", "test-type");
                    properties.put("channels", SingletonApp.getInstance().getUuid());
                    newRevision.setUserProperties(properties);
                    return true;
                });
            } catch (CouchbaseLiteException e) {
                e.printStackTrace();
            }
        });
        findViewById(R.id.act_test_type_btn_2).setOnClickListener(view -> {
            Document document = CouchbaseUtil.getInstance(ActTestType.this).getDatabase().getDocument(MY_DOCUMENT_NAME);
            try {
                document.update(newRevision -> {
                    Map<String, Object> properties = newRevision.getUserProperties();
                    properties.put("age", "thirty");
                    properties.put("type", "test-type");
                    properties.put("channels", SingletonApp.getInstance().getUuid());
                    newRevision.setUserProperties(properties);
                    return true;
                });
            } catch (CouchbaseLiteException e) {
                e.printStackTrace();
            }
        });
        findViewById(R.id.act_test_type_btn_3).setOnClickListener(view -> {
            Document document = CouchbaseUtil.getInstance(ActTestType.this).getDatabase().getDocument(MY_DOCUMENT_NAME);
            try {
                document.update(newRevision -> {
                    Map<String, Object> properties = newRevision.getUserProperties();
                    properties.put("age", null);
                    properties.put("type", "test-type");
                    properties.put("channels", SingletonApp.getInstance().getUuid());
                    newRevision.setUserProperties(properties);
                    return true;
                });
            } catch (CouchbaseLiteException e) {
                e.printStackTrace();
            }
        });
        listenForChanges();
    }

    private void createTestTypeDocumentIfNotExists() {
        Document document = CouchbaseUtil.getInstance(this).getDatabase().getExistingDocument(MY_DOCUMENT_NAME);
        if (document == null) {
            document = CouchbaseUtil.getInstance(this).getDatabase().getDocument(MY_DOCUMENT_NAME);
            try {
                document.update(newRevision -> {
                    Map<String, Object> properties = newRevision.getUserProperties();
                    properties.put("age", 0);
                    properties.put("type", "test-type");
                    properties.put("channels", SingletonApp.getInstance().getUuid());
                    newRevision.setUserProperties(properties);
                    return true;
                });
            } catch (CouchbaseLiteException e) {
                e.printStackTrace();
            }
        }
    }

    private void listenForChanges() {
        Document document = CouchbaseUtil.getInstance(this).getDatabase().getExistingDocument(MY_DOCUMENT_NAME);
        if (document != null) {
            document.addChangeListener(event -> {
                updateUiText();
            });
        }
    }

    private void updateUiText() {
        PojoTestType pojoTestType = UtilObjectMapper.getMapper()
                .convertValue(CouchbaseUtil.getInstance(this).getDatabase().getExistingDocument(MY_DOCUMENT_NAME).getProperties(), PojoTestType.class);
        Log.d(DEBUG, "pojo = " + pojoTestType.toString());
        runOnUiThread(() -> ((TextView) findViewById(R.id.act_test_type_tv_2)).setText("Saved age = " + pojoTestType.age));
    }
}

POJO

public class PojoTestType {

    String age;

    public PojoTestType() {
    }

    @Override
    public String toString() {
        return "PojoTestType{" + "age=" + age + '}';
    }
}

Layout

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
                                                   xmlns:app="http://schemas.android.com/apk/res-auto"
                                                   android:layout_width="match_parent"
                                                   android:layout_height="match_parent"
                                                   android:background="@android:color/white"
                                                   android:orientation="vertical">

    <TextView
        android:id="@+id/act_test_type_tv_1"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginEnd="8dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:textColor="@android:color/black"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>

    <TextView
        android:id="@+id/act_test_type_tv_2"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginEnd="8dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:text="Saved age = "
        android:textColor="@android:color/black"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/act_test_type_tv_1"/>

    <Button
        android:id="@+id/act_test_type_btn_create"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginEnd="8dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:text="Create document"
        android:textColor="@android:color/black"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/act_test_type_tv_2"/>

    <Button
        android:id="@+id/act_test_type_btn_1"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginEnd="8dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:text="Set age to twenty"
        android:textColor="@android:color/black"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/act_test_type_btn_create"/>

    <Button
        android:id="@+id/act_test_type_btn_2"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginEnd="8dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:text="Set age to thirty"
        android:textColor="@android:color/black"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/act_test_type_btn_1"/>

    <Button
        android:id="@+id/act_test_type_btn_3"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginEnd="8dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:text="Set age to null"
        android:textColor="@android:color/black"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/act_test_type_btn_2"/>

    <Button
        android:id="@+id/act_test_type_btn_delete"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginEnd="8dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:text="Delete document"
        android:textColor="@android:color/black"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/act_test_type_btn_3"/>
</androidx.constraintlayout.widget.ConstraintLayout>

Steps to reproduce and SG fn logs. Scroll horizontally to see steps if necessary

Thu, 08 Aug 2019 09:25:45 CEST: start of SG fn                                              <-- Create doc on device A, age is set to 0.
Thu, 08 Aug 2019 09:25:45 CEST: print doc
Thu, 08 Aug 2019 09:25:45 CEST: printDoc = _id -> my_doc_test_type_2
Thu, 08 Aug 2019 09:25:45 CEST: printDoc = channels -> AOGsnkwf8BR8AwKb0BH9r4JXi5P2
Thu, 08 Aug 2019 09:25:45 CEST: printDoc = type -> test-type
Thu, 08 Aug 2019 09:25:45 CEST: printDoc = age -> 0
Thu, 08 Aug 2019 09:25:45 CEST: printDoc = _rev -> 1-3fe9c953404a8d0e7177b81f327ac52f
Thu, 08 Aug 2019 09:25:45 CEST: printDoc = _revisions -> [object Object]
Thu, 08 Aug 2019 09:25:45 CEST: in if-clause
Thu, 08 Aug 2019 09:26:09 CEST: start of SG fn                                              <-- Update doc on device A, age is set twenty.
Thu, 08 Aug 2019 09:26:09 CEST: print doc
Thu, 08 Aug 2019 09:26:09 CEST: printDoc = _rev -> 2-f51836ef232d27a2a99b2f815562981b
Thu, 08 Aug 2019 09:26:09 CEST: printDoc = _revisions -> [object Object]
Thu, 08 Aug 2019 09:26:09 CEST: printDoc = _id -> my_doc_test_type_2
Thu, 08 Aug 2019 09:26:09 CEST: printDoc = channels -> AOGsnkwf8BR8AwKb0BH9r4JXi5P2
Thu, 08 Aug 2019 09:26:09 CEST: printDoc = type -> test-type
Thu, 08 Aug 2019 09:26:09 CEST: printDoc = age -> twenty
Thu, 08 Aug 2019 09:26:09 CEST: print old doc
Thu, 08 Aug 2019 09:26:09 CEST: printDoc = age -> 0
Thu, 08 Aug 2019 09:26:09 CEST: printDoc = channels -> AOGsnkwf8BR8AwKb0BH9r4JXi5P2
Thu, 08 Aug 2019 09:26:09 CEST: printDoc = type -> test-type
Thu, 08 Aug 2019 09:26:09 CEST: printDoc = _id -> my_doc_test_type_2
Thu, 08 Aug 2019 09:26:09 CEST: in if-clause
Thu, 08 Aug 2019 09:26:20 CEST: start of SG fn                                              <-- Update doc on device A, age is set thirty.
Thu, 08 Aug 2019 09:26:20 CEST: print doc
Thu, 08 Aug 2019 09:26:20 CEST: printDoc = channels -> AOGsnkwf8BR8AwKb0BH9r4JXi5P2
Thu, 08 Aug 2019 09:26:20 CEST: printDoc = type -> test-type
Thu, 08 Aug 2019 09:26:20 CEST: printDoc = age -> thirty
Thu, 08 Aug 2019 09:26:20 CEST: printDoc = _rev -> 3-9f57d24e52c63880118e78cc1755b6c5
Thu, 08 Aug 2019 09:26:20 CEST: printDoc = _revisions -> [object Object]
Thu, 08 Aug 2019 09:26:20 CEST: printDoc = _id -> my_doc_test_type_2
Thu, 08 Aug 2019 09:26:20 CEST: print old doc
Thu, 08 Aug 2019 09:26:20 CEST: printDoc = age -> twenty
Thu, 08 Aug 2019 09:26:20 CEST: printDoc = channels -> AOGsnkwf8BR8AwKb0BH9r4JXi5P2
Thu, 08 Aug 2019 09:26:20 CEST: printDoc = type -> test-type
Thu, 08 Aug 2019 09:26:20 CEST: printDoc = _id -> my_doc_test_type_2
Thu, 08 Aug 2019 09:26:20 CEST: in if-clause
Thu, 08 Aug 2019 09:26:28 CEST: start of SG fn                                              <-- Update doc on device B, age is set to null.
Thu, 08 Aug 2019 09:26:28 CEST: print doc
Thu, 08 Aug 2019 09:26:28 CEST: printDoc = _rev -> 4-925f4c03c89adb3c6fe07c0a1fd3873c
Thu, 08 Aug 2019 09:26:28 CEST: printDoc = _revisions -> [object Object]
Thu, 08 Aug 2019 09:26:28 CEST: printDoc = _id -> my_doc_test_type_2
Thu, 08 Aug 2019 09:26:28 CEST: printDoc = channels -> AOGsnkwf8BR8AwKb0BH9r4JXi5P2
Thu, 08 Aug 2019 09:26:28 CEST: printDoc = type -> test-type
Thu, 08 Aug 2019 09:26:28 CEST: printDoc = age -> undefined
Thu, 08 Aug 2019 09:26:28 CEST: print old doc
Thu, 08 Aug 2019 09:26:28 CEST: printDoc = type -> test-type
Thu, 08 Aug 2019 09:26:28 CEST: printDoc = _id -> my_doc_test_type_2
Thu, 08 Aug 2019 09:26:28 CEST: printDoc = age -> thirty
Thu, 08 Aug 2019 09:26:28 CEST: printDoc = channels -> AOGsnkwf8BR8AwKb0BH9r4JXi5P2
Thu, 08 Aug 2019 09:26:28 CEST: in if-clause
Thu, 08 Aug 2019 09:26:28 CEST: wrong value clause 1 and an exception is thrown             <-- This is expected. Reject invalid values at the SG fn layer.
Thu, 08 Aug 2019 09:26:37 CEST: start of SG fn                                              <-- Update doc on device A, age is set to twenty.
Thu, 08 Aug 2019 09:26:37 CEST: print doc
Thu, 08 Aug 2019 09:26:37 CEST: printDoc = _rev -> 4-42384f8927dd7f363e228979e422ca31
Thu, 08 Aug 2019 09:26:37 CEST: printDoc = _revisions -> [object Object]
Thu, 08 Aug 2019 09:26:37 CEST: printDoc = _id -> my_doc_test_type_2
Thu, 08 Aug 2019 09:26:37 CEST: printDoc = channels -> AOGsnkwf8BR8AwKb0BH9r4JXi5P2
Thu, 08 Aug 2019 09:26:37 CEST: printDoc = type -> test-type
Thu, 08 Aug 2019 09:26:37 CEST: printDoc = age -> twenty
Thu, 08 Aug 2019 09:26:37 CEST: print old doc
Thu, 08 Aug 2019 09:26:37 CEST: printDoc = age -> thirty
Thu, 08 Aug 2019 09:26:37 CEST: printDoc = channels -> AOGsnkwf8BR8AwKb0BH9r4JXi5P2
Thu, 08 Aug 2019 09:26:37 CEST: printDoc = type -> test-type
Thu, 08 Aug 2019 09:26:37 CEST: printDoc = _id -> my_doc_test_type_2
Thu, 08 Aug 2019 09:26:37 CEST: in if-clause
Thu, 08 Aug 2019 09:26:38 CEST: start of SG fn                                              <-- Unsure what starts this. Afterwards the document is not found on the server anymore, i.e. in the documents view in the Couchbase server web console.
Thu, 08 Aug 2019 09:26:38 CEST: print doc
Thu, 08 Aug 2019 09:26:38 CEST: printDoc = _rev -> 5-4459d6b8f7882cd66443049e03ac41f6
Thu, 08 Aug 2019 09:26:38 CEST: printDoc = _revisions -> [object Object]
Thu, 08 Aug 2019 09:26:38 CEST: printDoc = _deleted -> true
Thu, 08 Aug 2019 09:26:38 CEST: printDoc = _id -> my_doc_test_type_2
Thu, 08 Aug 2019 09:26:38 CEST: print old doc
Thu, 08 Aug 2019 09:26:38 CEST: printDoc = channels -> AOGsnkwf8BR8AwKb0BH9r4JXi5P2
Thu, 08 Aug 2019 09:26:38 CEST: printDoc = type -> test-type
Thu, 08 Aug 2019 09:26:38 CEST: printDoc = _id -> my_doc_test_type_2
Thu, 08 Aug 2019 09:26:38 CEST: printDoc = age -> twenty
Thu, 08 Aug 2019 09:26:38 CEST: in else-clause                                              <-- First time in else-clause.
Thu, 08 Aug 2019 09:26:38 CEST: in else-clause ... if (oldDoc)
Thu, 08 Aug 2019 09:26:38 CEST: start of SG fn                                              <-- Unsure who starts this.
Thu, 08 Aug 2019 09:26:38 CEST: print doc
Thu, 08 Aug 2019 09:26:38 CEST: printDoc = _revisions -> [object Object]
Thu, 08 Aug 2019 09:26:38 CEST: printDoc = _id -> my_doc_test_type_2
Thu, 08 Aug 2019 09:26:38 CEST: printDoc = channels -> AOGsnkwf8BR8AwKb0BH9r4JXi5P2
Thu, 08 Aug 2019 09:26:38 CEST: printDoc = type -> test-type
Thu, 08 Aug 2019 09:26:38 CEST: printDoc = age -> undefined
Thu, 08 Aug 2019 09:26:38 CEST: printDoc = _rev -> 5-05278c8e912a58d2a121b6db51a06d31
Thu, 08 Aug 2019 09:26:38 CEST: print old doc
Thu, 08 Aug 2019 09:26:38 CEST: printDoc = age -> thirty
Thu, 08 Aug 2019 09:26:38 CEST: printDoc = channels -> AOGsnkwf8BR8AwKb0BH9r4JXi5P2
Thu, 08 Aug 2019 09:26:38 CEST: printDoc = type -> test-type
Thu, 08 Aug 2019 09:26:38 CEST: printDoc = _id -> my_doc_test_type_2
Thu, 08 Aug 2019 09:26:38 CEST: in if-clause
Thu, 08 Aug 2019 09:26:38 CEST: wrong value clause 1 and an exception is thrown

The document is deleted on Couchbase Server and on device A. Device B still has the document.