Skip to content

fix(client): decode RESP3 doubles to the value the server sent - #3393

Open
Jaybhade wants to merge 2 commits into
redis:masterfrom
Jaybhade:fix/resp3-double-precision
Open

fix(client): decode RESP3 doubles to the value the server sent#3393
Jaybhade wants to merge 2 commits into
redis:masterfrom
Jaybhade:fix/resp3-double-precision

Conversation

@Jaybhade

@Jaybhade Jaybhade commented Aug 6, 2026

Copy link
Copy Markdown

Description

RESP3 doubles are decoded to a different double than the server sent, for most values. On master (RESP3 is the default since #3215):

ZADD score           bytes redis sends           zScore() returns
0.3                  ,0.3                        0.30000000000000004
99.99                ,99.99                      99.99000000000001
8275.35              ,8275.35                     8275.349999999999
12345.6789           ,12345.6789                  12345.678899999999
Number.MAX_VALUE     ,1.7976931348623157e+308     Infinity
Number.MIN_VALUE     ,5e-324                      0

Over random doubles round-tripped through the wire format redis actually
writes, 51.5% (3087/5999) came back as a different double than the one
stored
. The same ZSCORE under RESP: 2 is exact, because the RESP2 path
goes through transformDoubleReply[2], which just calls Number().

The cause is in #decodeDouble. Fraction digits were summed as
double += digit * 1e-k from a precalculated multiplier table, and any
exponent was then applied as a separate double * 10 ** exponent. Each of
those steps rounds, so the error compounds — and for magnitudes near the
limits of the type the final multiplication overflows to Infinity or
underflows to 0 even though the value itself is perfectly representable.
Digits past the 17th were dropped rather than rounded.

This affects everything that returns a RESP3 double: ZSCORE, ZMSCORE,
ZINCRBY, ZADD … INCR, *WITHSCORES, GEODIST, GEOPOS, and the
module commands built on doubles.

What changed

Digits now accumulate into an integer significand and are scaled by a
single division by an exactly representable power of ten. One IEEE
operation on two exact operands rounds once, so the result is the correctly
rounded double — the exact value the server encoded.

Tokens that cannot be rebuilt that way — an e exponent, a significand
above MAX_SAFE_INTEGER, more than 22 fraction digits, inf/nan — hand
off to Number(), which is correctly rounded for every input. That fallback
also fixes the overflow/underflow cases, and its inf/+inf/-inf/nan
handling is copied from transformDoubleReply[2] so the two protocol
versions can no longer disagree.

Scanning still happens in place over the chunk: a double that arrives whole
costs no slice and no intermediate string. The change is a net −120 lines,
since the exponent and decimal continuation state machines are gone; the
split-across-chunks case buffers the token and converts it once whole.

Verification

Wire bytes for the differential tests were produced by compiling redis's own
deps/fpconv/fpconv_dtoa.c and driving it exactly as d2string() does, so
the inputs are the literal bytes the server writes, not an approximation.

  • Round-trip over 6031 doubles (hand-picked edge cases + random bit patterns,
    uniform, log-uniform, and 2-decimal): before 3098 wrong, after 0.
  • 300k generated decimal tokens, including malformed ones (1.2.3, 0x10,
    ' 1', bare +, 1e, 30-digit significands): the fast path agrees with
    Number() on all of them, 0 divergences.
  • Chunk-boundary fuzz: 30,275 split configurations (every single-cut position,
    byte-by-byte, and random multi-cut) over 21 doubles — 0 failures.
  • 11 new cases in decoder.spec.ts; each runs single-chunk and byte-by-byte
    via the existing test() helper. 18 of the 22 assertions fail on master.
  • npm run build and npm run test:types -w @redis/client clean;
    eslint --max-warnings=0 clean on both changed files.
  • Docker-free specs in packages/client: 344 passing. I could not run the
    dockerized suite (no host-networking Docker on this machine), so the
    integration tests are unverified locally — CI covers them.

Performance

This file is written for speed, so I measured old vs new in the same process
with rounds interleaved, reporting medians and spreads rather than a single
delta:

workload old new
ZRANGE … WITHSCORES, 500 members, RESP3 162.1ms 161.0ms 0.99x, spreads overlap
doubles only, typical score shapes 21.8ms 22.3ms 1.02x
doubles only, mixed incl. e exponents and extremes 40.5ms 46.8ms 1.16x
integers only (control, untouched path) 24.0ms 23.6ms 0.98x

768k doubles per run. Realistic replies are unchanged; the 1.16x is a stream
made entirely of shapes that now take the Number() fallback, which no
longer decodes them wrongly.


Checklist

  • Does npm test pass with this change (including linting)? Build, type
    tests, lint and all Docker-free specs pass; the dockerized suite needs
    host networking I don't have locally.
  • Is the new or changed code fully tested?
  • Is a documentation update included (if this change modifies existing APIs, or introduces new ones)? No API change — decoded values are now correct.

Note

Medium Risk
Touches hot-path protocol parsing used by all RESP3 double replies (scores, geo, modules); behavior change is correctness-focused but affects every consumer of those values.

Overview
Fixes incorrect RESP3 double decoding so values like sorted-set scores and geo coordinates match what Redis encoded on the wire (e.g. 0.3, 99.99, Number.MAX_VALUE no longer drift, overflow to Infinity, or underflow to 0).

#decodeDouble in the RESP decoder replaces the old digit-by-digit decimal multipliers and separate exponent scaling with an integer significand scaled by one division using exactly representable powers of ten. Tokens that cannot use that path (e exponents, huge significands, inf/nan, etc.) go through a new slowParseDouble helper aligned with the RESP2 Number() transformer.

Tests: adds a round-trips the double the server encoded block in decoder.spec.ts (including byte-by-byte chunk splits via the existing harness).

Reviewed by Cursor Bugbot for commit cf3857e. Bugbot is set up for automated code reviews on this repo. Configure here.

Jaybhade and others added 2 commits August 7, 2026 01:30
The RESP3 double decoder built its result by adding each fraction digit
multiplied by a precalculated power of ten, then applying any exponent as a
separate multiplication. Every one of those steps rounds, so the decoded
double drifted from the one the server encoded, and large/small magnitudes
overflowed or underflowed outright.

Digits are now accumulated into an integer significand and scaled by a
single division by an exactly representable power of ten, so the result is
rounded once — i.e. correctly rounded. Tokens that cannot be reconstructed
that way (an `e` exponent, a significand or decimal exponent outside the
exact range, `inf`/`nan`) are converted with `Number`, matching what the
RESP2 double transformer already did.

Scanning stays in place over the chunk, so a double that arrives whole
costs no slice and no intermediate string.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@nkaradzhov nkaradzhov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @Jaybhade, nice catch, this looks good!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants