Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions redisinsight/api/bruno/RedisInsight/Array/Aggregate.bru
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
]);
});
Expand All @@ -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(
Expand All @@ -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),
Expand Down
9 changes: 6 additions & 3 deletions redisinsight/api/src/modules/browser/array/array.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}),
Comment thread
VaskoAtanasovRedis marked this conversation as resolved.
Comment thread
VaskoAtanasovRedis marked this conversation as resolved.
);

// A full array (top index already 2^64-2) reports length 2^64-1 — the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

[
{
Expand Down