mirror of
https://github.com/oven-sh/bun
synced 2026-02-10 10:58:56 +00:00
472 lines
16 KiB
TypeScript
472 lines
16 KiB
TypeScript
import { randomUUIDv7 } from "bun";
|
|
import { beforeEach, describe, expect, test } from "bun:test";
|
|
import { ConnectionType, createClient, ctx, isEnabled } from "../test-utils";
|
|
|
|
/**
|
|
* Integration test suite for complex Redis operations
|
|
* - Transaction handling
|
|
* - Pipelining (to be implemented)
|
|
* - Complex data type operations
|
|
* - Realistic use cases
|
|
*/
|
|
describe.skipIf(!isEnabled)("Valkey: Complex Operations", () => {
|
|
beforeEach(() => {
|
|
if (ctx.redis?.connected) {
|
|
ctx.redis.close?.();
|
|
}
|
|
ctx.redis = createClient(ConnectionType.TCP);
|
|
});
|
|
describe("Multi/Exec Transactions", () => {
|
|
test("should execute commands in a transaction", async () => {
|
|
const prefix = ctx.generateKey("transaction");
|
|
const key1 = `${prefix}-1`;
|
|
const key2 = `${prefix}-2`;
|
|
const key3 = `${prefix}-3`;
|
|
|
|
// Start transaction
|
|
await ctx.redis.send("MULTI", []);
|
|
|
|
// Queue commands in transaction
|
|
const queueResults = await Promise.all([
|
|
ctx.redis.set(key1, "value1"),
|
|
ctx.redis.set(key2, "value2"),
|
|
ctx.redis.incr(key3),
|
|
ctx.redis.get(key1),
|
|
]);
|
|
|
|
// All queue commands should return "QUEUED"
|
|
for (const result of queueResults) {
|
|
expect(result).toBe("QUEUED");
|
|
}
|
|
|
|
// Execute transaction
|
|
const execResult = await ctx.redis.send("EXEC", []);
|
|
|
|
// Should get an array of results
|
|
expect(Array.isArray(execResult)).toBe(true);
|
|
expect(execResult.length).toBe(4);
|
|
|
|
// Check individual results
|
|
expect(execResult[0]).toBe("OK"); // SET result
|
|
expect(execResult[1]).toBe("OK"); // SET result
|
|
expect(execResult[2]).toBe(1); // INCR result
|
|
expect(execResult[3]).toBe("value1"); // GET result
|
|
|
|
// Verify the transaction was applied
|
|
const key1Value = await ctx.redis.get(key1);
|
|
expect(key1Value).toBe("value1");
|
|
|
|
const key2Value = await ctx.redis.get(key2);
|
|
expect(key2Value).toBe("value2");
|
|
|
|
const key3Value = await ctx.redis.get(key3);
|
|
expect(key3Value).toBe("1");
|
|
});
|
|
|
|
test("should handle transaction discards", async () => {
|
|
const prefix = ctx.generateKey("transaction-discard");
|
|
const key = `${prefix}-key`;
|
|
|
|
// Set initial value
|
|
await ctx.redis.set(key, "initial");
|
|
|
|
// Start transaction
|
|
await ctx.redis.send("MULTI", []);
|
|
|
|
// Queue some commands
|
|
await ctx.redis.set(key, "changed");
|
|
await ctx.redis.incr(`${prefix}-counter`);
|
|
|
|
// Discard the transaction
|
|
const discardResult = await ctx.redis.send("DISCARD", []);
|
|
expect(discardResult).toBe("OK");
|
|
|
|
// Verify the key was not changed
|
|
const value = await ctx.redis.get(key);
|
|
expect(value).toBe("initial");
|
|
|
|
// Verify counter was not incremented
|
|
const counterValue = await ctx.redis.get(`${prefix}-counter`);
|
|
expect(counterValue).toBeNull();
|
|
});
|
|
|
|
test("should handle transaction errors", async () => {
|
|
const prefix = ctx.generateKey("transaction-error");
|
|
const key = `${prefix}-key`;
|
|
|
|
// Set initial value
|
|
await ctx.redis.set(key, "string-value");
|
|
|
|
// Start transaction
|
|
await ctx.redis.send("MULTI", []);
|
|
|
|
// Queue valid command
|
|
await ctx.redis.set(`${prefix}-valid`, "valid");
|
|
|
|
// Queue command that will fail (INCR on a string)
|
|
await ctx.redis.incr(key);
|
|
|
|
// Queue another valid command
|
|
await ctx.redis.set(`${prefix}-after`, "after");
|
|
|
|
// Execute transaction
|
|
const execResult = await ctx.redis.send("EXEC", []);
|
|
|
|
// Should get an array of results, with error for the failing command
|
|
expect(Array.isArray(execResult)).toBe(true);
|
|
expect(execResult).toMatchInlineSnapshot(`
|
|
[
|
|
"OK",
|
|
[Error: ERR value is not an integer or out of range],
|
|
"OK",
|
|
]
|
|
`);
|
|
|
|
// Verify the valid commands were executed
|
|
const validValue = await ctx.redis.get(`${prefix}-valid`);
|
|
expect(validValue).toBe("valid");
|
|
|
|
const afterValue = await ctx.redis.get(`${prefix}-after`);
|
|
expect(afterValue).toBe("after");
|
|
});
|
|
|
|
test("should handle nested commands in transaction", async () => {
|
|
const prefix = ctx.generateKey("transaction-nested");
|
|
const hashKey = `${prefix}-hash`;
|
|
const setKey = `${prefix}-set`;
|
|
|
|
// Start transaction
|
|
await ctx.redis.send("MULTI", []);
|
|
|
|
// Queue complex data type commands
|
|
await ctx.redis.send("HSET", [hashKey, "field1", "value1", "field2", "value2"]);
|
|
await ctx.redis.send("SADD", [setKey, "member1", "member2", "member3"]);
|
|
await ctx.redis.send("HGETALL", [hashKey]);
|
|
await ctx.redis.send("SMEMBERS", [setKey]);
|
|
|
|
// Execute transaction
|
|
const execResult = await ctx.redis.send("EXEC", []);
|
|
|
|
// Should get an array of results
|
|
expect(Array.isArray(execResult)).toBe(true);
|
|
expect(execResult.length).toBe(4);
|
|
|
|
// HSET should return number of fields added
|
|
expect(execResult[0]).toBe(2);
|
|
|
|
// SADD should return number of members added
|
|
expect(execResult[1]).toBe(3);
|
|
|
|
// HGETALL should return hash object or array
|
|
const hashResult = execResult[2];
|
|
if (typeof hashResult === "object" && hashResult !== null) {
|
|
// RESP3 style (map)
|
|
expect(hashResult.field1).toBe("value1");
|
|
expect(hashResult.field2).toBe("value2");
|
|
} else if (Array.isArray(hashResult)) {
|
|
// RESP2 style (array of field-value pairs)
|
|
expect(hashResult.length).toBe(4);
|
|
expect(hashResult).toContain("field1");
|
|
expect(hashResult).toContain("value1");
|
|
expect(hashResult).toContain("field2");
|
|
expect(hashResult).toContain("value2");
|
|
}
|
|
|
|
// SMEMBERS should return array
|
|
expect(Array.isArray(execResult[3])).toBe(true);
|
|
expect(execResult[3].length).toBe(3);
|
|
expect(execResult[3]).toContain("member1");
|
|
expect(execResult[3]).toContain("member2");
|
|
expect(execResult[3]).toContain("member3");
|
|
});
|
|
});
|
|
|
|
describe("Complex Key Patterns", () => {
|
|
test("should handle hierarchical key patterns", async () => {
|
|
// Create hierarchical key structure (user:id:field)
|
|
const userId = randomUUIDv7().substring(0, 8);
|
|
const baseKey = ctx.generateKey(`user:${userId}`);
|
|
|
|
// Set multiple fields
|
|
await ctx.redis.set(`${baseKey}:name`, "John Doe");
|
|
await ctx.redis.set(`${baseKey}:email`, "john@example.com");
|
|
await ctx.redis.set(`${baseKey}:age`, "30");
|
|
|
|
// Create a counter
|
|
await ctx.redis.incr(`${baseKey}:visits`);
|
|
await ctx.redis.incr(`${baseKey}:visits`);
|
|
|
|
// Get all keys matching the pattern
|
|
const patternResult = await ctx.redis.send("KEYS", [`${baseKey}:*`]);
|
|
|
|
// Should find all our keys
|
|
expect(Array.isArray(patternResult)).toBe(true);
|
|
expect(patternResult.length).toBe(4);
|
|
|
|
// Sort for consistent snapshot
|
|
const sortedKeys = [...patternResult].sort();
|
|
expect(sortedKeys).toMatchInlineSnapshot(`
|
|
[
|
|
"${baseKey}:age",
|
|
"${baseKey}:email",
|
|
"${baseKey}:name",
|
|
"${baseKey}:visits",
|
|
]
|
|
`);
|
|
|
|
// Verify values
|
|
const nameValue = await ctx.redis.get(`${baseKey}:name`);
|
|
expect(nameValue).toBe("John Doe");
|
|
|
|
const visitsValue = await ctx.redis.get(`${baseKey}:visits`);
|
|
expect(visitsValue).toBe("2");
|
|
});
|
|
|
|
test("should handle complex key patterns with expiry", async () => {
|
|
// Create session-like structure
|
|
const sessionId = randomUUIDv7().substring(0, 8);
|
|
const baseKey = ctx.generateKey(`session:${sessionId}`);
|
|
|
|
// Set session data with expiry
|
|
await ctx.redis.set(`${baseKey}:data`, JSON.stringify({ user: "user123", role: "admin" }));
|
|
await ctx.redis.expire(`${baseKey}:data`, 30); // 30 second expiry
|
|
|
|
// Set session heartbeat with shorter expiry
|
|
await ctx.redis.set(`${baseKey}:heartbeat`, Date.now().toString());
|
|
await ctx.redis.expire(`${baseKey}:heartbeat`, 10); // 10 second expiry
|
|
|
|
// Verify TTLs
|
|
const dataTtl = await ctx.redis.ttl(`${baseKey}:data`);
|
|
expect(typeof dataTtl).toBe("number");
|
|
expect(dataTtl).toBeGreaterThan(0);
|
|
expect(dataTtl).toBeLessThanOrEqual(30);
|
|
|
|
const heartbeatTtl = await ctx.redis.ttl(`${baseKey}:heartbeat`);
|
|
expect(typeof heartbeatTtl).toBe("number");
|
|
expect(heartbeatTtl).toBeGreaterThan(0);
|
|
expect(heartbeatTtl).toBeLessThanOrEqual(10);
|
|
|
|
// Update heartbeat and reset TTL
|
|
await ctx.redis.set(`${baseKey}:heartbeat`, Date.now().toString());
|
|
await ctx.redis.expire(`${baseKey}:heartbeat`, 10);
|
|
|
|
// Verify updated TTL
|
|
const updatedTtl = await ctx.redis.ttl(`${baseKey}:heartbeat`);
|
|
expect(updatedTtl).toBeGreaterThan(0);
|
|
expect(updatedTtl).toBeLessThanOrEqual(10);
|
|
});
|
|
});
|
|
|
|
describe("Realistic Use Cases", () => {
|
|
test("should implement a simple rate limiter", async () => {
|
|
// Implementation of a rate limiter using Redis
|
|
const ipAddress = "192.168.1.1";
|
|
const rateLimitKey = ctx.generateKey(`ratelimit:${ipAddress}`);
|
|
const maxRequests = 5;
|
|
const windowSecs = 10;
|
|
|
|
// Function to check if the IP is rate limited
|
|
async function isRateLimited() {
|
|
// Get current count
|
|
const count = await ctx.redis.incr(rateLimitKey);
|
|
|
|
// If this is the first request, set expiry
|
|
if (count === 1) {
|
|
await ctx.redis.expire(rateLimitKey, windowSecs);
|
|
}
|
|
|
|
// Check if over limit
|
|
return count > maxRequests;
|
|
}
|
|
|
|
// Simulate multiple requests
|
|
const results = [];
|
|
for (let i = 0; i < 7; i++) {
|
|
results.push(await isRateLimited());
|
|
}
|
|
|
|
// Check results with inline snapshot for better readability
|
|
expect(results).toMatchInlineSnapshot(`
|
|
[
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
true,
|
|
true,
|
|
]
|
|
`);
|
|
|
|
const finalCount = await ctx.redis.get(rateLimitKey);
|
|
expect(finalCount).toBe("7");
|
|
|
|
// Verify TTL exists
|
|
const ttl = await ctx.redis.ttl(rateLimitKey);
|
|
expect(ttl).toBeGreaterThan(0);
|
|
expect(ttl).toBeLessThanOrEqual(windowSecs);
|
|
});
|
|
|
|
test("should implement a simple cache with expiry", async () => {
|
|
const cachePrefix = ctx.generateKey("cache");
|
|
|
|
// Cache implementation
|
|
async function getOrSetCache(key, ttl, fetchFunction) {
|
|
const cacheKey = `${cachePrefix}:${key}`;
|
|
|
|
// Try to get from cache
|
|
const cachedValue = await ctx.redis.get(cacheKey);
|
|
if (cachedValue !== null) {
|
|
return JSON.parse(cachedValue);
|
|
}
|
|
|
|
// Not in cache, fetch the value
|
|
const freshValue = await fetchFunction();
|
|
|
|
// Store in cache with expiry
|
|
await ctx.redis.set(cacheKey, JSON.stringify(freshValue));
|
|
await ctx.redis.expire(cacheKey, ttl);
|
|
|
|
return freshValue;
|
|
}
|
|
|
|
// Simulate expensive operation
|
|
let fetchCount = 0;
|
|
async function fetchData() {
|
|
fetchCount++;
|
|
return { data: "example", timestamp: Date.now() };
|
|
}
|
|
|
|
// First fetch should call the function
|
|
const result1 = await getOrSetCache("test-key", 30, fetchData);
|
|
expect(result1).toBeDefined();
|
|
expect(fetchCount).toBe(1);
|
|
|
|
// Second fetch should use cache
|
|
const result2 = await getOrSetCache("test-key", 30, fetchData);
|
|
expect(result2).toBeDefined();
|
|
expect(fetchCount).toBe(1); // Still 1 because we used cache
|
|
|
|
// Different key should call function again
|
|
const result3 = await getOrSetCache("other-key", 30, fetchData);
|
|
expect(result3).toBeDefined();
|
|
expect(fetchCount).toBe(2);
|
|
|
|
// Verify cache entry has TTL
|
|
const ttl = await ctx.redis.ttl(`${cachePrefix}:test-key`);
|
|
expect(ttl).toBeGreaterThan(0);
|
|
expect(ttl).toBeLessThanOrEqual(30);
|
|
});
|
|
|
|
test("should implement a simple leaderboard", async () => {
|
|
const leaderboardKey = ctx.generateKey("leaderboard");
|
|
|
|
// Add scores
|
|
await ctx.redis.send("ZADD", [leaderboardKey, "100", "player1"]);
|
|
await ctx.redis.send("ZADD", [leaderboardKey, "200", "player2"]);
|
|
await ctx.redis.send("ZADD", [leaderboardKey, "150", "player3"]);
|
|
await ctx.redis.send("ZADD", [leaderboardKey, "300", "player4"]);
|
|
await ctx.redis.send("ZADD", [leaderboardKey, "50", "player5"]);
|
|
|
|
// Get top 3 players (highest scores)
|
|
const topPlayers = await ctx.redis.send("ZREVRANGE", [leaderboardKey, "0", "2", "WITHSCORES"]);
|
|
|
|
expect(topPlayers).toMatchInlineSnapshot(`
|
|
[
|
|
[
|
|
"player4",
|
|
300,
|
|
],
|
|
[
|
|
"player2",
|
|
200,
|
|
],
|
|
[
|
|
"player3",
|
|
150,
|
|
],
|
|
]
|
|
`);
|
|
|
|
// Get player rank (0-based)
|
|
const player3Rank = await ctx.redis.send("ZREVRANK", [leaderboardKey, "player3"]);
|
|
expect(player3Rank).toBe(2); // 0-based index, so 3rd place is 2
|
|
|
|
// Get player score
|
|
const player3Score = await ctx.redis.send("ZSCORE", [leaderboardKey, "player3"]);
|
|
expect(player3Score).toBe(150);
|
|
|
|
// Increment a score
|
|
await ctx.redis.send("ZINCRBY", [leaderboardKey, "25", "player3"]);
|
|
|
|
// Verify score was updated
|
|
const updatedScore = await ctx.redis.send("ZSCORE", [leaderboardKey, "player3"]);
|
|
expect(updatedScore).toBe(175);
|
|
|
|
// Get updated rank
|
|
const updatedRank = await ctx.redis.send("ZREVRANK", [leaderboardKey, "player3"]);
|
|
expect(updatedRank).toBe(2); // Still in third place
|
|
|
|
// Get count of players with scores between 100 and 200
|
|
const countInRange = await ctx.redis.send("ZCOUNT", [leaderboardKey, "100", "200"]);
|
|
expect(countInRange).toBe(3); // player1, player3, player2
|
|
});
|
|
});
|
|
|
|
describe("Distributed Locks", () => {
|
|
test("should implement a simple distributed lock", async () => {
|
|
const lockName = ctx.generateKey("lock-resource");
|
|
const lockValue = randomUUIDv7(); // Unique identifier for the owner
|
|
const lockTimeout = 10; // seconds
|
|
|
|
// Acquire the lock
|
|
const acquireResult = await ctx.redis.send("SET", [
|
|
lockName,
|
|
lockValue,
|
|
"NX", // Only set if key doesn't exist
|
|
"EX",
|
|
lockTimeout.toString(),
|
|
]);
|
|
|
|
// Should acquire the lock successfully
|
|
expect(acquireResult).toBe("OK");
|
|
|
|
// Try to acquire again (should fail as it's already locked)
|
|
const retryResult = await ctx.redis.send("SET", [lockName, "other-value", "NX", "EX", lockTimeout.toString()]);
|
|
|
|
// Should return null (lock not acquired)
|
|
expect(retryResult).toBeNull();
|
|
|
|
// LUA script for safe release (only release if we own the lock)
|
|
const releaseLockScript = `
|
|
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
return redis.call("del", KEYS[1])
|
|
else
|
|
return 0
|
|
end
|
|
`;
|
|
|
|
// Release the lock
|
|
const releaseResult = await ctx.redis.send("EVAL", [
|
|
releaseLockScript,
|
|
"1", // Number of keys
|
|
lockName, // KEYS[1]
|
|
lockValue, // ARGV[1]
|
|
]);
|
|
|
|
// Should return 1 (lock released)
|
|
expect(releaseResult).toBe(1);
|
|
|
|
// Try to release again (should fail as lock is gone)
|
|
const reReleaseResult = await ctx.redis.send("EVAL", [releaseLockScript, "1", lockName, lockValue]);
|
|
|
|
// Should return 0 (no lock to release)
|
|
expect(reReleaseResult).toBe(0);
|
|
|
|
// Verify lock is gone
|
|
const finalCheck = await ctx.redis.get(lockName);
|
|
expect(finalCheck).toBeNull();
|
|
});
|
|
});
|
|
});
|