Couchbase 7.6.0 changes how Big Decimals are stored

In the following discussion of the bug, the application code does not change. I change solely the Couchbase versions and show that the format in which Couchbase stored Big Decimals changed from:

  • version 7.2.4 (stores as 49000000000000000784)
  • version 7.6.0 (stores as 4.2e+19)

The following two JSON documents are part of a document stored in each Couchbase version. I produce the documents using the same application code (Scala SDK). Then, look up the document and check its contents through the web UI:

image

image

Big Decimal is produced into Couchbase 7.2.4 stores as:

...
"unlocked": false,
"ordinal": 49000000000000000981,
"parents": []
...

Big Decimal is produced into Couchbase 7.6.0 stores as:

...
"unlocked": false,
"ordinal": 4.2e+19,
"parents": []
...

Remarks:

  • Can be reproduced for CE and EE versions of 7.2.4 and 7.6.0.

  • When uploading the JSON document manually through the “Add document”, both Couchbase versions store the Big Decimals in the correct format 49000000000000000981.
    image

  • Using Scala SDK 1.5.3 and 1.6.0 or Java SDK 3.5.3 and 3.6.0 both has the same issue.

  • The application serializes data using JSON4S before it is passed to the Scala SDK, and the Big Decimals are in the correct format, 49000000000000000981, in the JSON string before they are handed over to the SDK. Please note that the issue is agnostic of the SDK version.

  • No similar issues were detected from Couchbase 7.0.0 to 7.2.4.

There is an issue with query rounding down the last three digits. MB-61365. And 7.2.4 has the same behavior. But I don’t see it being stored in scientific notation (I’m using the Java SDK). I’m guessing that is a function of JSON4S your application is using.

col.insert("3", JsonObject.jo().put("bd_json", new BigDecimal("49000000000000000981")));
col.insert("4", new MyBigDecimalHolder());

static class MyBigDecimalHolder {
	public BigDecimal bd = new BigDecimal("49000000000000000981");
}

From Buckets → my_bucket → Documents in the web-ui:

{
“bd_json”: 49000000000000000981
}
{
“bd”: 49000000000000000981
}

From Query in the web-ui:

[
  {
    "bd_json": 49000000000000000000
  },
  {
    "bd": 49000000000000000000
  }
]```

From the query REST API:

curl -u Administrator:password -XPOST http://localhost:8093/query/service -H “Accept: application/json” -H “content-type: application/json” -d ‘{“statement”:“select my_bucket.* from my_bucket”}’
{
“requestID”: “be03d127-d69a-4e92-b8a1-e779b2fc614c”,
“signature”: {““:””},
“results”: [
{“bd_json”:49000000000000000000},
{“bd”:49000000000000000000}
],
“status”: “success”,
“metrics”: {“elapsedTime”: “6.068784ms”,“executionTime”: “6.020666ms”,“resultCount”: 2,“resultSize”: 59,“serviceLoad”: 2}
}```

JSON doesn’t specify an underlying type for numbers. It is a common agreement to only use a widely compatible underlying type. Query uses 64-bit signed integers for integer values up to ± 2^63 and IEEE 64-bit floating point for non-integer values or values outside the aforementioned range. Any value larger than 2^53 is subject to precision loss in IEEE 64-bit floating point format.

So any time you individually select or try to filter on or index a “big decimal” outside the noted ranges you’ll see the precision loss. If you select the entire document without accessing the fields (i.e. no filtering or individual field projection) the raw document may be returned to you without the fields being parsed and therefore coerced into the noted types.

This has always been the case for Query.

Ref:
https://issues.couchbase.com/browse/MB-24464
https://stackoverflow.com/questions/35709595/why-would-you-use-a-string-in-json-to-represent-a-decimal-number#38357877

Some workarounds are to store big decimals as strings and process them in clients with the necessary type support - with the obvious limitations on use within the server (e.g. must be leading zero padded if sorting etc.) or to decompose into multiple signed 64-bit values that can be combined in clients with the necessary large type support. (Sorting & filtering on the parts is then possible.)

[Edit]
Yes, this doesn’t address differences observed when using the Java SDKs but should highlight the issues with attempting to store big decimals directly in JSON. Just wanted to highlight it for awareness.
[/Edit]

@mreiche @dh
Hi!

I am working with @zoltan.zvara on this issue.

Here are my findings, after some debugging today:

The precision loss only happens when the data is inserted through the transaction API (confirmed with replace and insert as well) and it only happens with Couchbase 7.6.0.

Here is the code I used for testing:

package org.something.tests

import com.couchbase.client.scala.codec.{JsonDeserializer, JsonSerializer}
import com.couchbase.client.scala.durability.Durability
import com.couchbase.client.scala.env.ClusterEnvironment
import com.couchbase.client.scala.manager.bucket.{BucketType, CreateBucketSettings, EjectionMethod}
import com.couchbase.client.scala.manager.collection.CreateCollectionSettings
import com.couchbase.client.scala.transactions.TransactionAttemptContext
import org.json4s.jackson.Serialization.{read, write}
import org.json4s.{DefaultFormats, Formats}
import org.scalatest.flatspec.AnyFlatSpec

import java.nio.charset.Charset
import scala.reflect.classTag

final case class Data(field: BigDecimal)

class DataSerializer extends JsonDeserializer[Data] with JsonSerializer[Data] {

  implicit val formats: Formats = DefaultFormats.withBigDecimal

