Nadav Har'El created CASSANDRA-21751:
----------------------------------------

             Summary: Decimal validation accepts a 4-byte value (a scale with 
no unscaled value), which Cassandra itself cannot read, compare, flush or 
compact
                 Key: CASSANDRA-21751
                 URL: https://issues.apache.org/jira/browse/CASSANDRA-21751
             Project: Apache Cassandra
          Issue Type: Bug
            Reporter: Nadav Har'El


A {{decimal}} value is a 4-byte {{int}} scale followed by a {{varint}} unscaled 
value. {{DecimalSerializer.validate()}} only rejects non-empty values shorter 
than 4 bytes, so it accepts a 4-byte value consisting of the scale alone. 
Nothing in Cassandra can interpret such a value: 
{{DecimalSerializer.deserialize()}} passes the empty remainder to {{{}new 
BigInteger(byte[]){}}}, which throws {{{}NumberFormatException: Zero length 
BigInteger{}}}. As a result, one such value, inserted with {{blobAsDecimal()}} 
or as a bound value, causes server errors on reads and writes. As a clustering 
key it also makes memtable flush and compaction of the table fail.

Tested on 5.0.9 and 6.0-alpha2, with identical results.
h3. Reproducer
{code:sql}
CREATE KEYSPACE ks WITH replication = {'class': 'SimpleStrategy', 
'replication_factor': 1};
CREATE TABLE ks.t (p int, c decimal, v decimal, PRIMARY KEY (p, c));

-- Accepted (also accepted as a 4-byte bound value):
INSERT INTO ks.t (p, c, v) VALUES (1, 1, blobAsDecimal(0x00000000));
SELECT decimalAsBlob(v) FROM ks.t WHERE p = 1;     -- 0x00000000, stored as is

-- Every operation that interprets the value fails with
-- ServerError: java.lang.NumberFormatException: Zero length BigInteger
SELECT CAST(v AS text) FROM ks.t WHERE p = 1;
SELECT JSON p, c, v FROM ks.t WHERE p = 1;
UPDATE ks.t SET v = 2 WHERE p = 1 AND c = 1 IF v = 0;

-- As a clustering key:
INSERT INTO ks.t (p, c) VALUES (2, blobAsDecimal(0x00000000));  -- accepted
INSERT INTO ks.t (p, c) VALUES (2, 1);                          -- WriteFailure
SELECT decimalAsBlob(c) FROM ks.t WHERE p = 2 AND c = 0;        -- ReadFailure
{code}
Then {{nodetool flush ks t}} fails with "error: Zero length BigInteger". The 
same failure happens with arithmetic ({{{}v + 1{}}}) and {{{}sum(v){}}}.
h3. Impact
 # *Server errors on reads:* {{{}CAST{}}}, {{{}SELECT JSON{}}}, arithmetic, 
{{sum()}} and LWT conditions on the column all fail with a server error rather 
than a clean client error.
 # *The partition stops accepting writes:* once a 4-byte clustering key is in a 
partition, inserting any other clustering key into that partition fails 
(WriteFailure). The memtable insert has to compare the new key with the 4-byte 
one, and that comparison throws. Reads that compare against the key (e.g. 
{{WHERE c = 0}} or {{{}c > -1{}}}) fail with ReadFailure.
 # *Flush fails:* {{MetadataCollector.updateClusteringValues()}} compares every 
row's clustering key with the min/max so far. So a memtable holding a 4-byte 
clustering key plus any other row of the table, even in another partition, 
cannot be flushed:
{noformat}
ERROR [PerDiskMemtableFlushWriter_0:2] JVMStabilityInspector -- Exception in 
thread Thread[PerDiskMemtableFlushWriter_0:2,...]
java.lang.NumberFormatException: Zero length BigInteger
        at java.base/java.math.BigInteger.<init>(Unknown Source)
        at 
org.apache.cassandra.serializers.DecimalSerializer.deserialize(DecimalSerializer.java:38)
        at 
org.apache.cassandra.db.marshal.AbstractType.compose(AbstractType.java:133)
        at 
org.apache.cassandra.db.marshal.AbstractType.compareComposed(AbstractType.java:115)
        at 
