diff --git a/redisinsight/api/bruno/RedisInsight/Array/Aggregate.bru b/redisinsight/api/bruno/RedisInsight/Array/Aggregate.bru index f2dcf8bd7d..2e458db3db 100644 --- a/redisinsight/api/bruno/RedisInsight/Array/Aggregate.bru +++ b/redisinsight/api/bruno/RedisInsight/Array/Aggregate.bru @@ -62,8 +62,8 @@ docs { `result` is a decimal string for numeric ops and an integer count as a string for `MATCH` / `USED`. It is `null` when AROP yields a nil reply (numeric ops over a range with no numeric values; bitwise ops over an empty - range). `SUM` / `MIN` / `MAX` preserve full precision; `AND` / `OR` / `XOR` - results above 2^53 may be rounded (see RI-8296). + range). All operations preserve full precision, including `AND` / `OR` / + `XOR` results above 2^53. ## Errors @@ -118,8 +118,8 @@ docs { ### XOR — bitwise fold over integer elements - Bitwise ops require integer elements. Results above 2^53 may be rounded by - the client (see RI-8296); `SUM` / `MIN` / `MAX` are unaffected. + Bitwise ops require integer elements. Results keep full precision above + 2^53, same as `SUM` / `MIN` / `MAX`. ```json { "keyName": "counters", "start": "0", "end": "9", "operation": "XOR" } diff --git a/redisinsight/api/src/modules/browser/array/array.service.spec.ts b/redisinsight/api/src/modules/browser/array/array.service.spec.ts index b05fe8f4a9..39bf88b521 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.spec.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.spec.ts @@ -581,6 +581,27 @@ describe('ArrayService', () => { }); }); + describe('getCount', () => { + it('should read ARCOUNT with the bigint opt-in and return the count as a string', async () => { + // 2^53 + 1 is odd, so a rounded number reply cannot produce this string. + when(mockStandaloneRedisClient.sendCommand) + .calledWith([BrowserToolArrayCommands.ArCount, mockKeyDto.keyName], { + integerReply: 'bigint', + }) + .mockResolvedValue(BigInt('9007199254740993')); + + const result = await service.getCount( + mockBrowserClientMetadata, + mockKeyDto, + ); + + expect(result).toEqual({ + keyName: mockKeyDto.keyName, + count: '9007199254740993', + }); + }); + }); + describe('getElement', () => { beforeEach(() => { when(mockStandaloneRedisClient.sendCommand) @@ -910,23 +931,32 @@ describe('ArrayService', () => { }); it('should append at the current length (ARLEN then ARSET) and return the index', async () => { + // ARLEN can exceed 2^53; the bigint opt-in keeps the derived index exact. + // 2^53 + 1 (9007199254740993) is odd and unrepresentable as a float64, so + // a rounded read would land on the wrong slot. when(client.sendCommand) - .calledWith([BrowserToolArrayCommands.ArLen, dto.keyName]) - .mockResolvedValue(7); + .calledWith([BrowserToolArrayCommands.ArLen, dto.keyName], { + integerReply: 'bigint', + }) + .mockResolvedValue(BigInt('9007199254740993')); const result = await service.appendElement( mockBrowserClientMetadata, dto, ); - expect(result).toEqual({ keyName: dto.keyName, index: '7' }); - // Writes at the length read by ARLEN, passing the index as a string so - // the append stays precise once ioredis returns 64-bit integers - // losslessly (RI-8296). + expect(result).toEqual({ + keyName: dto.keyName, + index: '9007199254740993', + }); + expect(client.sendCommand).toHaveBeenCalledWith( + [BrowserToolArrayCommands.ArLen, dto.keyName], + { integerReply: 'bigint' }, + ); expect(client.sendCommand).toHaveBeenCalledWith([ BrowserToolArrayCommands.ArSet, dto.keyName, - '7', + '9007199254740993', dto.value, ]); }); @@ -944,7 +974,9 @@ describe('ArrayService', () => { // Top index already 2^64-2, so the next index is the reserved 2^64-1 — // guard it before ARSET rather than letting Redis 500. when(client.sendCommand) - .calledWith([BrowserToolArrayCommands.ArLen, dto.keyName]) + .calledWith([BrowserToolArrayCommands.ArLen, dto.keyName], { + integerReply: 'bigint', + }) .mockResolvedValue('18446744073709551615'); await expect( @@ -965,7 +997,9 @@ describe('ArrayService', () => { command: 'ARLEN', }; when(client.sendCommand) - .calledWith([BrowserToolArrayCommands.ArLen, dto.keyName]) + .calledWith([BrowserToolArrayCommands.ArLen, dto.keyName], { + integerReply: 'bigint', + }) .mockRejectedValue(replyError); await expect( service.appendElement(mockBrowserClientMetadata, dto), diff --git a/redisinsight/api/src/modules/browser/array/array.service.ts b/redisinsight/api/src/modules/browser/array/array.service.ts index 608893e2e9..66abcf109a 100644 --- a/redisinsight/api/src/modules/browser/array/array.service.ts +++ b/redisinsight/api/src/modules/browser/array/array.service.ts @@ -564,10 +564,13 @@ export class ArrayService { // Append-to-end = ARSET at the current length. Read the length, then // write there. Not wrapped in a transaction (the client has no // MULTI/WATCH yet), so concurrent appends can race on the same length — - // acceptable for the desktop GUI. The index stays a string, so the write - // is precise once ioredis returns 64-bit integers losslessly (RI-8296). + // acceptable for the desktop GUI. ARLEN comes back as a RESP integer that + // can exceed 2^53; opt into bigint so it reaches toRequiredIndexString + // exact. const index = toRequiredIndexString( - await client.sendCommand([BrowserToolArrayCommands.ArLen, keyName]), + await client.sendCommand([BrowserToolArrayCommands.ArLen, keyName], { + integerReply: 'bigint', + }), ); // A full array (top index already 2^64-2) reports length 2^64-1 — the diff --git a/redisinsight/api/src/modules/workbench/providers/workbench-commands.executor.spec.ts b/redisinsight/api/src/modules/workbench/providers/workbench-commands.executor.spec.ts index 46ffcffd06..0777dbdbb5 100644 --- a/redisinsight/api/src/modules/workbench/providers/workbench-commands.executor.spec.ts +++ b/redisinsight/api/src/modules/workbench/providers/workbench-commands.executor.spec.ts @@ -369,6 +369,34 @@ describe('WorkbenchCommandsExecutor', () => { }, ); }); + it('requests ARLEN with exact u64 integer replies', async () => { + client.sendCommand.mockResolvedValueOnce('9007199254740993'); + + await service.sendCommand(client, { + command: 'ARLEN key', + mode: RunQueryMode.ASCII, + type: CommandExecutionType.Workbench, + }); + + expect(client.sendCommand).toHaveBeenCalledWith( + ['ARLEN', 'key'], + expect.objectContaining({ integerReply: 'bigint' }), + ); + }); + it('does not request exact u64 integer replies for non-array commands', async () => { + client.sendCommand.mockResolvedValueOnce('value'); + + await service.sendCommand(client, { + command: 'GET key', + mode: RunQueryMode.ASCII, + type: CommandExecutionType.Workbench, + }); + + expect(client.sendCommand).toHaveBeenCalledWith( + ['GET', 'key'], + expect.not.objectContaining({ integerReply: 'bigint' }), + ); + }); }); describe('CommandParsingError', () => { it('should return response with [CLI_UNTERMINATED_QUOTES] error for sendCommandForNodes', async () => { diff --git a/redisinsight/api/test/api/array/POST-databases-id-array-append.test.ts b/redisinsight/api/test/api/array/POST-databases-id-array-append.test.ts index 4351bd4072..6e775f98de 100644 --- a/redisinsight/api/test/api/array/POST-databases-id-array-append.test.ts +++ b/redisinsight/api/test/api/array/POST-databases-id-array-append.test.ts @@ -104,12 +104,9 @@ describe('POST /databases/:instanceId/array/append', () => { expect(await arget(keyName, '6')).to.eql('20.5'); }); - // Above 2^53 ioredis parses the ARLEN reply as a JS number and drops the - // low bit, so append derives the wrong end index. This is purely the - // integer-transport limitation tracked by RI-8296 (ioredis - // `stringNumbers`); the service keeps the index as a string, so it flips - // green once the client returns 64-bit integers losslessly. Skipped until. - it.skip('Should append precisely above 2^53 (needs ioredis 64-bit int fix — RI-8296)', async () => { + // The service reads ARLEN with the bigint opt-in, so the derived end index + // stays exact above 2^53. + it('Should append precisely above 2^53', async () => { const keyName = constants.getRandomString(); // Length becomes 2^53 + 1 (9007199254740993) — odd, not representable as // a JS double, so a rounded read would collide with the seeded element. diff --git a/redisinsight/api/test/api/array/POST-databases-id-array-get_next_index.test.ts b/redisinsight/api/test/api/array/POST-databases-id-array-get_next_index.test.ts index e65f31f764..029d7b776e 100644 --- a/redisinsight/api/test/api/array/POST-databases-id-array-get_next_index.test.ts +++ b/redisinsight/api/test/api/array/POST-databases-id-array-get_next_index.test.ts @@ -105,16 +105,28 @@ describe('POST /databases/:instanceId/array/get-next-index', () => { }); }); + it('Should keep the cursor exact in the (2^53, 2^63) RESP-integer zone', async () => { + const keyName = constants.getRandomString(); + // 2^53 + 1: below 2^63 so Redis replies with a bare RESP integer, and + // above 2^53 so a JS number would round it to 2^53. ARSEEK reaches the + // zone in one step, pinning ARNEXT's own bigint opt-in end to end. + const gapCursor = '9007199254740993'; + await rte.client.call('ARINSERT', keyName, 'a'); + await rte.client.call('ARSEEK', keyName, gapCursor); + + await validateApiCall({ + endpoint, + data: { keyName }, + responseSchema, + responseBody: { keyName, index: gapCursor }, + }); + }); + // The "exhausted cursor returns null" path is verified end-to-end via // the toIndexString unit test (nil reply -> null); reproducing it here // would require driving ARSEEK / ARINSERT to the u64 sentinel boundary, // whose semantics on Redis 8.8 are not documented well enough to keep // the assertion stable across patch releases. - // - // The u64-cursor precision regression is already pinned by the skipped - // ARSCAN canary (Should preserve u64 precision when scanning at indexes - // above MAX_SAFE_INTEGER) — re-asserting it here would duplicate that - // coverage without adding a new wire path. [ {