  def serialize(content: Data): scala.util.Try[scala.Array[scala.Byte]] =
    scala.util.Try(
      write[Data](content)(formats).getBytes(Charset.forName("UTF-8"))
    )

  def deserialize(bytes: scala.Array[scala.Byte]): scala.util.Try[Data] =
    scala.util.Try(
      read[Data](new String(bytes, Charset.forName("UTF-8")))(formats, implicitly[Manifest[Data]])
    )

}

/**
  * Results using Couchbase 7.2.4
  * Serialized Data: [{"field":49000000000000000981}]
  * Deserialized Data: [49000000000000000981]
  * Insert: [Success({"field":49000000000000000981})]
  * Transaction Insert: [Success({"field":49000000000000000981})]
  */

/**
  * Results using Couchbase 7.6.0
  * Serialized Data: [{"field":49000000000000000981}]
  * Deserialized Data: [49000000000000000981]
  * Insert: [Success({"field":49000000000000000981})]
  * Transaction Insert: [Success({"field":4.9e+19})]
  */

/**
  * Using:
  * org.scala-lang:scala-library:2.13.13
  * org.scala-lang:scala-reflect:2.13.13
  *
  * org.scalatest:scalatest:3.2.17
  *
  * org.json4s:json4s-native:3.7.0-M11
  * org.json4s:json4s-jackson:3.7.0-M11
  * org.json4s:json4s-ext:3.7.0-M11
  * org.json4s:json4s-core:3.7.0-M11
  * org.json4s:json4s-ast:3.7.0-M11
  *
  * com.couchbase.client:scala-client:1.6.0
  */
class suiteTest extends AnyFlatSpec {
  "Test" should "be able to test" in {

    // Setup
    val serializer = new DataSerializer()
    val key1 = "key-1"
    val key2 = "key-2"
    val data = Data(BigDecimal("49000000000000000981"))

    // Serialization Proof
    val serializedData = serializer.serialize(data).get
    Console.println(s"Serialized Data: [${new String(serializedData, Charset.forName("UTF-8"))}]")
    val deserializedData = serializer.deserialize(serializedData).get
    Console.println(s"Deserialized Data: [${deserializedData.field}]")

    // Cluster
    val cluster = com.couchbase.client.scala.Cluster.connect(
      "connectionString",
      com.couchbase.client.scala.ClusterOptions.create("username", "password").environment(
        ClusterEnvironment.builder.build.get
      )
    ).get

    // Bucket
    cluster.buckets.create(
      CreateBucketSettings(
        name = "Test",
        ramQuotaMB = 100,
        flushEnabled = Some(true),
        numReplicas = Some(0),
        replicaIndexes = Some(false),
        bucketType = Some(BucketType.Couchbase),
        ejectionMethod = Some(EjectionMethod.ValueOnly),
        maxTTL = None,
        compressionMode = None,
        conflictResolutionType = None,
        minimumDurabilityLevel = Some(Durability.Majority)
      )
    ).get

    val bucket = cluster.bucket("Test")

    // Scope
    bucket.collections.createScope("Test").get
    val scope = bucket.scope("Test")

    // Collection
    bucket.collections.createCollection("Test", "Test", CreateCollectionSettings()).get
    val collection = scope.collection("Test")

    // Insert
    collection.insert[Data](
      key1,
      data
    )(serializer).get

    val getAfterInsertResult = collection.get(key1).get.contentAs[String](
      JsonDeserializer.Passthrough.StringConvert,
      classTag[String]
    )

    Console.println(s"Insert: [$getAfterInsertResult]")

    // Transaction Insert
    cluster.transactions.run((attemptContext: TransactionAttemptContext) =>
      scala.util.Try {
        attemptContext.insert[Data](
          collection,
          key2,
          data
        )(serializer).get
        (): Unit
      }
    ).get

    val getAfterTransactionInsertResult = collection.get(key2).get.contentAs[String](
      JsonDeserializer.Passthrough.StringConvert,
      classTag[String]
    )

    Console.println(s"Transaction Insert: [$getAfterTransactionInsertResult]")

  }
}

Here is the relevant part of the testing results:

/**
  * Results using Couchbase 7.2.4
  * Serialized Data: [{"field":49000000000000000981}]
  * Deserialized Data: [49000000000000000981]
  * Insert: [Success({"field":49000000000000000981})]
  * Transaction Insert: [Success({"field":49000000000000000981})]
  */

/**
  * Results using Couchbase 7.6.0
  * Serialized Data: [{"field":49000000000000000981}]
  * Deserialized Data: [49000000000000000981]
  * Insert: [Success({"field":49000000000000000981})]
  * Transaction Insert: [Success({"field":4.9e+19})]
  */

I have only changed the connection-string between the two test run.

  • The serializer/deserializer works fine.
  • Inserting through the normal API works correctly.
  • Inserting through the transaction API works correctly with Couchbase 7.2.4
  • Inserting through the transaction API DOES NOT work correctly with Couchbase 7.6.0

Can you provide a repo or a zip file with your reproducer?
I’m having trouble finding

* org.scalatest:scalatest:3.2.17
import org.scalatest.flatspec.AnyFlatSpec

Thanks

  • Mike

@mreiche
Sure! Here it is:
repo.zip (5.0 KB)

Thanks. I was able to reproduce the issue. I’ll use the case I opened earlier - MB-61365