@output.ai/core 0.3.9-dev.pr306-0a74ef2 → 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@output.ai/core",
3
- "version": "0.3.9-dev.pr306-0a74ef2",
3
+ "version": "0.3.11-dev.pr311-3950092",
4
4
  "description": "The core module of the output framework",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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 state = { client: null };
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
- * @returns {redis.RedisClientType}
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
- if ( await state.client?.ping().catch( _ => 0 ) === 'PONG' ) {
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
- const client = createClient( { url, socket: { keepAlive: 15000 } } );
17
- await client.connect();
18
- return state.client = client;
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
  } );