@output.ai/core 0.3.9-dev.pr306-3f50755 → 0.3.11-dev.pr311-3950092
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,19 +1,63 @@
|
|
|
1
1
|
import { createClient } from 'redis';
|
|
2
2
|
import { throws } from '#utils';
|
|
3
|
+
import { createChildLogger } from '#logger';
|
|
3
4
|
|
|
4
|
-
const
|
|
5
|
+
const log = createChildLogger( 'RedisClient' );
|
|
6
|
+
|
|
7
|
+
const state = {
|
|
8
|
+
client: null,
|
|
9
|
+
connectPromise: null
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
async function connect( url ) {
|
|
13
|
+
if ( state.client ) {
|
|
14
|
+
log.warn( 'Closing stale Redis client before reconnecting' );
|
|
15
|
+
await state.client.quit().catch( quitErr => {
|
|
16
|
+
log.warn( 'Failed to quit stale Redis client', { error: quitErr.message } );
|
|
17
|
+
} );
|
|
18
|
+
state.client = null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const client = createClient( { url, socket: { keepAlive: 15000 } } );
|
|
22
|
+
try {
|
|
23
|
+
await client.connect();
|
|
24
|
+
return state.client = client;
|
|
25
|
+
} catch ( err ) {
|
|
26
|
+
await client.quit().catch( () => {} );
|
|
27
|
+
throw new Error( `Failed to connect to Redis: ${err.message} (${err.code || 'UNKNOWN'})`, { cause: err } );
|
|
28
|
+
}
|
|
29
|
+
}
|
|
5
30
|
|
|
6
31
|
/**
|
|
7
|
-
* Return a connected Redis instance
|
|
8
|
-
*
|
|
32
|
+
* Return a connected Redis instance with automatic reconnection.
|
|
33
|
+
*
|
|
34
|
+
* Performs health check on cached client via ping(). If healthy, returns cached
|
|
35
|
+
* instance. Otherwise, closes stale client before creating new connection.
|
|
36
|
+
* Concurrent calls during connection will receive the same pending promise.
|
|
37
|
+
*
|
|
38
|
+
* @returns {Promise<redis.RedisClientType>} Connected Redis client
|
|
39
|
+
* @throws {Error} If REDIS_URL env var is missing
|
|
40
|
+
* @throws {Error} If connection fails (wrapped with context)
|
|
9
41
|
*/
|
|
10
42
|
export async function getRedisClient() {
|
|
11
43
|
const url = process.env.REDIS_URL ?? throws( new Error( 'Missing REDIS_URL environment variable' ) );
|
|
12
|
-
|
|
44
|
+
|
|
45
|
+
const pingResult = await state.client?.ping().catch( err => {
|
|
46
|
+
log.error( 'Redis ping failed', { error: err.message, code: err.code } );
|
|
47
|
+
return null;
|
|
48
|
+
} );
|
|
49
|
+
|
|
50
|
+
if ( pingResult === 'PONG' ) {
|
|
13
51
|
return state.client;
|
|
14
|
-
}
|
|
52
|
+
}
|
|
15
53
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
54
|
+
if ( state.connectPromise ) {
|
|
55
|
+
return state.connectPromise;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
state.connectPromise = connect( url ).finally( () => {
|
|
59
|
+
state.connectPromise = null;
|
|
60
|
+
} );
|
|
61
|
+
|
|
62
|
+
return state.connectPromise;
|
|
63
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
|
|
3
3
|
vi.mock( '#utils', () => ( {
|
|
4
4
|
throws: e => {
|
|
@@ -6,6 +6,14 @@ vi.mock( '#utils', () => ( {
|
|
|
6
6
|
}
|
|
7
7
|
} ) );
|
|
8
8
|
|
|
9
|
+
const logCalls = { warn: [], error: [] };
|
|
10
|
+
vi.mock( '#logger', () => ( {
|
|
11
|
+
createChildLogger: () => ( {
|
|
12
|
+
warn: ( ...args ) => logCalls.warn.push( args ),
|
|
13
|
+
error: ( ...args ) => logCalls.error.push( args )
|
|
14
|
+
} )
|
|
15
|
+
} ) );
|
|
16
|
+
|
|
9
17
|
const createClientImpl = vi.fn();
|
|
10
18
|
vi.mock( 'redis', () => ( { createClient: opts => createClientImpl( opts ) } ) );
|
|
11
19
|
|
|
@@ -18,6 +26,12 @@ describe( 'tracing/processors/s3/redis_client', () => {
|
|
|
18
26
|
beforeEach( () => {
|
|
19
27
|
vi.clearAllMocks();
|
|
20
28
|
delete process.env.REDIS_URL;
|
|
29
|
+
logCalls.warn = [];
|
|
30
|
+
logCalls.error = [];
|
|
31
|
+
} );
|
|
32
|
+
|
|
33
|
+
afterEach( () => {
|
|
34
|
+
vi.useRealTimers();
|
|
21
35
|
} );
|
|
22
36
|
|
|
23
37
|
it( 'throws if REDIS_URL is missing', async () => {
|
|
@@ -47,4 +61,121 @@ describe( 'tracing/processors/s3/redis_client', () => {
|
|
|
47
61
|
expect( c1 ).toBe( c2 );
|
|
48
62
|
expect( created[0] ).toMatchObject( { url: 'redis://localhost:6379', socket: { keepAlive: 15000 } } );
|
|
49
63
|
} );
|
|
64
|
+
|
|
65
|
+
it( 'closes stale client and reconnects when ping fails', async () => {
|
|
66
|
+
process.env.REDIS_URL = 'redis://localhost:6379';
|
|
67
|
+
|
|
68
|
+
const quitMock = vi.fn().mockResolvedValue();
|
|
69
|
+
const connectMock = vi.fn().mockResolvedValue();
|
|
70
|
+
const pingMock = vi.fn()
|
|
71
|
+
.mockResolvedValueOnce( 'PONG' )
|
|
72
|
+
.mockRejectedValueOnce( new Error( 'Connection lost' ) )
|
|
73
|
+
.mockResolvedValueOnce( 'PONG' );
|
|
74
|
+
|
|
75
|
+
const created = [];
|
|
76
|
+
createClientImpl.mockImplementation( opts => {
|
|
77
|
+
created.push( opts );
|
|
78
|
+
return { connect: connectMock, ping: pingMock, quit: quitMock };
|
|
79
|
+
} );
|
|
80
|
+
|
|
81
|
+
const { getRedisClient } = await loadModule();
|
|
82
|
+
|
|
83
|
+
const c1 = await getRedisClient();
|
|
84
|
+
const c2 = await getRedisClient();
|
|
85
|
+
expect( c1 ).toBe( c2 );
|
|
86
|
+
expect( created ).toHaveLength( 1 );
|
|
87
|
+
|
|
88
|
+
const c3 = await getRedisClient();
|
|
89
|
+
expect( quitMock ).toHaveBeenCalledTimes( 1 );
|
|
90
|
+
expect( created ).toHaveLength( 2 );
|
|
91
|
+
expect( c3 ).not.toBe( c1 );
|
|
92
|
+
} );
|
|
93
|
+
|
|
94
|
+
it( 'reconnects successfully even when quit() on stale client rejects', async () => {
|
|
95
|
+
process.env.REDIS_URL = 'redis://localhost:6379';
|
|
96
|
+
|
|
97
|
+
const quitMock = vi.fn().mockRejectedValue( new Error( 'Quit failed' ) );
|
|
98
|
+
const connectMock = vi.fn().mockResolvedValue();
|
|
99
|
+
const pingMock = vi.fn()
|
|
100
|
+
.mockResolvedValueOnce( 'PONG' )
|
|
101
|
+
.mockRejectedValueOnce( new Error( 'Connection lost' ) )
|
|
102
|
+
.mockResolvedValueOnce( 'PONG' );
|
|
103
|
+
|
|
104
|
+
const created = [];
|
|
105
|
+
createClientImpl.mockImplementation( opts => {
|
|
106
|
+
created.push( opts );
|
|
107
|
+
return { connect: connectMock, ping: pingMock, quit: quitMock };
|
|
108
|
+
} );
|
|
109
|
+
|
|
110
|
+
const { getRedisClient } = await loadModule();
|
|
111
|
+
|
|
112
|
+
const c1 = await getRedisClient();
|
|
113
|
+
const c1again = await getRedisClient();
|
|
114
|
+
expect( c1 ).toBe( c1again );
|
|
115
|
+
expect( created ).toHaveLength( 1 );
|
|
116
|
+
|
|
117
|
+
const c2 = await getRedisClient();
|
|
118
|
+
expect( quitMock ).toHaveBeenCalledTimes( 1 );
|
|
119
|
+
expect( created ).toHaveLength( 2 );
|
|
120
|
+
expect( c2 ).not.toBe( c1 );
|
|
121
|
+
} );
|
|
122
|
+
|
|
123
|
+
it( 'wraps connect() errors with code and cleans up failed client', async () => {
|
|
124
|
+
process.env.REDIS_URL = 'redis://localhost:6379';
|
|
125
|
+
|
|
126
|
+
const connectErr = new Error( 'Connection refused' );
|
|
127
|
+
connectErr.code = 'ECONNREFUSED';
|
|
128
|
+
const connectMock = vi.fn().mockRejectedValue( connectErr );
|
|
129
|
+
const quitMock = vi.fn().mockResolvedValue();
|
|
130
|
+
|
|
131
|
+
createClientImpl.mockImplementation( () => ( {
|
|
132
|
+
connect: connectMock,
|
|
133
|
+
quit: quitMock
|
|
134
|
+
} ) );
|
|
135
|
+
|
|
136
|
+
const { getRedisClient } = await loadModule();
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await getRedisClient();
|
|
140
|
+
expect.fail( 'Should have thrown' );
|
|
141
|
+
} catch ( err ) {
|
|
142
|
+
expect( err.message ).toBe( 'Failed to connect to Redis: Connection refused (ECONNREFUSED)' );
|
|
143
|
+
expect( err.cause ).toBe( connectErr );
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
expect( quitMock ).toHaveBeenCalledTimes( 1 );
|
|
147
|
+
} );
|
|
148
|
+
|
|
149
|
+
it( 'logs ping failures with error level', async () => {
|
|
150
|
+
process.env.REDIS_URL = 'redis://localhost:6379';
|
|
151
|
+
|
|
152
|
+
const pingErr = new Error( 'Connection reset' );
|
|
153
|
+
pingErr.code = 'ECONNRESET';
|
|
154
|
+
const pingMock = vi.fn()
|
|
155
|
+
.mockResolvedValueOnce( 'PONG' )
|
|
156
|
+
.mockRejectedValueOnce( pingErr )
|
|
157
|
+
.mockResolvedValueOnce( 'PONG' );
|
|
158
|
+
const connectMock = vi.fn().mockResolvedValue();
|
|
159
|
+
const quitMock = vi.fn().mockResolvedValue();
|
|
160
|
+
|
|
161
|
+
createClientImpl.mockImplementation( () => ( {
|
|
162
|
+
connect: connectMock,
|
|
163
|
+
ping: pingMock,
|
|
164
|
+
quit: quitMock
|
|
165
|
+
} ) );
|
|
166
|
+
|
|
167
|
+
const { getRedisClient } = await loadModule();
|
|
168
|
+
|
|
169
|
+
// First call: state.client is null, creates client (no ping)
|
|
170
|
+
await getRedisClient();
|
|
171
|
+
// Second call: pings existing client, returns PONG
|
|
172
|
+
await getRedisClient();
|
|
173
|
+
// Third call: pings existing client, fails with pingErr, reconnects
|
|
174
|
+
await getRedisClient();
|
|
175
|
+
|
|
176
|
+
expect( logCalls.error ).toContainEqual( [
|
|
177
|
+
'Redis ping failed',
|
|
178
|
+
{ error: 'Connection reset', code: 'ECONNRESET' }
|
|
179
|
+
] );
|
|
180
|
+
} );
|
|
50
181
|
} );
|