@tstdl/base 0.93.143 → 0.93.145
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/authentication/authentication.api.d.ts +9 -0
- package/authentication/authentication.api.js +3 -0
- package/authentication/client/authentication.service.d.ts +1 -0
- package/authentication/client/authentication.service.js +25 -16
- package/authentication/client/http-client.middleware.js +12 -5
- package/authentication/tests/authentication.client-middleware.test.js +63 -0
- package/authentication/tests/authentication.client-service-methods.test.js +38 -6
- package/authentication/tests/authentication.client-service-refresh.test.js +7 -0
- package/package.json +3 -3
- package/task-queue/postgres/task-queue.js +11 -11
- package/task-queue/postgres/task.model.d.ts +0 -6
- package/task-queue/postgres/task.model.js +1 -7
- package/task-queue/task-queue.d.ts +12 -2
- package/task-queue/task-queue.js +5 -0
- package/task-queue/tests/coverage-branch.test.js +2 -2
- package/task-queue/tests/fan-out-spawning.test.js +3 -3
- package/task-queue/tests/queue.test.js +4 -4
|
@@ -126,6 +126,9 @@ export declare const authenticationApiDefinition: {
|
|
|
126
126
|
timestamp: {
|
|
127
127
|
resource: string;
|
|
128
128
|
result: import("../schema/index.js").NumberSchema;
|
|
129
|
+
data: {
|
|
130
|
+
[dontWaitForValidToken]: boolean;
|
|
131
|
+
};
|
|
129
132
|
};
|
|
130
133
|
};
|
|
131
134
|
};
|
|
@@ -248,6 +251,9 @@ export declare function getAuthenticationApiDefinition<AdditionalTokenPayload ex
|
|
|
248
251
|
timestamp: {
|
|
249
252
|
resource: string;
|
|
250
253
|
result: import("../schema/index.js").NumberSchema;
|
|
254
|
+
data: {
|
|
255
|
+
[dontWaitForValidToken]: boolean;
|
|
256
|
+
};
|
|
251
257
|
};
|
|
252
258
|
};
|
|
253
259
|
};
|
|
@@ -365,6 +371,9 @@ export declare function getAuthenticationApiEndpointsDefinition<AdditionalTokenP
|
|
|
365
371
|
timestamp: {
|
|
366
372
|
resource: string;
|
|
367
373
|
result: import("../schema/index.js").NumberSchema;
|
|
374
|
+
data: {
|
|
375
|
+
[dontWaitForValidToken]: boolean;
|
|
376
|
+
};
|
|
368
377
|
};
|
|
369
378
|
};
|
|
370
379
|
export {};
|
|
@@ -31,6 +31,7 @@ export declare class AuthenticationClientService<AdditionalTokenPayload extends
|
|
|
31
31
|
private clockOffset;
|
|
32
32
|
private initialized;
|
|
33
33
|
private refreshLoopPromise;
|
|
34
|
+
private loggingOut;
|
|
34
35
|
/**
|
|
35
36
|
* Observable for authentication errors.
|
|
36
37
|
* Emits when a refresh fails.
|
|
@@ -74,6 +74,7 @@ let AuthenticationClientService = class AuthenticationClientService {
|
|
|
74
74
|
clockOffset = 0;
|
|
75
75
|
initialized = false;
|
|
76
76
|
refreshLoopPromise;
|
|
77
|
+
loggingOut;
|
|
77
78
|
/**
|
|
78
79
|
* Observable for authentication errors.
|
|
79
80
|
* Emits when a refresh fails.
|
|
@@ -233,18 +234,26 @@ let AuthenticationClientService = class AuthenticationClientService {
|
|
|
233
234
|
* This will attempt to end the session on the server and then clear local credentials.
|
|
234
235
|
*/
|
|
235
236
|
async logout() {
|
|
236
|
-
|
|
237
|
-
await
|
|
238
|
-
|
|
239
|
-
timeout(logoutTimeout),
|
|
240
|
-
]).catch((error) => this.logger.error(error));
|
|
241
|
-
}
|
|
242
|
-
finally {
|
|
243
|
-
// Always clear the local token, even if the server call fails.
|
|
244
|
-
this.forceRefreshRequested.set(false);
|
|
245
|
-
this.setNewToken(undefined);
|
|
246
|
-
this.loggedOutBus.publishAndForget();
|
|
237
|
+
if (isDefined(this.loggingOut)) {
|
|
238
|
+
await this.loggingOut;
|
|
239
|
+
return;
|
|
247
240
|
}
|
|
241
|
+
this.loggingOut = (async () => {
|
|
242
|
+
try {
|
|
243
|
+
await Promise.race([
|
|
244
|
+
this.client.endSession(),
|
|
245
|
+
timeout(logoutTimeout),
|
|
246
|
+
]).catch((error) => this.logger.error(error));
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
// Always clear the local token, even if the server call fails.
|
|
250
|
+
this.forceRefreshRequested.set(false);
|
|
251
|
+
this.setNewToken(undefined);
|
|
252
|
+
this.loggedOutBus.publishAndForget();
|
|
253
|
+
this.loggingOut = undefined;
|
|
254
|
+
}
|
|
255
|
+
})();
|
|
256
|
+
await this.loggingOut;
|
|
248
257
|
}
|
|
249
258
|
/**
|
|
250
259
|
* Force an immediate refresh of the token.
|
|
@@ -411,7 +420,7 @@ let AuthenticationClientService = class AuthenticationClientService {
|
|
|
411
420
|
if (stillNeedsRefresh) {
|
|
412
421
|
await this.refresh();
|
|
413
422
|
}
|
|
414
|
-
if (this.forceRefreshRequested() && (this.token() != currentToken)) {
|
|
423
|
+
if (this.forceRefreshRequested() && (this.token()?.jti != currentToken?.jti)) {
|
|
415
424
|
this.forceRefreshRequested.set(false);
|
|
416
425
|
}
|
|
417
426
|
});
|
|
@@ -419,7 +428,7 @@ let AuthenticationClientService = class AuthenticationClientService {
|
|
|
419
428
|
// Lock held by another instance, wait 5 seconds or until token changes (Passive Sync)
|
|
420
429
|
const changeReason = await firstValueFrom(race([
|
|
421
430
|
timer(5000).pipe(map(() => 'timer')),
|
|
422
|
-
this.token$.pipe(filter((t) => t != token), map(() => 'token')),
|
|
431
|
+
this.token$.pipe(filter((t) => t?.jti != token.jti), map(() => 'token')),
|
|
423
432
|
from(this.disposeSignal),
|
|
424
433
|
]), { defaultValue: undefined });
|
|
425
434
|
if (changeReason == 'token') {
|
|
@@ -433,7 +442,7 @@ let AuthenticationClientService = class AuthenticationClientService {
|
|
|
433
442
|
const delay = Math.min(maxRefreshDelay, ((currentToken?.exp ?? 0) - this.estimatedServerTimestampSeconds() - currentRefreshBufferSeconds) * millisecondsPerSecond);
|
|
434
443
|
const wakeUpSignals = [
|
|
435
444
|
from(this.disposeSignal),
|
|
436
|
-
this.token$.pipe(filter((t) => t != currentToken)),
|
|
445
|
+
this.token$.pipe(filter((t) => t?.jti != currentToken?.jti)),
|
|
437
446
|
];
|
|
438
447
|
if (!forceRefresh) {
|
|
439
448
|
wakeUpSignals.push(this.forceRefreshRequested$.pipe(filter((requested) => requested)));
|
|
@@ -442,7 +451,7 @@ let AuthenticationClientService = class AuthenticationClientService {
|
|
|
442
451
|
await firstValueFrom(race([timer(delay), ...wakeUpSignals]), { defaultValue: undefined });
|
|
443
452
|
}
|
|
444
453
|
else {
|
|
445
|
-
await firstValueFrom(race([timer(
|
|
454
|
+
await firstValueFrom(race([timer(2500), ...wakeUpSignals]), { defaultValue: undefined });
|
|
446
455
|
}
|
|
447
456
|
}
|
|
448
457
|
catch (error) {
|
|
@@ -451,7 +460,7 @@ let AuthenticationClientService = class AuthenticationClientService {
|
|
|
451
460
|
await firstValueFrom(race([
|
|
452
461
|
timer(2500),
|
|
453
462
|
from(this.disposeSignal),
|
|
454
|
-
this.token$.pipe(filter((t) => t != currentToken)),
|
|
463
|
+
this.token$.pipe(filter((t) => t?.jti != currentToken?.jti)),
|
|
455
464
|
this.forceRefreshRequested$.pipe(filter((requested) => requested), skip(this.forceRefreshRequested() ? 1 : 0)),
|
|
456
465
|
]), { defaultValue: undefined });
|
|
457
466
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { firstValueFrom, race, timeout } from 'rxjs';
|
|
1
|
+
import { firstValueFrom, race, timeout as rxjsTimeout } from 'rxjs';
|
|
2
2
|
import { HttpError } from '../../http/index.js';
|
|
3
|
+
import { timeout } from '../../utils/timing.js';
|
|
3
4
|
import { isDefined } from '../../utils/type-guards.js';
|
|
4
5
|
import { cacheValueOrAsyncProvider } from '../../utils/value-or-provider.js';
|
|
5
6
|
import { dontWaitForValidToken } from '../authentication.api.js';
|
|
@@ -19,10 +20,13 @@ export function waitForAuthenticationCredentialsMiddleware(authenticationService
|
|
|
19
20
|
authenticationService.validToken$,
|
|
20
21
|
request.cancellationSignal,
|
|
21
22
|
]);
|
|
22
|
-
await firstValueFrom(race$.pipe(
|
|
23
|
+
await firstValueFrom(race$.pipe(rxjsTimeout(30000))).catch(() => undefined);
|
|
23
24
|
if (request.cancellationSignal.isSet) {
|
|
24
25
|
break;
|
|
25
26
|
}
|
|
27
|
+
if (!authenticationService.hasValidToken && authenticationService.isLoggedIn()) {
|
|
28
|
+
await timeout(100);
|
|
29
|
+
}
|
|
26
30
|
}
|
|
27
31
|
}
|
|
28
32
|
await next();
|
|
@@ -36,14 +40,17 @@ export function waitForAuthenticationCredentialsMiddleware(authenticationService
|
|
|
36
40
|
*/
|
|
37
41
|
export function logoutOnUnauthorizedMiddleware(authenticationServiceOrProvider) {
|
|
38
42
|
const getAuthenticationService = cacheValueOrAsyncProvider(authenticationServiceOrProvider);
|
|
39
|
-
async function logoutOnUnauthorizedMiddleware(
|
|
43
|
+
async function logoutOnUnauthorizedMiddleware({ request }, next) {
|
|
40
44
|
try {
|
|
41
45
|
await next();
|
|
42
46
|
}
|
|
43
47
|
catch (error) {
|
|
44
48
|
if ((error instanceof HttpError) && (error.response?.statusCode == 401)) {
|
|
45
|
-
const
|
|
46
|
-
|
|
49
|
+
const endpoint = request.context?.endpoint;
|
|
50
|
+
if (endpoint?.data?.[dontWaitForValidToken] != true) {
|
|
51
|
+
const authenticationService = await getAuthenticationService();
|
|
52
|
+
await authenticationService.logout();
|
|
53
|
+
}
|
|
47
54
|
}
|
|
48
55
|
throw error;
|
|
49
56
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { of } from 'rxjs';
|
|
2
2
|
import { describe, expect, test, vi } from 'vitest';
|
|
3
3
|
import { HttpClientRequest, HttpClientResponse, HttpError, HttpErrorReason } from '../../http/index.js';
|
|
4
|
+
import { dontWaitForValidToken } from '../authentication.api.js';
|
|
4
5
|
import { logoutOnUnauthorizedMiddleware, waitForAuthenticationCredentialsMiddleware } from '../client/http-client.middleware.js';
|
|
5
6
|
describe('waitForAuthenticationCredentialsMiddleware', () => {
|
|
6
7
|
test('should wait for token and call next', async () => {
|
|
@@ -20,6 +21,41 @@ describe('waitForAuthenticationCredentialsMiddleware', () => {
|
|
|
20
21
|
await middleware({ request }, next);
|
|
21
22
|
expect(next).toHaveBeenCalled();
|
|
22
23
|
});
|
|
24
|
+
test('should NOT wait if endpoint has dontWaitForValidToken', async () => {
|
|
25
|
+
const authenticationServiceMock = {
|
|
26
|
+
isLoggedIn: vi.fn().mockReturnValue(true),
|
|
27
|
+
hasValidToken: false,
|
|
28
|
+
};
|
|
29
|
+
const middleware = waitForAuthenticationCredentialsMiddleware(authenticationServiceMock);
|
|
30
|
+
const request = new HttpClientRequest('http://localhost');
|
|
31
|
+
request.context = {
|
|
32
|
+
endpoint: {
|
|
33
|
+
credentials: true,
|
|
34
|
+
data: {
|
|
35
|
+
[dontWaitForValidToken]: true,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
const next = vi.fn().mockResolvedValue(undefined);
|
|
40
|
+
await middleware({ request }, next);
|
|
41
|
+
expect(next).toHaveBeenCalled();
|
|
42
|
+
});
|
|
43
|
+
test('should NOT wait if credentials is NOT true', async () => {
|
|
44
|
+
const authenticationServiceMock = {
|
|
45
|
+
isLoggedIn: vi.fn().mockReturnValue(true),
|
|
46
|
+
hasValidToken: false,
|
|
47
|
+
};
|
|
48
|
+
const middleware = waitForAuthenticationCredentialsMiddleware(authenticationServiceMock);
|
|
49
|
+
const request = new HttpClientRequest('http://localhost');
|
|
50
|
+
request.context = {
|
|
51
|
+
endpoint: {
|
|
52
|
+
credentials: false,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
const next = vi.fn().mockResolvedValue(undefined);
|
|
56
|
+
await middleware({ request }, next);
|
|
57
|
+
expect(next).toHaveBeenCalled();
|
|
58
|
+
});
|
|
23
59
|
});
|
|
24
60
|
describe('logoutOnUnauthorizedMiddleware', () => {
|
|
25
61
|
test('should call logout on 401 error', async () => {
|
|
@@ -41,6 +77,33 @@ describe('logoutOnUnauthorizedMiddleware', () => {
|
|
|
41
77
|
await expect(middleware({ request }, next)).rejects.toThrow(HttpError);
|
|
42
78
|
expect(authenticationServiceMock.logout).toHaveBeenCalled();
|
|
43
79
|
});
|
|
80
|
+
test('should NOT call logout on 401 error if endpoint has dontWaitForValidToken', async () => {
|
|
81
|
+
const authenticationServiceMock = {
|
|
82
|
+
logout: vi.fn().mockResolvedValue(undefined),
|
|
83
|
+
};
|
|
84
|
+
const middleware = logoutOnUnauthorizedMiddleware(authenticationServiceMock);
|
|
85
|
+
const request = new HttpClientRequest('http://localhost');
|
|
86
|
+
request.context = {
|
|
87
|
+
endpoint: {
|
|
88
|
+
resource: 'end-session',
|
|
89
|
+
data: {
|
|
90
|
+
[dontWaitForValidToken]: true,
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
const response = new HttpClientResponse({
|
|
95
|
+
request,
|
|
96
|
+
statusCode: 401,
|
|
97
|
+
statusMessage: 'Unauthorized',
|
|
98
|
+
headers: {},
|
|
99
|
+
body: undefined,
|
|
100
|
+
closeHandler: () => { }
|
|
101
|
+
});
|
|
102
|
+
const error = new HttpError(HttpErrorReason.StatusCode, request, { response });
|
|
103
|
+
const next = vi.fn().mockRejectedValue(error);
|
|
104
|
+
await expect(middleware({ request }, next)).rejects.toThrow(HttpError);
|
|
105
|
+
expect(authenticationServiceMock.logout).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
44
107
|
test('should not call logout on other errors', async () => {
|
|
45
108
|
const authenticationServiceMock = {
|
|
46
109
|
logout: vi.fn().mockResolvedValue(undefined),
|
|
@@ -1,19 +1,20 @@
|
|
|
1
|
-
import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest';
|
|
2
1
|
import { Subject } from 'rxjs';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
|
3
3
|
import { AuthenticationClientService } from '../../authentication/client/authentication.service.js';
|
|
4
4
|
import { AUTHENTICATION_API_CLIENT } from '../../authentication/client/tokens.js';
|
|
5
|
+
import { CancellationSignal, CancellationToken } from '../../cancellation/token.js';
|
|
6
|
+
import { Injector } from '../../injector/index.js';
|
|
5
7
|
import { Lock } from '../../lock/index.js';
|
|
6
8
|
import { Logger } from '../../logger/index.js';
|
|
7
9
|
import { MessageBus } from '../../message-bus/index.js';
|
|
8
|
-
import { Injector } from '../../injector/index.js';
|
|
9
|
-
import { CancellationSignal, CancellationToken } from '../../cancellation/token.js';
|
|
10
10
|
import { configureDefaultSignalsImplementation } from '../../signals/implementation/configure.js';
|
|
11
11
|
describe('AuthenticationClientService Methods', () => {
|
|
12
12
|
let injector;
|
|
13
13
|
let service;
|
|
14
14
|
let mockApiClient;
|
|
15
15
|
let mockLock;
|
|
16
|
-
let
|
|
16
|
+
let mockTokenUpdateBus;
|
|
17
|
+
let mockLoggedOutBus;
|
|
17
18
|
let mockLogger;
|
|
18
19
|
beforeEach(() => {
|
|
19
20
|
const storage = new Map();
|
|
@@ -46,9 +47,16 @@ describe('AuthenticationClientService Methods', () => {
|
|
|
46
47
|
return await callback({ lost: false });
|
|
47
48
|
}),
|
|
48
49
|
};
|
|
49
|
-
|
|
50
|
+
mockTokenUpdateBus = {
|
|
50
51
|
publishAndForget: vi.fn(),
|
|
51
52
|
messages$: new Subject(),
|
|
53
|
+
allMessages$: new Subject(),
|
|
54
|
+
dispose: vi.fn(),
|
|
55
|
+
};
|
|
56
|
+
mockLoggedOutBus = {
|
|
57
|
+
publishAndForget: vi.fn(),
|
|
58
|
+
messages$: new Subject(),
|
|
59
|
+
allMessages$: new Subject(),
|
|
52
60
|
dispose: vi.fn(),
|
|
53
61
|
};
|
|
54
62
|
mockLogger = {
|
|
@@ -59,7 +67,15 @@ describe('AuthenticationClientService Methods', () => {
|
|
|
59
67
|
};
|
|
60
68
|
injector.register(AUTHENTICATION_API_CLIENT, { useValue: mockApiClient });
|
|
61
69
|
injector.register(Lock, { useValue: mockLock });
|
|
62
|
-
injector.register(MessageBus, {
|
|
70
|
+
injector.register(MessageBus, {
|
|
71
|
+
useFactory: (argument) => {
|
|
72
|
+
if (argument === 'AuthenticationService:tokenUpdate')
|
|
73
|
+
return mockTokenUpdateBus;
|
|
74
|
+
if (argument === 'AuthenticationService:loggedOut')
|
|
75
|
+
return mockLoggedOutBus;
|
|
76
|
+
return undefined;
|
|
77
|
+
},
|
|
78
|
+
});
|
|
63
79
|
injector.register(Logger, { useValue: mockLogger });
|
|
64
80
|
const disposeToken = new CancellationToken();
|
|
65
81
|
injector.register(CancellationSignal, { useValue: disposeToken.signal });
|
|
@@ -122,6 +138,22 @@ describe('AuthenticationClientService Methods', () => {
|
|
|
122
138
|
mockApiClient.unimpersonate.mockRejectedValue(new Error('Unimpersonation failed'));
|
|
123
139
|
await expect(service.unimpersonate()).rejects.toThrow('Unimpersonation failed');
|
|
124
140
|
});
|
|
141
|
+
test('logout should handle concurrent calls and avoid multiple api requests', async () => {
|
|
142
|
+
let resolveEndSession;
|
|
143
|
+
const endSessionPromise = new Promise((resolve) => {
|
|
144
|
+
resolveEndSession = resolve;
|
|
145
|
+
});
|
|
146
|
+
mockApiClient.endSession.mockReturnValue(endSessionPromise);
|
|
147
|
+
const logout1 = service.logout();
|
|
148
|
+
const logout2 = service.logout();
|
|
149
|
+
// logout1 and logout2 will be different promises because the method is async
|
|
150
|
+
expect(mockApiClient.endSession).toHaveBeenCalledTimes(1);
|
|
151
|
+
resolveEndSession(undefined);
|
|
152
|
+
await Promise.all([logout1, logout2]);
|
|
153
|
+
expect(service.isLoggedIn()).toBe(false);
|
|
154
|
+
expect(mockTokenUpdateBus.publishAndForget).toHaveBeenCalledWith(undefined);
|
|
155
|
+
expect(mockLoggedOutBus.publishAndForget).toHaveBeenCalled();
|
|
156
|
+
});
|
|
125
157
|
test('syncClock should handle errors gracefully', async () => {
|
|
126
158
|
mockApiClient.timestamp.mockRejectedValue(new Error('Time sync failed'));
|
|
127
159
|
await service.syncClock();
|
|
@@ -123,4 +123,11 @@ describe('AuthenticationClientService Refresh Loop Reproduction', () => {
|
|
|
123
123
|
// If it busy loops, this will be much higher than 1.
|
|
124
124
|
expect(mockLock.tryUse.mock.calls.length).toBeLessThan(5);
|
|
125
125
|
});
|
|
126
|
+
test('refresh() should call timestamp() to sync clock', async () => {
|
|
127
|
+
service = injector.resolve(AuthenticationClientService);
|
|
128
|
+
const newToken = { iat: 1000, exp: 2000, jti: 'new' };
|
|
129
|
+
mockApiClient.refresh.mockResolvedValue(newToken);
|
|
130
|
+
await service.refresh();
|
|
131
|
+
expect(mockApiClient.timestamp).toHaveBeenCalled();
|
|
132
|
+
});
|
|
126
133
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tstdl/base",
|
|
3
|
-
"version": "0.93.
|
|
3
|
+
"version": "0.93.145",
|
|
4
4
|
"author": "Patrick Hein",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -152,8 +152,8 @@
|
|
|
152
152
|
"type-fest": "^5.4"
|
|
153
153
|
},
|
|
154
154
|
"peerDependencies": {
|
|
155
|
-
"@aws-sdk/client-s3": "^3.
|
|
156
|
-
"@aws-sdk/s3-request-presigner": "^3.
|
|
155
|
+
"@aws-sdk/client-s3": "^3.999",
|
|
156
|
+
"@aws-sdk/s3-request-presigner": "^3.999",
|
|
157
157
|
"@genkit-ai/google-genai": "^1.29",
|
|
158
158
|
"@google-cloud/storage": "^7.19",
|
|
159
159
|
"@toon-format/toon": "^2.1.0",
|
|
@@ -73,10 +73,10 @@ import { Timer } from '../../utils/timer.js';
|
|
|
73
73
|
import { cancelableTimeout } from '../../utils/timing.js';
|
|
74
74
|
import { isArray, isDefined, isNotNull, isNull, isNumber, isString, isUndefined } from '../../utils/type-guards.js';
|
|
75
75
|
import { millisecondsPerMinute, millisecondsPerSecond } from '../../utils/units.js';
|
|
76
|
-
import { defaultQueueConfig, TaskDependencyType, TaskQueue, TaskStatus } from '../task-queue.js';
|
|
76
|
+
import { defaultQueueConfig, queueableOrWaitableStatuses, queueableStatuses, TaskDependencyType, TaskQueue, TaskStatus, terminalStatuses } from '../task-queue.js';
|
|
77
77
|
import { PostgresTaskQueueModuleConfig } from './module.js';
|
|
78
78
|
import { taskArchive as taskArchiveTable, taskDependency as taskDependencyTable, taskDependencyType, taskStatus, task as taskTable } from './schemas.js';
|
|
79
|
-
import {
|
|
79
|
+
import { PostgresTask, PostgresTaskArchive } from './task.model.js';
|
|
80
80
|
let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
|
|
81
81
|
#database = inject(Database);
|
|
82
82
|
#repository = injectRepository(PostgresTask);
|
|
@@ -189,7 +189,7 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
|
|
|
189
189
|
}));
|
|
190
190
|
const itemsWithIdempotency = entitiesWithIndex.filter((e) => isNotNull(e.entity.idempotencyKey));
|
|
191
191
|
const itemsWithoutIdempotency = entitiesWithIndex.filter((e) => isNull(e.entity.idempotencyKey));
|
|
192
|
-
const hasDependencies = itemsWithDistinctDependencies.some((item) => ((item.scheduleAfter?.length ?? 0) > 0) || ((item.completeAfter?.length ?? 0) > 0) || (isDefined(item.parentId) && (item.
|
|
192
|
+
const hasDependencies = itemsWithDistinctDependencies.some((item) => ((item.scheduleAfter?.length ?? 0) > 0) || ((item.completeAfter?.length ?? 0) > 0) || (isDefined(item.parentId) && (item.parentRequires != false) && !(isArray(item.parentRequires) && (item.parentRequires.length == 0))));
|
|
193
193
|
const mustUseTransaction = (entitiesWithIndex.length > 1) || hasDependencies;
|
|
194
194
|
const newTransaction = __addDisposableResource(env_1, (mustUseTransaction && isUndefined(options?.transaction)) ? await this.#repository.startTransaction() : undefined, true);
|
|
195
195
|
const transaction = newTransaction ?? options?.transaction;
|
|
@@ -258,13 +258,13 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
|
|
|
258
258
|
if (!processedTaskIds.has(task.id)) {
|
|
259
259
|
continue;
|
|
260
260
|
}
|
|
261
|
-
if (isDefined(item.parentId) && (item.
|
|
261
|
+
if (isDefined(item.parentId) && (item.parentRequires != false) && !(isArray(item.parentRequires) && (item.parentRequires.length == 0))) {
|
|
262
262
|
dependencies.push({
|
|
263
263
|
taskId: item.parentId,
|
|
264
264
|
dependencyTaskId: task.id,
|
|
265
265
|
type: TaskDependencyType.Child,
|
|
266
|
-
requiredStatuses: isArray(item.
|
|
267
|
-
? item.
|
|
266
|
+
requiredStatuses: isArray(item.parentRequires)
|
|
267
|
+
? item.parentRequires
|
|
268
268
|
: [TaskStatus.Completed],
|
|
269
269
|
});
|
|
270
270
|
}
|
|
@@ -506,7 +506,7 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
|
|
|
506
506
|
const timeout = options?.timeout ?? Infinity;
|
|
507
507
|
const interval = options?.interval ?? 1000;
|
|
508
508
|
const cancellationSignal = this.#cancellationSignal.optionallyInherit(options?.cancellationSignal);
|
|
509
|
-
const waitStatuses = options?.statuses ??
|
|
509
|
+
const waitStatuses = options?.statuses ?? terminalStatuses;
|
|
510
510
|
const messageBus$ = this.#messageBus.allMessages$.pipe(filter((namespace) => namespace == this.#namespace), throttleTime(500, undefined, { leading: true, trailing: true }));
|
|
511
511
|
const continue$ = merge(messageBus$, cancellationSignal);
|
|
512
512
|
const timer = Timer.startNew();
|
|
@@ -566,7 +566,7 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
|
|
|
566
566
|
const nonFinalizedTasks = await tx.pgTransaction
|
|
567
567
|
.select({ id: taskTable.id, namespace: taskTable.namespace })
|
|
568
568
|
.from(taskTable)
|
|
569
|
-
.where(and(eq(taskTable.namespace, this.#namespace), notInArray(taskTable.status,
|
|
569
|
+
.where(and(eq(taskTable.namespace, this.#namespace), notInArray(taskTable.status, terminalStatuses)))
|
|
570
570
|
.for('update');
|
|
571
571
|
if (nonFinalizedTasks.length > 0) {
|
|
572
572
|
const ids = nonFinalizedTasks.map((t) => t.id);
|
|
@@ -1065,7 +1065,7 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
|
|
|
1065
1065
|
status: taskTable.status,
|
|
1066
1066
|
});
|
|
1067
1067
|
for (const row of updatedRows) {
|
|
1068
|
-
if (
|
|
1068
|
+
if (terminalStatuses.includes(row.status)) {
|
|
1069
1069
|
terminalTasks.push({ id: row.id, status: row.status, namespace: row.namespace });
|
|
1070
1070
|
}
|
|
1071
1071
|
notifiedNamespaces.add(row.namespace);
|
|
@@ -1101,7 +1101,7 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
|
|
|
1101
1101
|
const rowsToArchive = await tx.pgTransaction
|
|
1102
1102
|
.select()
|
|
1103
1103
|
.from(taskTable)
|
|
1104
|
-
.where(and(eq(taskTable.namespace, this.#namespace), inArray(taskTable.status,
|
|
1104
|
+
.where(and(eq(taskTable.namespace, this.#namespace), inArray(taskTable.status, terminalStatuses), lte(taskTable.completeTimestamp, sql `${TRANSACTION_TIMESTAMP} - ${interval(this.retention, 'milliseconds')}`), notExists(tx.pgTransaction
|
|
1105
1105
|
.select({ id: childTaskTable.id })
|
|
1106
1106
|
.from(childTaskTable)
|
|
1107
1107
|
.where(eq(childTaskTable.parentId, taskTable.id))), notExists(tx.pgTransaction
|
|
@@ -1299,7 +1299,7 @@ let PostgresTaskQueue = class PostgresTaskQueue extends TaskQueue {
|
|
|
1299
1299
|
priorityAgeTimestamp: TRANSACTION_TIMESTAMP,
|
|
1300
1300
|
state: (options?.resetState == true) ? null : undefined,
|
|
1301
1301
|
})
|
|
1302
|
-
.where(and(eq(taskTable.id, id), or(inArray(taskTable.status, queueableStatuses), inArray(taskTable.status,
|
|
1302
|
+
.where(and(eq(taskTable.id, id), or(inArray(taskTable.status, queueableStatuses), inArray(taskTable.status, terminalStatuses), lt(taskTable.visibilityDeadline, TRANSACTION_TIMESTAMP))));
|
|
1303
1303
|
}
|
|
1304
1304
|
notify(namespace = this.#namespace) {
|
|
1305
1305
|
this.#messageBus.publishAndForget(namespace);
|
|
@@ -2,12 +2,6 @@
|
|
|
2
2
|
import { BaseEntity, type Json, type Timestamp } from '../../orm/index.js';
|
|
3
3
|
import type { ObjectLiteral } from '../../types/types.js';
|
|
4
4
|
import { type Task, TaskDependencyType, TaskStatus } from '../task-queue.js';
|
|
5
|
-
export declare const terminalStatuses: TaskStatus[];
|
|
6
|
-
export declare const finalizedStatuses: TaskStatus[];
|
|
7
|
-
export declare const nonFinalizedStatuses: TaskStatus[];
|
|
8
|
-
export declare const queueableStatuses: TaskStatus[];
|
|
9
|
-
export declare const waitableStatuses: TaskStatus[];
|
|
10
|
-
export declare const queueableOrWaitableStatuses: TaskStatus[];
|
|
11
5
|
export declare abstract class PostgresTaskBase<Data extends ObjectLiteral = ObjectLiteral, State extends ObjectLiteral = ObjectLiteral, Result extends ObjectLiteral = ObjectLiteral> extends BaseEntity implements Task {
|
|
12
6
|
namespace: string;
|
|
13
7
|
type: string;
|
|
@@ -11,13 +11,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
|
|
|
11
11
|
import { BaseEntity, ForeignKey, Index, JsonProperty, Table, TimestampProperty, Unique, UuidProperty } from '../../orm/index.js';
|
|
12
12
|
import { Array as ArrayProperty, BooleanProperty, Enumeration, Integer, NumberProperty, StringProperty } from '../../schema/index.js';
|
|
13
13
|
import { isNotNull } from 'drizzle-orm';
|
|
14
|
-
import { TaskDependencyType, TaskStatus } from '../task-queue.js';
|
|
15
|
-
export const terminalStatuses = [TaskStatus.Completed, TaskStatus.Cancelled, TaskStatus.Dead, TaskStatus.TimedOut, TaskStatus.Expired, TaskStatus.Skipped, TaskStatus.Orphaned];
|
|
16
|
-
export const finalizedStatuses = terminalStatuses;
|
|
17
|
-
export const nonFinalizedStatuses = [TaskStatus.Pending, TaskStatus.Retrying, TaskStatus.Waiting, TaskStatus.WaitingChildren, TaskStatus.Running];
|
|
18
|
-
export const queueableStatuses = [TaskStatus.Pending, TaskStatus.Retrying];
|
|
19
|
-
export const waitableStatuses = [TaskStatus.Waiting, TaskStatus.WaitingChildren];
|
|
20
|
-
export const queueableOrWaitableStatuses = [...queueableStatuses, ...waitableStatuses];
|
|
14
|
+
import { queueableOrWaitableStatuses, queueableStatuses, TaskDependencyType, TaskStatus, terminalStatuses } from '../task-queue.js';
|
|
21
15
|
export class PostgresTaskBase extends BaseEntity {
|
|
22
16
|
namespace;
|
|
23
17
|
type;
|
|
@@ -95,10 +95,20 @@ export declare const TaskStatus: {
|
|
|
95
95
|
*/
|
|
96
96
|
readonly Orphaned: "orphaned";
|
|
97
97
|
};
|
|
98
|
+
export declare const terminalStatuses: ["completed", "cancelled", "dead", "timed-out", "expired", "skipped", "orphaned"];
|
|
99
|
+
export declare const nonTerminalStatuses: ["pending", "retrying", "waiting", "waiting-children", "running"];
|
|
100
|
+
export declare const queueableStatuses: ["pending", "retrying"];
|
|
101
|
+
export declare const waitableStatuses: ["waiting", "waiting-children"];
|
|
102
|
+
export declare const queueableOrWaitableStatuses: ["pending", "retrying", "waiting", "waiting-children"];
|
|
98
103
|
/**
|
|
99
104
|
* Type of task status.
|
|
100
105
|
*/
|
|
101
106
|
export type TaskStatus = EnumType<typeof TaskStatus>;
|
|
107
|
+
export type TerminalTaskStatus = typeof terminalStatuses[number];
|
|
108
|
+
export type NonTerminalTaskStatus = typeof nonTerminalStatuses[number];
|
|
109
|
+
export type QueueableTaskStatus = typeof queueableStatuses[number];
|
|
110
|
+
export type WaitableTaskStatus = typeof waitableStatuses[number];
|
|
111
|
+
export type QueueableOrWaitableTaskStatus = typeof queueableOrWaitableStatuses[number];
|
|
102
112
|
/**
|
|
103
113
|
* Represents the type of dependency between tasks.
|
|
104
114
|
*/
|
|
@@ -158,7 +168,7 @@ export type Task<Definitions extends TaskDefinitionMap = Record<string, {
|
|
|
158
168
|
unresolvedScheduleDependencies: number;
|
|
159
169
|
/** The number of unresolved completion dependencies. */
|
|
160
170
|
unresolvedCompleteDependencies: number;
|
|
161
|
-
/** Whether to skip the task if any of its dependencies fail (dependency finalized with a status not in `
|
|
171
|
+
/** Whether to skip the task if any of its dependencies fail (dependency finalized with a status not in `parentRequires`). */
|
|
162
172
|
abortOnDependencyFailure: boolean;
|
|
163
173
|
/** The data associated with the task. */
|
|
164
174
|
data: TaskData<Definitions, Type>;
|
|
@@ -242,7 +252,7 @@ export type EnqueueOptions = {
|
|
|
242
252
|
/** Whether to skip the task if any of its dependencies fail. */
|
|
243
253
|
abortOnDependencyFailure?: boolean;
|
|
244
254
|
/** The statuses the parent task should wait for this task to reach. */
|
|
245
|
-
|
|
255
|
+
parentRequires?: boolean | TerminalTaskStatus[];
|
|
246
256
|
/** The timestamp when the task should be processed. */
|
|
247
257
|
scheduleTimestamp?: number;
|
|
248
258
|
/** The duration (ms) before the task is considered expired. */
|
package/task-queue/task-queue.js
CHANGED
|
@@ -157,6 +157,11 @@ export const TaskStatus = defineEnum('TaskStatus', {
|
|
|
157
157
|
*/
|
|
158
158
|
Orphaned: 'orphaned',
|
|
159
159
|
});
|
|
160
|
+
export const terminalStatuses = [TaskStatus.Completed, TaskStatus.Cancelled, TaskStatus.Dead, TaskStatus.TimedOut, TaskStatus.Expired, TaskStatus.Skipped, TaskStatus.Orphaned];
|
|
161
|
+
export const nonTerminalStatuses = [TaskStatus.Pending, TaskStatus.Retrying, TaskStatus.Waiting, TaskStatus.WaitingChildren, TaskStatus.Running];
|
|
162
|
+
export const queueableStatuses = [TaskStatus.Pending, TaskStatus.Retrying];
|
|
163
|
+
export const waitableStatuses = [TaskStatus.Waiting, TaskStatus.WaitingChildren];
|
|
164
|
+
export const queueableOrWaitableStatuses = [...queueableStatuses, ...waitableStatuses];
|
|
160
165
|
/**
|
|
161
166
|
* Represents the type of dependency between tasks.
|
|
162
167
|
*/
|
|
@@ -305,9 +305,9 @@ describe('Task Queue Branch Coverage Enhancement', () => {
|
|
|
305
305
|
const updated = await pruneQueue.getTask(task.id);
|
|
306
306
|
expect(updated?.status).toBe(TaskStatus.TimedOut);
|
|
307
307
|
});
|
|
308
|
-
it('should handle enqueueMany with parentId and
|
|
308
|
+
it('should handle enqueueMany with parentId and parentRequires false', async () => {
|
|
309
309
|
const parent = await queue.enqueue('p', {});
|
|
310
|
-
const tasks = await queue.enqueueMany([{ type: 'child', data: {}, parentId: parent.id,
|
|
310
|
+
const tasks = await queue.enqueueMany([{ type: 'child', data: {}, parentId: parent.id, parentRequires: false }], { returnTasks: true });
|
|
311
311
|
expect(tasks).toHaveLength(1);
|
|
312
312
|
expect(tasks[0]?.parentId).toBe(parent.id);
|
|
313
313
|
const updatedParent = await queue.getTask(parent.id);
|
|
@@ -45,11 +45,11 @@ describe('Fan-Out Spawning', () => {
|
|
|
45
45
|
const fParent = await queue.getTask(parent.id);
|
|
46
46
|
expect(fParent?.status).toBe(TaskStatus.Completed);
|
|
47
47
|
});
|
|
48
|
-
it('should NOT transition parent to Waiting if
|
|
48
|
+
it('should NOT transition parent to Waiting if parentRequires is false', async () => {
|
|
49
49
|
const parent = await queue.enqueue('parent', {});
|
|
50
50
|
const dParent = await queue.dequeue();
|
|
51
|
-
// Spawn child with
|
|
52
|
-
await queue.enqueueMany([{ type: 'child', data: {}, parentId: parent.id,
|
|
51
|
+
// Spawn child with parentRequires: false
|
|
52
|
+
await queue.enqueueMany([{ type: 'child', data: {}, parentId: parent.id, parentRequires: false }], { returnTasks: true });
|
|
53
53
|
await queue.complete(dParent);
|
|
54
54
|
const uParent = await queue.getTask(parent.id);
|
|
55
55
|
expect(uParent?.status).toBe(TaskStatus.Completed); // Finished immediately
|
|
@@ -195,7 +195,7 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
|
|
|
195
195
|
const queueB = queueProvider.get(nameB);
|
|
196
196
|
const parent = await queueA.enqueue('test', { value: 'parent' });
|
|
197
197
|
expect(parent.unresolvedCompleteDependencies).toBe(0);
|
|
198
|
-
await queueB.enqueue('test', { value: 'child' }, { parentId: parent.id,
|
|
198
|
+
await queueB.enqueue('test', { value: 'child' }, { parentId: parent.id, parentRequires: true });
|
|
199
199
|
const updatedParent = await queueA.getTask(parent.id);
|
|
200
200
|
expect(updatedParent?.unresolvedCompleteDependencies).toBe(1);
|
|
201
201
|
await database.update(taskTable).set({ parentId: null }).where(or(eq(taskTable.namespace, nameA), eq(taskTable.namespace, nameB)));
|
|
@@ -254,7 +254,7 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
|
|
|
254
254
|
await q.enqueue('test', {}, {
|
|
255
255
|
idempotencyKey,
|
|
256
256
|
parentId: parent.id,
|
|
257
|
-
|
|
257
|
+
parentRequires: true,
|
|
258
258
|
});
|
|
259
259
|
// Dequeue and complete parent
|
|
260
260
|
const dequeuedParent = await q.dequeue();
|
|
@@ -263,10 +263,10 @@ describe('PostgresQueue (Distributed Task Orchestration)', () => {
|
|
|
263
263
|
expect(updatedParent?.status).toBe(TaskStatus.Completed);
|
|
264
264
|
await q.clear();
|
|
265
265
|
});
|
|
266
|
-
it('should increment unresolvedCompleteDependencies for children with
|
|
266
|
+
it('should increment unresolvedCompleteDependencies for children with parentRequires: true (Bug 6-2)', async () => {
|
|
267
267
|
const parent = await queue.enqueue('parent', {});
|
|
268
268
|
expect(parent.unresolvedCompleteDependencies).toBe(0);
|
|
269
|
-
await queue.enqueue('child', {}, { parentId: parent.id,
|
|
269
|
+
await queue.enqueue('child', {}, { parentId: parent.id, parentRequires: true });
|
|
270
270
|
const updatedParent = await queue.getTask(parent.id);
|
|
271
271
|
expect(updatedParent?.unresolvedCompleteDependencies).toBe(1);
|
|
272
272
|
});
|