Skip to content
Closed
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
2 changes: 2 additions & 0 deletions scripts/verify-ci-test-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ const EXPECTED_BASELINE = [
{ group: "storage-and-schema", runner: "node", file: "test/smart-extractor-bulk-store-edge-cases.test.mjs", args: ["--test"] },
// Issue #680 regression tests
{ group: "core-regression", runner: "node", file: "test/memory-reflection-issue680-tdd.test.mjs", args: ["--test"] },
// Issue #704 Redis distributed lock — URL parsing fix
{ group: "storage-and-schema", runner: "node", file: "test/redis-url-parsing.test.mjs" },
];

function fail(message) {
Expand Down
156 changes: 156 additions & 0 deletions src/redis-lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// src/redis-lock.ts
/**
* Redis Lock Manager(基礎設施)
*
* 本檔案為 PR-1 基礎設施,RedisLockManager 完整實作見後續 PR。
* 目前工廠函式在 Redis 不可用時正確返回 null,確保現有 file lock 行為不受影響。
*/

import Redis from 'ioredis';

// ============================================================================
// URL 解析(修復 PR-1 問題:原本的 .replace('redis://', '') 破壞 auth/TLS URL)
// ============================================================================

/**
* 解析 Redis URL,處理三種格式:
* 1. Legacy(無 scheme):host:port → redis://host:port
* 2. 標準(redis://):完整保留,交給 ioredis 自己解析
* 3. TLS(rediss://):完整保留
*
* 原本的錯誤實作:
* new Redis(redisUrl.replace('redis://', ''))
* 會破壞:
* - redis://user:pass@host:6379 → "user:pass@host:6379"(ioredis 解析錯誤)
* - rediss://host:6379 → "rediss://host:6379"(scheme 被當成 hostname)
* - redis://host:6379?tls=true → "host:6379?tls=true"(query string 被當路徑)
*/
export function parseRedisUrl(url: string): string {
if (!url.includes('://')) {
// Legacy 格式(無 scheme):host:port → redis://host:port
return `redis://${url}`;
}
// 有 scheme:直接傳給 ioredis,讓它自己解析 auth / TLS / query string
return url;
}

// ============================================================================
// Lock Domain Decision(single-flight,全程序只決定一次)
// ============================================================================

export type LockDomain = 'redis' | 'file';

let _lockDomainDecision: LockDomain | null = null;
let _lockDomainPromise: Promise<LockDomain> | null = null;

/**
* 決定全程序使用哪種 lock domain。
*
* 採用 single-flight 模式:所有 concurrent caller 共享同一個 Promise,
* 確保整個 process 在啟動時只會執行一次 init 邏輯,
* 之後所有請求直接取用已決定的 domain,不會再改變。
*
* 一旦決定用 Redis,就永遠用 Redis(即使後來 Redis 掛了,也不重試)。
* 一旦決定用 File lock,就永遠用 File lock。
* 這樣可以避免「req-A 用 Redis lock,req-B 用 file lock」的 domain 分裂問題。
*/
export async function determineLockDomain(): Promise<LockDomain> {
if (_lockDomainDecision !== null) return _lockDomainDecision;
if (_lockDomainPromise !== null) return _lockDomainPromise;

_lockDomainPromise = (async () => {
try {
const manager = await createRedisLockManager();
if (manager && await manager.isHealthy()) {
_lockDomainDecision = 'redis';
return 'redis';
}
} catch {
// ignore — fallback to file
}
_lockDomainDecision = 'file';
return 'file';
})();

return _lockDomainPromise;
}

// ============================================================================
// RedisLockManager 工廠(骨架,完整實作見後續 PR)
// ============================================================================

export interface LockConfig {
redisUrl?: string;
ttl?: number;
maxWait?: number;
retryDelay?: number;
}

export class RedisLockManager {
private redis: Redis;
private defaultTTL = 60000;
private maxWait = 60000;
private retryDelay = 100;

constructor(config?: LockConfig) {
const redisUrl = config?.redisUrl || process.env.REDIS_URL || 'redis://localhost:6379';
this.redis = new Redis(parseRedisUrl(redisUrl), {
lazyConnect: true,
retryStrategy: (times) => {
if (times > 3) return null;
return Math.min(times * 200, 2000);
},
});

if (config?.ttl) this.defaultTTL = config.ttl;
if (config?.maxWait) this.maxWait = config.maxWait;
}

async connect(): Promise<void> {
try {
await this.redis.connect();
} catch (err) {
console.warn(`[RedisLock] Could not connect to Redis: ${err}`);
}
}

async isHealthy(): Promise<boolean> {
try {
await this.redis.ping();
return true;
} catch {
return false;
}
}

async disconnect(): Promise<void> {
await this.redis.quit();
}
}

/**
* 建立 RedisLockManager 工廠。
* 若 Redis 初始化失敗或無法連線,返回 null( caller 應 fallback 到 file lock)。
*
* 目前本函式在 Redis 不可用時直接返回 null,不嘗試重連。
* 完整實作(acquire / release / fallback)在後續 PR。
*/
export async function createRedisLockManager(
config?: LockConfig
): Promise<RedisLockManager | null> {
const manager = new RedisLockManager(config);

try {
await manager.connect();
const isHealthy = await manager.isHealthy();
if (isHealthy) {
return manager;
} else {
await manager.disconnect();
return null;
}
} catch (err) {
console.warn(`[RedisLock] Failed to initialize: ${err}`);
return null;
}
}
21 changes: 21 additions & 0 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,27 @@ export function __setLockfileModuleForTests(module: any): void {
lockfileModule = module;
}

// =========================================================================
// Redis Lock Domain Decision(single-flight,全程序只決定一次)
// =========================================================================

// Lazy import:避免 module 層級迴圈依賴
let _redisLockDomain: import("./redis-lock.js").LockDomain | null = null;

async function getLockDomain(): Promise<import("./redis-lock.js").LockDomain> {
if (_redisLockDomain !== null) return _redisLockDomain;

try {
const { determineLockDomain } = await import("./redis-lock.js");
_redisLockDomain = await determineLockDomain();
} catch {
// 若 redis-lock.js 不存在或匯入失敗,預設用 file lock
_redisLockDomain = 'file';
}

return _redisLockDomain;
}

export const loadLanceDB = async (): Promise<
typeof import("@lancedb/lancedb")
> => {
Expand Down
131 changes: 131 additions & 0 deletions test/redis-url-parsing.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* test/redis-url-parsing.test.mjs
*
* PR-1:URL parsing 單元測試
* 驗證 parseRedisUrl() 正確處理 legacy / auth / TLS / query string 格式
*
* 使用 jiti(與專案其他測試一致)來 import TypeScript 原始碼。
*/

import jitiFactory from "jiti";

const jiti = jitiFactory(import.meta.url, { interopDefault: true });
const { parseRedisUrl } = jiti('../src/redis-lock.ts');

// ============================================================================
// parseRedisUrl 測試
// ============================================================================

const URL_TESTS = [
{
input: 'localhost:6379',
expected: 'redis://localhost:6379',
description: 'Legacy 格式(無 scheme):補上 redis://',
},
{
input: 'host:6379',
expected: 'redis://host:6379',
description: 'Legacy 格式:基本 host:port',
},
{
input: 'redis://localhost:6379',
expected: 'redis://localhost:6379',
description: '標準格式(redis://):完整保留',
},
{
input: 'redis://user:password@localhost:6379',
expected: 'redis://user:password@localhost:6379',
description: '含密碼 URL:完整保留(不破壞 auth)',
},
{
input: 'redis://user:pass@host:6379',
expected: 'redis://user:pass@host:6379',
description: '含特殊字元密碼:不破壞',
},
{
input: 'rediss://localhost:6379',
expected: 'rediss://localhost:6379',
description: 'TLS 格式(rediss://):完整保留',
},
{
input: 'rediss://user:pass@host:6379',
expected: 'rediss://user:pass@host:6379',
description: 'TLS + Auth:完整保留',
},
{
input: 'redis://localhost:6379?tls=true',
expected: 'redis://localhost:6379?tls=true',
description: 'Query string:完整保留',
},
{
input: 'redis://host:6379?tls=true&maxRetriesPerRequest=3',
expected: 'redis://host:6379?tls=true&maxRetriesPerRequest=3',
description: '多個 query params:完整保留',
},
{
input: 'redis://localhost:6379/2',
expected: 'redis://localhost:6379/2',
description: 'DB index(/2):完整保留',
},
{
input: 'redis://:password@localhost:6379',
expected: 'redis://:password@localhost:6379',
description: '只有密碼(無 username):完整保留',
},
];

let passed = 0;
let failed = 0;

for (const { input, expected, description } of URL_TESTS) {
const result = parseRedisUrl(input);
const pass = result === expected;
if (pass) {
console.log(`✅ ${description}`);
passed++;
} else {
console.log(`❌ ${description}`);
console.log(` Input: ${input}`);
console.log(` Expected: ${expected}`);
console.log(` Got: ${result}`);
failed++;
}
}

// ============================================================================
// 邊界條件測試
// ============================================================================

const EDGE_CASES = [
{
input: '127.0.0.1:6379',
expected: 'redis://127.0.0.1:6379',
description: 'IP:port:補上 scheme',
},
{
input: '[::1]:6379',
expected: 'redis://[::1]:6379',
description: 'IPv6:port:補上 scheme',
},
];

for (const { input, expected, description } of EDGE_CASES) {
const result = parseRedisUrl(input);
const pass = result === expected;
if (pass) {
console.log(`✅ ${description}`);
passed++;
} else {
console.log(`❌ ${description}`);
console.log(` Input: ${input}`);
console.log(` Expected: ${expected}`);
console.log(` Got: ${result}`);
failed++;
}
}

console.log(`\n結果:${passed} 通過,${failed} 失敗(共 ${URL_TESTS.length + EDGE_CASES.length} 個測試)`);

if (failed > 0) {
process.exit(1);
}
Loading