org.apache.cassandra.db.marshal.DecimalType.compareCustom(DecimalType.java:88)
        at 
org.apache.cassandra.db.marshal.AbstractType.compare(AbstractType.java:214)
        at 
org.apache.cassandra.db.ClusteringComparator.compareComponent(ClusteringComparator.java:191)
        at 
org.apache.cassandra.db.ClusteringComparator.compare(ClusteringComparator.java:148)
        at 
org.apache.cassandra.io.sstable.metadata.MetadataCollector.updateClusteringValues(MetadataCollector.java:311)
        at 
org.apache.cassandra.io.sstable.format.SortedTableWriter.addRow(SortedTableWriter.java:213)
        ...
{noformat}
After the failed flush, the next {{nodetool flush}} of the table reports 
success without writing anything, and the rows of the failed memtable are still 
served from memory. When the table was later dropped, Cassandra logged "Failed 
to force-recycle all segments; at least one segment is still in use with dirty 
CFs." I did not test what commit log replay does after a restart.

 # *Compaction fails:* if the 4-byte key was flushed alone into its own 
sstable, compacting that sstable with any other sstable of the table fails with 
the same exception, from {{CompactionTask.runMayThrow()}} through 
{{{}MetadataCollector.updateClusteringValues(){}}}.
 # *Clients cannot read it:* the Java driver 3.12.1 bundled with Cassandra 
throws {{NumberFormatException: Zero length BigInteger}} from 
{{{}TypeCodec.decimal().deserialize(){}}}. The Python driver fails to decode 
the result column ("invalid literal for int() with base 16") and then marks the 
whole connection defunct. ScyllaDB's fork of gocql (v1.18.3) explicitly rejects 
it ("the length of the data should be 0 or more than 5").

h3. Why this is a validation gap, not a legitimate encoding of zero

One might argue that a 4-byte decimal is a valid encoding of zero, and that 
{{deserialize()}} should be fixed to accept it instead. It should not:
 # *The protocol spec defines zero as one byte.* Section 5.6 of 
native_protocol_v5.spec defines a decimal as a scale "followed by a varint 
encoding (see section 5.24) of the unscaled value". Section 5.24's example 
table encodes 0 as {{{}0x00{}}}. No varint is encoded as zero bytes.
 # *Cassandra's own varint type treats empty bytes as "empty", not as 0.* 
{{IntegerSerializer.deserialize()}} returns {{null}} for an empty value, and 
{{IntegerType}} sorts the empty value before all numbers. On 5.0.9 and 
6.0-alpha2, a {{varint}} column set to {{blobAsVarint(0x)}} reads back as null, 
{{CAST(i AS text)}} and {{i + 1}} return null, and {{SELECT JSON}} returns an 
empty string (see below). Reading a decimal's empty unscaled varint as 0 would 
contradict how the same varint encoding is read everywhere else in Cassandra.
{code:java}
SELECT JSON i FROM ks.v WHERE p = 1;   -- {"i": ""}
{code}

 # *No writer produces it.* {{DecimalSerializer.serialize()}} writes 
{{{}BigInteger.toByteArray(){}}}, which is never empty (zero is {{{}0x00{}}}), 
and drivers do the same. The only ways to create a 4-byte decimal are 
{{blobAsDecimal()}} or a hand-crafted bound value.
 # *The 4-byte lower bound was never a format decision.* Until 2013, 
{{validate()}} did nothing ("no useful check for invalid decimals"). The 
current check was added during the CASSANDRA-5744 cleanup with the comment "We 
at least store the scale", i.e. as a minimal sanity check. Nothing indicates 
that a value with no unscaled part was considered and allowed.
 # *Nothing can depend on it.* Every code path that interprets such a value 
already fails, both in Cassandra and in the drivers. So rejecting it at write 
time cannot break any working application. It only turns a later server error, 
or a flush or compaction failure, into an immediate {{{}InvalidRequest{}}}.

h3. Suggested fix

In {{{}DecimalSerializer.validate(){}}}, reject non-empty values shorter than 5 
bytes (4-byte scale plus at least one byte of unscaled value), e.g. "Expected 0 
or at least 5 bytes". 



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to