node-redis icon indicating copy to clipboard operation
node-redis copied to clipboard

Getting corrupted strings when using ElastiCache Redis 7.1.0 since library version 5

Open Kauhsa opened this issue 6 months ago • 2 comments

I'm using Amazon ElastiCache running Redis 7.1.0 (I do realize this is unsupported version, but thought that this is worth reporting anyway).

When I'm running the following script:

import redis from 'redis'

async function main() {
  const client = redis.createClient({
    url: 'redis://my-elasticache-cluster:6379'
  })

  await client.connect()
  console.log('Connected to Redis')

  const value = Array.from({ length: 1000 }, (_) => 'ä').join('')
  await client.set('test-key', value, {
    EX: 5
  })
  const result = await client.get('test-key')
  console.log('Value got from Redis:', result)
  console.log('Has replacement character', result && result.includes('�'))

  await client.disconnect()
}

main().catch((err) => {
  console.error('Unhandled error:', err)
  process.exit(1)
})

I sometimes get the following output (about 50% of the time), scroll right to see the error:

Connected to Redis
Value got from Redis: äääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääää��ääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääääää
Has replacement character true

If I install pnpm i redis@^4, this never happens. I could not replicate this with local redis (but did not test the same version, since I could not find 7.1 images easily).

Speculation - somewhere there is a mechanism where data is coming in as a separate byte chunks, and these incomplete byte chunks are being read as UTF-8 strings, even though they might be broken mid-codepoint and the conversion should wait until the bytes are retrieved in full?

Kauhsa avatar Jun 06 '25 09:06 Kauhsa

Just as an FYI I can reproduce this also on Eliasticache 7.1.0. Maybe it's enough to put up a warning about it.

dt-atkinson avatar Jun 12 '25 09:06 dt-atkinson

Hi @Kauhsa, thanks for flagging this. Im not sure when I will have the time to look into it though

nkaradzhov avatar Jun 18 '25 12:06 nkaradzhov

Hi,

It looks like I’ve encountered the same issue and was able to identify the root cause, along with three unit tests that reproduce the problem.

From what I can tell, the issue occurs in packages/client/lib/RESP/decoder.ts when decoding strings containing UTF-8 characters that require multiple bytes for encoding.

If a multi-byte character is split across two separate chunks, it is not correctly reconstructed as a single character. The methods #continueDecodeSimpleString and #continueDecodeStringWithLength appear to be the source of the issue, as they directly join the string representation of each chucks instead of conbining the buffers into a single one an then decoding it as a string.

From my tests, replacing:

return type === Buffer ?
  Buffer.concat(chunks) :
  chunks.join('');

by

const buffer = Buffer.concat(chunks);
return type === Buffer ? buffer : buffer.toString();

should resolve the issue.

The following unit tests can be used to reproduce the issue (packages/client/lib/RESP/decoder.spec.ts): SimpleString case:

test("'é'", {
  toWrite: Buffer.from('+é\r\n'),
  replies: ['é']
});

BlobString case:

test("'é'", {
  toWrite: Buffer.from('$2\r\né\r\n'),
  replies: ['é']
});

VerbatimString case:

test("'é'", {
  toWrite: Buffer.from('=6\r\ntxt:é\r\n'),
  replies: ['é']
});

All three tests pass when the data is processed as a single chunk but fail when processed byte by byte.

msebire avatar Jul 10 '25 14:07 msebire

We are seeing this in production as well.

paavohuhtala avatar Jul 31 '25 07:07 paavohuhtala

We are also observing this in production as well. It was very obscure, but luckily this issue helped us narrow down why it happened.

Edit: As this affects our production, we see no other option other than to downgrade to v4.

udnes99 avatar Sep 05 '25 08:09 udnes99

We were bit this bug as well. @msebire did you make a Pull Request for this? It seems like you have a working solution.

brb3 avatar Oct 11 '25 01:10 brb3

We also ran into this issue and it was driving us crazy for weeks before I came across this issue. If you don't want to downgrade, this workaround solved the issue for us (inspired by @msebire 's explanation). You just use the withTypeMapping() option return strings as buffers instead of strings

So instead of this:

  // content is a string
  const content = await redis.hGet(key, filePath)
  return content // intermittent corruption of multi-byte UTF-8 characters

do this:

  // content is a Buffer
  const content = await redis
    .withTypeMapping({
      [RESP_TYPES.BLOB_STRING]: Buffer,
    })
    .hGet(key, filePath)
  return content?.toString("utf-8") || null // no corruption so far? 🤞 

mzbyszynski avatar Oct 11 '25 02:10 mzbyszynski