@webpieces/cloudtasks-client 0.4.893 → 0.4.895
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/README.md +74 -13
- package/package.json +10 -5
- package/src/ClientCloudTasksFactory.d.ts +1 -1
- package/src/ClientCloudTasksFactory.js +1 -1
- package/src/ClientCloudTasksFactory.js.map +1 -1
- package/src/CloudTaskScheduler.d.ts +37 -13
- package/src/CloudTaskScheduler.js +38 -14
- package/src/CloudTaskScheduler.js.map +1 -1
- package/src/CloudTasksClientProvider.d.ts +29 -0
- package/src/CloudTasksClientProvider.js +41 -0
- package/src/CloudTasksClientProvider.js.map +1 -0
- package/src/GcpTaskInvoker.d.ts +27 -4
- package/src/GcpTaskInvoker.js +61 -19
- package/src/GcpTaskInvoker.js.map +1 -1
- package/src/GcpTaskRegistry.d.ts +31 -0
- package/src/GcpTaskRegistry.js +79 -0
- package/src/GcpTaskRegistry.js.map +1 -0
- package/src/GrpcErrorMapper.d.ts +27 -0
- package/src/GrpcErrorMapper.js +52 -0
- package/src/GrpcErrorMapper.js.map +1 -0
- package/src/InMemoryTaskInvoker.d.ts +9 -1
- package/src/InMemoryTaskInvoker.js +16 -3
- package/src/InMemoryTaskInvoker.js.map +1 -1
- package/src/TaskAlreadyExistsError.d.ts +18 -0
- package/src/TaskAlreadyExistsError.js +26 -0
- package/src/TaskAlreadyExistsError.js.map +1 -0
- package/src/TaskName.d.ts +33 -0
- package/src/TaskName.js +71 -0
- package/src/TaskName.js.map +1 -0
- package/src/TaskProxyClient.d.ts +32 -1
- package/src/TaskProxyClient.js +77 -27
- package/src/TaskProxyClient.js.map +1 -1
- package/src/TaskTypes.d.ts +110 -23
- package/src/TaskTypes.js +72 -22
- package/src/TaskTypes.js.map +1 -1
- package/src/__tests__/FakeCloudTasksServer.d.ts +51 -0
- package/src/__tests__/FakeCloudTasksServer.js +121 -0
- package/src/__tests__/FakeCloudTasksServer.js.map +1 -0
- package/src/index.d.ts +7 -1
- package/src/index.js +13 -1
- package/src/index.js.map +1 -1
package/src/TaskName.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TaskName = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
/** How a {@link TaskName} picks the Cloud Task id. */
|
|
6
|
+
var TaskNameMode;
|
|
7
|
+
(function (TaskNameMode) {
|
|
8
|
+
TaskNameMode["DEDUP"] = "dedup";
|
|
9
|
+
TaskNameMode["GENERATED"] = "generated";
|
|
10
|
+
TaskNameMode["SERVER_ASSIGNED"] = "server-assigned";
|
|
11
|
+
})(TaskNameMode || (TaskNameMode = {}));
|
|
12
|
+
/**
|
|
13
|
+
* How an enqueue names its Cloud Task — the id after `.../queues/<queue>/tasks/`.
|
|
14
|
+
*
|
|
15
|
+
* The name is resolved ONCE per logical enqueue (in `TaskProxyClient.enqueue`, before any attempt),
|
|
16
|
+
* so every retry of that enqueue carries the SAME name. That is what makes the enqueue retry safe: an
|
|
17
|
+
* attempt whose response was lost but whose task landed makes the retry answer ALREADY_EXISTS, which
|
|
18
|
+
* is reported as success (`JobReference.alreadyExisted`), not as a second task.
|
|
19
|
+
*
|
|
20
|
+
* - {@link TaskName.generated} — the DEFAULT. One fresh UUID per logical enqueue, reused on every retry.
|
|
21
|
+
* - {@link TaskName.dedup} — the CALLER's key: true dedup across callers and processes (a Twilio
|
|
22
|
+
* MessageSid, `org + period`, ...). A used name stays blocked for about an hour after its task runs
|
|
23
|
+
* or is deleted.
|
|
24
|
+
* - {@link TaskName.serverAssigned} — no name: the fastest `createTask` (Google documents that named
|
|
25
|
+
* tasks add enqueue latency), at the price that a retry after a lost response may create a duplicate.
|
|
26
|
+
*/
|
|
27
|
+
class TaskName {
|
|
28
|
+
mode;
|
|
29
|
+
id;
|
|
30
|
+
/** Cloud Tasks task ids: letters, digits, hyphens and underscores, at most 500 characters. */
|
|
31
|
+
static VALID_ID = /^[A-Za-z0-9_-]{1,500}$/;
|
|
32
|
+
constructor(mode, id) {
|
|
33
|
+
this.mode = mode;
|
|
34
|
+
this.id = id;
|
|
35
|
+
}
|
|
36
|
+
/** The caller's own dedup key, used verbatim as the task id. */
|
|
37
|
+
// webpieces-disable no-function-outside-class -- named constructor of this value type
|
|
38
|
+
static dedup(name) {
|
|
39
|
+
if (!TaskName.VALID_ID.test(name)) {
|
|
40
|
+
throw new RangeError(`TaskName.dedup('${name}') is not a valid Cloud Tasks task id: use only letters, ` +
|
|
41
|
+
'digits, hyphens and underscores, at most 500 characters.');
|
|
42
|
+
}
|
|
43
|
+
return new TaskName(TaskNameMode.DEDUP, name);
|
|
44
|
+
}
|
|
45
|
+
/** The default: a fresh UUID for this logical enqueue, shared by every retry of it. */
|
|
46
|
+
// webpieces-disable no-function-outside-class -- named constructor of this value type
|
|
47
|
+
static generated() {
|
|
48
|
+
return new TaskName(TaskNameMode.GENERATED);
|
|
49
|
+
}
|
|
50
|
+
/** No name: Cloud Tasks assigns one. Fastest; a retry after a lost response may duplicate. */
|
|
51
|
+
// webpieces-disable no-function-outside-class -- named constructor of this value type
|
|
52
|
+
static serverAssigned() {
|
|
53
|
+
return new TaskName(TaskNameMode.SERVER_ASSIGNED);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The task id to send, or `undefined` for a server-assigned name. Call ONCE per logical
|
|
57
|
+
* enqueue: a generated name is a new UUID on every call.
|
|
58
|
+
*/
|
|
59
|
+
resolve() {
|
|
60
|
+
switch (this.mode) {
|
|
61
|
+
case TaskNameMode.DEDUP:
|
|
62
|
+
return this.id;
|
|
63
|
+
case TaskNameMode.GENERATED:
|
|
64
|
+
return (0, crypto_1.randomUUID)();
|
|
65
|
+
case TaskNameMode.SERVER_ASSIGNED:
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
exports.TaskName = TaskName;
|
|
71
|
+
//# sourceMappingURL=TaskName.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TaskName.js","sourceRoot":"","sources":["../../../../../packages/cloud/cloudtasks-client/src/TaskName.ts"],"names":[],"mappings":";;;AAAA,mCAAoC;AAEpC,sDAAsD;AACtD,IAAK,YAIJ;AAJD,WAAK,YAAY;IACb,+BAAe,CAAA;IACf,uCAAuB,CAAA;IACvB,mDAAmC,CAAA;AACvC,CAAC,EAJI,YAAY,KAAZ,YAAY,QAIhB;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAa,QAAQ;IAKI;IACA;IALrB,8FAA8F;IACtF,MAAM,CAAU,QAAQ,GAAG,wBAAwB,CAAC;IAE5D,YACqB,IAAkB,EAClB,EAAW;QADX,SAAI,GAAJ,IAAI,CAAc;QAClB,OAAE,GAAF,EAAE,CAAS;IAC7B,CAAC;IAEJ,gEAAgE;IAChE,sFAAsF;IACtF,MAAM,CAAC,KAAK,CAAC,IAAY;QACrB,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;YAChC,MAAM,IAAI,UAAU,CAChB,mBAAmB,IAAI,2DAA2D;gBAC9E,0DAA0D,CACjE,CAAC;QACN,CAAC;QACD,OAAO,IAAI,QAAQ,CAAC,YAAY,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;IAClD,CAAC;IAED,uFAAuF;IACvF,sFAAsF;IACtF,MAAM,CAAC,SAAS;QACZ,OAAO,IAAI,QAAQ,CAAC,YAAY,CAAC,SAAS,CAAC,CAAC;IAChD,CAAC;IAED,8FAA8F;IAC9F,sFAAsF;IACtF,MAAM,CAAC,cAAc;QACjB,OAAO,IAAI,QAAQ,CAAC,YAAY,CAAC,eAAe,CAAC,CAAC;IACtD,CAAC;IAED;;;OAGG;IACH,OAAO;QACH,QAAQ,IAAI,CAAC,IAAI,EAAE,CAAC;YAChB,KAAK,YAAY,CAAC,KAAK;gBACnB,OAAO,IAAI,CAAC,EAAE,CAAC;YACnB,KAAK,YAAY,CAAC,SAAS;gBACvB,OAAO,IAAA,mBAAU,GAAE,CAAC;YACxB,KAAK,YAAY,CAAC,eAAe;gBAC7B,OAAO,SAAS,CAAC;QACzB,CAAC;IACL,CAAC;;AA9CL,4BA+CC","sourcesContent":["import { randomUUID } from 'crypto';\n\n/** How a {@link TaskName} picks the Cloud Task id. */\nenum TaskNameMode {\n DEDUP = 'dedup',\n GENERATED = 'generated',\n SERVER_ASSIGNED = 'server-assigned',\n}\n\n/**\n * How an enqueue names its Cloud Task — the id after `.../queues/<queue>/tasks/`.\n *\n * The name is resolved ONCE per logical enqueue (in `TaskProxyClient.enqueue`, before any attempt),\n * so every retry of that enqueue carries the SAME name. That is what makes the enqueue retry safe: an\n * attempt whose response was lost but whose task landed makes the retry answer ALREADY_EXISTS, which\n * is reported as success (`JobReference.alreadyExisted`), not as a second task.\n *\n * - {@link TaskName.generated} — the DEFAULT. One fresh UUID per logical enqueue, reused on every retry.\n * - {@link TaskName.dedup} — the CALLER's key: true dedup across callers and processes (a Twilio\n * MessageSid, `org + period`, ...). A used name stays blocked for about an hour after its task runs\n * or is deleted.\n * - {@link TaskName.serverAssigned} — no name: the fastest `createTask` (Google documents that named\n * tasks add enqueue latency), at the price that a retry after a lost response may create a duplicate.\n */\nexport class TaskName {\n /** Cloud Tasks task ids: letters, digits, hyphens and underscores, at most 500 characters. */\n private static readonly VALID_ID = /^[A-Za-z0-9_-]{1,500}$/;\n\n private constructor(\n private readonly mode: TaskNameMode,\n private readonly id?: string,\n ) {}\n\n /** The caller's own dedup key, used verbatim as the task id. */\n // webpieces-disable no-function-outside-class -- named constructor of this value type\n static dedup(name: string): TaskName {\n if (!TaskName.VALID_ID.test(name)) {\n throw new RangeError(\n `TaskName.dedup('${name}') is not a valid Cloud Tasks task id: use only letters, ` +\n 'digits, hyphens and underscores, at most 500 characters.',\n );\n }\n return new TaskName(TaskNameMode.DEDUP, name);\n }\n\n /** The default: a fresh UUID for this logical enqueue, shared by every retry of it. */\n // webpieces-disable no-function-outside-class -- named constructor of this value type\n static generated(): TaskName {\n return new TaskName(TaskNameMode.GENERATED);\n }\n\n /** No name: Cloud Tasks assigns one. Fastest; a retry after a lost response may duplicate. */\n // webpieces-disable no-function-outside-class -- named constructor of this value type\n static serverAssigned(): TaskName {\n return new TaskName(TaskNameMode.SERVER_ASSIGNED);\n }\n\n /**\n * The task id to send, or `undefined` for a server-assigned name. Call ONCE per logical\n * enqueue: a generated name is a new UUID on every call.\n */\n resolve(): string | undefined {\n switch (this.mode) {\n case TaskNameMode.DEDUP:\n return this.id;\n case TaskNameMode.GENERATED:\n return randomUUID();\n case TaskNameMode.SERVER_ASSIGNED:\n return undefined;\n }\n }\n}\n"]}
|
package/src/TaskProxyClient.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TransportDefault } from '@webpieces/core-util';
|
|
1
2
|
import { RequestContextHeaders } from '@webpieces/core-context';
|
|
2
3
|
import { TaskInvoker } from './TaskTypes';
|
|
3
4
|
import { ApiPrototype, TaskClientConfig } from './TaskClientConfig';
|
|
@@ -23,6 +24,15 @@ import { ApiPrototype, TaskClientConfig } from './TaskClientConfig';
|
|
|
23
24
|
export declare class TaskProxyClient {
|
|
24
25
|
private readonly invoker;
|
|
25
26
|
private readonly headers;
|
|
27
|
+
/**
|
|
28
|
+
* The enqueue transport's default for `CallRegistry.execute` (Path A): 30 s if a caller only
|
|
29
|
+
* registers a strategy-free timeout ladder, otherwise webpieces' shared `RetryStrategy` — 5 s per
|
|
30
|
+
* attempt, 3 retries, backoff 500 ms x2 capped at 4 s. RPC passes `new TransportDefault(30_000)`
|
|
31
|
+
* (no retry); this is the ONLY place the two transports differ.
|
|
32
|
+
*/
|
|
33
|
+
static readonly ENQUEUE_TRANSPORT_DEFAULT: TransportDefault;
|
|
34
|
+
/** Path B's safety cap when no timeout is registered: caller-owned gax timing still ends. */
|
|
35
|
+
static readonly ENQUEUE_SAFETY_CAP_MS = 30000;
|
|
26
36
|
private plans;
|
|
27
37
|
private apiName;
|
|
28
38
|
private apiClass;
|
|
@@ -43,9 +53,30 @@ export declare class TaskProxyClient {
|
|
|
43
53
|
/**
|
|
44
54
|
* Enqueue one task for the named endpoint. Must run inside a CloudTaskScheduler lambda
|
|
45
55
|
* (which supplies the ScheduleInfo) within an active RequestContext, e.g.:
|
|
46
|
-
* scheduler.addToQueue(() => taskClient.foo(req),
|
|
56
|
+
* scheduler.addToQueue(() => taskClient.foo(req), new ScheduleOptions(TaskName.dedup(id)));
|
|
57
|
+
*
|
|
58
|
+
* Before any attempt it resolves, ONCE: the task name (so every attempt carries the same one),
|
|
59
|
+
* and the effective customizer and gax options (call site, else `GcpTaskRegistry` method -> API
|
|
60
|
+
* -> ALL). Then exactly ONE retry layer runs:
|
|
61
|
+
*
|
|
62
|
+
* - **Path A, no gax — webpieces owns timing.** `CallRegistry.execute` with
|
|
63
|
+
* {@link TaskProxyClient.ENQUEUE_TRANSPORT_DEFAULT}: a registered strategy (method -> API -> ALL), else a
|
|
64
|
+
* registered timeout, else `RetryStrategy(5_000, 3, 500, 2, 4_000)`. Each attempt sends
|
|
65
|
+
* `createTask(request, { timeout: attemptMs })`.
|
|
66
|
+
* - **Path B, gax set — you own timing.** No strategy lookup and ONE attempt, sending
|
|
67
|
+
* `createTask(request, yourGax)` under a safety cap from the timeout ladder (method -> API ->
|
|
68
|
+
* ALL -> 30 s) so a hung channel cannot block forever.
|
|
69
|
+
*
|
|
70
|
+
* Either way the endpoint reports `WRITE_IDEMPOTENT`: one name for every attempt makes a repeat
|
|
71
|
+
* safe (delivery is at-least-once anyway, and `TaskName.serverAssigned()` accepts duplicates).
|
|
72
|
+
* A `TaskAlreadyExistsError` is caught HERE, above the retry layer: `WARN [ENQUEUE-DEDUP]`, then
|
|
73
|
+
* success with `JobReference.alreadyExisted = true`.
|
|
47
74
|
*/
|
|
48
75
|
enqueue(methodName: string, requestDto: unknown): Promise<void>;
|
|
76
|
+
/** ALREADY_EXISTS is the task the caller asked for, already there: success, logged once. */
|
|
77
|
+
private dedupAware;
|
|
78
|
+
/** ONE enqueue attempt: a fresh TaskRequest, handed to the bound invoker. */
|
|
79
|
+
private attempt;
|
|
49
80
|
/** Endpoint name -> its resolved path / queue / auth mode, read once from the decorators. */
|
|
50
81
|
private buildPlans;
|
|
51
82
|
/**
|
package/src/TaskProxyClient.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var TaskProxyClient_1;
|
|
2
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
4
|
exports.TASK_PROXY_CLIENT_PROVIDER = exports.TaskProxyClient = void 0;
|
|
4
5
|
const tslib_1 = require("tslib");
|
|
@@ -6,6 +7,8 @@ const inversify_1 = require("inversify");
|
|
|
6
7
|
const core_util_1 = require("@webpieces/core-util");
|
|
7
8
|
const core_context_1 = require("@webpieces/core-context");
|
|
8
9
|
const TaskTypes_1 = require("./TaskTypes");
|
|
10
|
+
const TaskAlreadyExistsError_1 = require("./TaskAlreadyExistsError");
|
|
11
|
+
const GcpTaskRegistry_1 = require("./GcpTaskRegistry");
|
|
9
12
|
const ScheduleContext_1 = require("./ScheduleContext");
|
|
10
13
|
const log = core_util_1.LogManager.getLogger('TaskProxyClient');
|
|
11
14
|
/** Per-endpoint routing plan resolved once from the contract's decorators. */
|
|
@@ -16,13 +19,10 @@ class EndpointPlan {
|
|
|
16
19
|
/** @MaskLog spec for this endpoint's request DTO, read once here so the enqueue log path masks
|
|
17
20
|
* a secret on the task payload without a per-enqueue reflection hit. Undefined = log verbatim. */
|
|
18
21
|
mask;
|
|
19
|
-
|
|
20
|
-
operation;
|
|
21
|
-
constructor(path, queueName, authMode, operation, mask) {
|
|
22
|
+
constructor(path, queueName, authMode, mask) {
|
|
22
23
|
this.path = path;
|
|
23
24
|
this.queueName = queueName;
|
|
24
25
|
this.authMode = authMode;
|
|
25
|
-
this.operation = operation;
|
|
26
26
|
this.mask = mask;
|
|
27
27
|
}
|
|
28
28
|
}
|
|
@@ -46,8 +46,18 @@ class EndpointPlan {
|
|
|
46
46
|
* - Handing a fully-built TaskRequest to the bound {@link TaskInvoker}
|
|
47
47
|
*/
|
|
48
48
|
let TaskProxyClient = class TaskProxyClient {
|
|
49
|
+
static { TaskProxyClient_1 = this; }
|
|
49
50
|
invoker;
|
|
50
51
|
headers;
|
|
52
|
+
/**
|
|
53
|
+
* The enqueue transport's default for `CallRegistry.execute` (Path A): 30 s if a caller only
|
|
54
|
+
* registers a strategy-free timeout ladder, otherwise webpieces' shared `RetryStrategy` — 5 s per
|
|
55
|
+
* attempt, 3 retries, backoff 500 ms x2 capped at 4 s. RPC passes `new TransportDefault(30_000)`
|
|
56
|
+
* (no retry); this is the ONLY place the two transports differ.
|
|
57
|
+
*/
|
|
58
|
+
static ENQUEUE_TRANSPORT_DEFAULT = new core_util_1.TransportDefault(30_000, new core_util_1.RetryStrategy(5_000, 3, 500, 2, 4_000).run);
|
|
59
|
+
/** Path B's safety cap when no timeout is registered: caller-owned gax timing still ends. */
|
|
60
|
+
static ENQUEUE_SAFETY_CAP_MS = 30_000;
|
|
51
61
|
// Assigned by init(), which the factory calls immediately after construction.
|
|
52
62
|
plans;
|
|
53
63
|
apiName;
|
|
@@ -85,7 +95,24 @@ let TaskProxyClient = class TaskProxyClient {
|
|
|
85
95
|
/**
|
|
86
96
|
* Enqueue one task for the named endpoint. Must run inside a CloudTaskScheduler lambda
|
|
87
97
|
* (which supplies the ScheduleInfo) within an active RequestContext, e.g.:
|
|
88
|
-
* scheduler.addToQueue(() => taskClient.foo(req),
|
|
98
|
+
* scheduler.addToQueue(() => taskClient.foo(req), new ScheduleOptions(TaskName.dedup(id)));
|
|
99
|
+
*
|
|
100
|
+
* Before any attempt it resolves, ONCE: the task name (so every attempt carries the same one),
|
|
101
|
+
* and the effective customizer and gax options (call site, else `GcpTaskRegistry` method -> API
|
|
102
|
+
* -> ALL). Then exactly ONE retry layer runs:
|
|
103
|
+
*
|
|
104
|
+
* - **Path A, no gax — webpieces owns timing.** `CallRegistry.execute` with
|
|
105
|
+
* {@link TaskProxyClient.ENQUEUE_TRANSPORT_DEFAULT}: a registered strategy (method -> API -> ALL), else a
|
|
106
|
+
* registered timeout, else `RetryStrategy(5_000, 3, 500, 2, 4_000)`. Each attempt sends
|
|
107
|
+
* `createTask(request, { timeout: attemptMs })`.
|
|
108
|
+
* - **Path B, gax set — you own timing.** No strategy lookup and ONE attempt, sending
|
|
109
|
+
* `createTask(request, yourGax)` under a safety cap from the timeout ladder (method -> API ->
|
|
110
|
+
* ALL -> 30 s) so a hung channel cannot block forever.
|
|
111
|
+
*
|
|
112
|
+
* Either way the endpoint reports `WRITE_IDEMPOTENT`: one name for every attempt makes a repeat
|
|
113
|
+
* safe (delivery is at-least-once anyway, and `TaskName.serverAssigned()` accepts duplicates).
|
|
114
|
+
* A `TaskAlreadyExistsError` is caught HERE, above the retry layer: `WARN [ENQUEUE-DEDUP]`, then
|
|
115
|
+
* success with `JobReference.alreadyExisted = true`.
|
|
89
116
|
*/
|
|
90
117
|
// webpieces-disable no-any-unknown -- the request DTO's type is erased at the proxy boundary
|
|
91
118
|
async enqueue(methodName, requestDto) {
|
|
@@ -96,35 +123,58 @@ let TaskProxyClient = class TaskProxyClient {
|
|
|
96
123
|
const frame = (0, ScheduleContext_1.currentScheduleFrame)();
|
|
97
124
|
if (!frame) {
|
|
98
125
|
throw new Error('Cloud task enqueue must run inside a CloudTaskScheduler lambda, e.g. ' +
|
|
99
|
-
'scheduler.addToQueue(() => taskClient.method(req),
|
|
126
|
+
'scheduler.addToQueue(() => taskClient.method(req), new ScheduleOptions(TaskName.dedup(id))).');
|
|
100
127
|
}
|
|
128
|
+
const enqueuePlan = new TaskTypes_1.EnqueuePlan(frame.info.taskName.resolve(), frame.info.customizeTask ?? GcpTaskRegistry_1.GcpTaskRegistry.customizerFor(this.apiClass, methodName), frame.info.gax ?? GcpTaskRegistry_1.GcpTaskRegistry.gaxFor(this.apiClass, methodName));
|
|
101
129
|
// Route through LogApiCall exactly like ProxyClient does for http. TaskProxyClient.enqueue is
|
|
102
130
|
// the ONE seam above BOTH GcpTaskInvoker (prod) and InMemoryTaskInvoker (localhost) — whichever
|
|
103
131
|
// TaskInvoker is bound, this method sits above it — so the enqueue edge gets structured
|
|
104
|
-
// `jsonPayload.api.*` logging
|
|
105
|
-
// uniformly, without touching either invoker impl.
|
|
132
|
+
// `jsonPayload.api.*` logging uniformly, without touching either invoker impl.
|
|
106
133
|
const info = new core_util_1.ApiMethodInfo('client', this.apiName, methodName, undefined, plan.mask);
|
|
107
134
|
await this.logApiCall.execute(info, requestDto, async () => {
|
|
108
|
-
const callContext = new core_util_1.CallContext(this.apiName, methodName,
|
|
109
|
-
const
|
|
110
|
-
// Resolved lazily (not at client construction) so building a client stays synchronous.
|
|
111
|
-
// Every metadata read beneath resolveUrl is memoized process-wide, so only the first
|
|
112
|
-
// enqueue in the process pays a lookup.
|
|
113
|
-
const targetUrl = await this.config.resolveUrl();
|
|
114
|
-
core_util_1.CallDeadline.throwIfAborted(signal);
|
|
115
|
-
const request = new TaskTypes_1.TaskRequest(targetUrl, plan.path, plan.queueName, requestDto, this.buildContextHeaders(plan.authMode), plan.authMode, frame.info ?? new TaskTypes_1.ScheduleInfo());
|
|
116
|
-
// svcName, not the URL, is the stable name across demo/qa/prod.
|
|
117
|
-
log.debug(`enqueue task ${plan.queueName} -> ${this.config.svcName}${plan.path}`);
|
|
118
|
-
return this.invoker.enqueue(request);
|
|
119
|
-
}), 30_000);
|
|
135
|
+
const callContext = new core_util_1.CallContext(this.apiName, methodName, core_util_1.WRITE_IDEMPOTENT);
|
|
136
|
+
const attempt = (timeoutMs) => core_util_1.CallDeadline.run(timeoutMs, callContext, (signal) => this.attempt(plan, enqueuePlan, frame.info, requestDto, timeoutMs, signal));
|
|
120
137
|
// Only the winning attempt may publish the reference. A late enqueue cannot overwrite it.
|
|
121
|
-
frame.jobRef =
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
//
|
|
125
|
-
|
|
138
|
+
frame.jobRef = await this.dedupAware(methodName, () => enqueuePlan.gax === undefined
|
|
139
|
+
? core_util_1.CallRegistry.execute(this.apiClass, callContext, attempt, TaskProxyClient_1.ENQUEUE_TRANSPORT_DEFAULT)
|
|
140
|
+
: attempt(core_util_1.CallRegistry.resolveTimeout(this.apiClass, methodName, TaskProxyClient_1.ENQUEUE_SAFETY_CAP_MS)));
|
|
141
|
+
// Fire-and-forget: the enqueue ack is not a meaningful response, but it is non-null so
|
|
142
|
+
// LogApiCall's null-response guard passes; a recorded test asserts the enqueued REQUEST.
|
|
143
|
+
return frame.jobRef;
|
|
126
144
|
});
|
|
127
145
|
}
|
|
146
|
+
/** ALREADY_EXISTS is the task the caller asked for, already there: success, logged once. */
|
|
147
|
+
async dedupAware(methodName, enqueue) {
|
|
148
|
+
// webpieces-disable no-unmanaged-exceptions -- ALREADY_EXISTS is success; everything else propagates unchanged
|
|
149
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
150
|
+
try {
|
|
151
|
+
return await enqueue();
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
const error = (0, core_util_1.toError)(err);
|
|
155
|
+
if (!(error instanceof TaskAlreadyExistsError_1.TaskAlreadyExistsError)) {
|
|
156
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- rethrows the enqueue's own failure unchanged
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
log.warn(`[ENQUEUE-DEDUP] ${this.apiName}.${methodName} task ${error.taskName} already exists ` +
|
|
160
|
+
'(a retry whose earlier attempt landed, or a dedup name reused) — treated as success');
|
|
161
|
+
return new TaskTypes_1.JobReference(error.taskName, true);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** ONE enqueue attempt: a fresh TaskRequest, handed to the bound invoker. */
|
|
165
|
+
async attempt(plan, enqueuePlan, scheduleInfo,
|
|
166
|
+
// webpieces-disable no-any-unknown -- the request DTO's type is erased at the proxy boundary
|
|
167
|
+
requestDto, timeoutMs, signal) {
|
|
168
|
+
// Resolved lazily (not at client construction) so building a client stays synchronous.
|
|
169
|
+
// Every metadata read beneath resolveUrl is memoized process-wide, so only the first
|
|
170
|
+
// enqueue in the process pays a lookup.
|
|
171
|
+
const targetUrl = await this.config.resolveUrl();
|
|
172
|
+
core_util_1.CallDeadline.throwIfAborted(signal);
|
|
173
|
+
const request = new TaskTypes_1.TaskRequest(targetUrl, plan.path, plan.queueName, requestDto, this.buildContextHeaders(plan.authMode), plan.authMode, scheduleInfo, enqueuePlan);
|
|
174
|
+
// svcName, not the URL, is the stable name across demo/qa/prod.
|
|
175
|
+
log.debug(`enqueue task ${plan.queueName} -> ${this.config.svcName}${plan.path}`);
|
|
176
|
+
return this.invoker.enqueue(request, timeoutMs, signal);
|
|
177
|
+
}
|
|
128
178
|
/** Endpoint name -> its resolved path / queue / auth mode, read once from the decorators. */
|
|
129
179
|
buildPlans(apiClass) {
|
|
130
180
|
const basePath = (0, core_util_1.getApiPath)(apiClass) ?? '';
|
|
@@ -136,7 +186,7 @@ let TaskProxyClient = class TaskProxyClient {
|
|
|
136
186
|
if (!authMode) {
|
|
137
187
|
throw new Error(`Endpoint '${methodName}' on ${this.apiName} has no auth mode`);
|
|
138
188
|
}
|
|
139
|
-
const plan = new EndpointPlan(basePath + endpoints[methodName], (0, core_util_1.getQueueName)(apiClass, methodName), authMode, (0, core_util_1.
|
|
189
|
+
const plan = new EndpointPlan(basePath + endpoints[methodName], (0, core_util_1.getQueueName)(apiClass, methodName), authMode, (0, core_util_1.getMaskSpec)(apiClass, methodName));
|
|
140
190
|
plans.set(methodName, plan);
|
|
141
191
|
}
|
|
142
192
|
return plans;
|
|
@@ -162,7 +212,7 @@ let TaskProxyClient = class TaskProxyClient {
|
|
|
162
212
|
}
|
|
163
213
|
};
|
|
164
214
|
exports.TaskProxyClient = TaskProxyClient;
|
|
165
|
-
exports.TaskProxyClient = TaskProxyClient = tslib_1.__decorate([
|
|
215
|
+
exports.TaskProxyClient = TaskProxyClient = TaskProxyClient_1 = tslib_1.__decorate([
|
|
166
216
|
(0, core_context_1.provideFrameworkTransient)(),
|
|
167
217
|
tslib_1.__param(0, (0, inversify_1.inject)(TaskTypes_1.TaskInvoker)),
|
|
168
218
|
tslib_1.__param(1, (0, inversify_1.inject)(core_context_1.RequestContextHeaders)),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TaskProxyClient.js","sourceRoot":"","sources":["../../../../../packages/cloud/cloudtasks-client/src/TaskProxyClient.ts"],"names":[],"mappings":"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","sourcesContent":["import { inject } from 'inversify';\nimport {\n isApiPath,\n getApiPath,\n getEndpoints,\n getAuthMeta,\n getQueueName,\n getMaskSpec,\n assertPubSubConventions,\n assertEveryEndpointHasAuthMode,\n assertNotInternalApi,\n AuthMode,\n DestinationTrust,\n MaskSpec,\n LogManager,\n LogApiCallImpl,\n ApiMethodInfo,\n CallRegistry,\n CallDeadline,\n CallContext,\n getEndpointOperation,\n} from '@webpieces/core-util';\nimport type { EndpointOperation } from '@webpieces/core-util';\nimport {\n RequestContextApiCallContext,\n RequestContextHeaders,\n provideFrameworkTransient,\n} from '@webpieces/core-context';\nimport { TaskInvoker, TaskRequest, ScheduleInfo } from './TaskTypes';\nimport { currentScheduleFrame } from './ScheduleContext';\nimport { ApiPrototype, TaskClientConfig } from './TaskClientConfig';\n\nconst log = LogManager.getLogger('TaskProxyClient');\n\n/** Per-endpoint routing plan resolved once from the contract's decorators. */\nclass EndpointPlan {\n path: string;\n queueName: string;\n authMode: AuthMode;\n /** @MaskLog spec for this endpoint's request DTO, read once here so the enqueue log path masks\n * a secret on the task payload without a per-enqueue reflection hit. Undefined = log verbatim. */\n mask?: MaskSpec;\n /** The endpoint's declared @Endpoint operation; an enqueue deadline reports it on its timeout. */\n operation: EndpointOperation;\n\n constructor(\n path: string,\n queueName: string,\n authMode: AuthMode,\n operation: EndpointOperation,\n mask?: MaskSpec,\n ) {\n this.path = path;\n this.queueName = queueName;\n this.authMode = authMode;\n this.operation = operation;\n this.mask = mask;\n }\n}\n\n/**\n * TaskProxyClient - the enqueue engine behind one @PubSub API contract's client proxy.\n *\n * The fire-and-forget twin of http-client-core's ProxyClient, and TWO-PHASE for the same reason:\n * its COLLABORATORS (invoker, headers) come from the container, while the PER-CLIENT state (which\n * contract, which target) arrives on {@link init}. That is what lets {@link ClientCloudTasksFactory}\n * hold a `Provider<TaskProxyClient>` and hand out a fresh, independently-configured client per\n * contract.\n *\n * Calling an endpoint ENQUEUES a task (it does not call remotely); the task is later delivered to\n * the same endpoint's controller through the full server filter chain.\n *\n * It owns:\n * - @ApiPath / @PubSub convention validation + the endpoint plans from the contract's decorators\n * - Resolving the callee's base URL from svcName through `ClientRegistry.resolve` — the SAME chain the\n * RPC client runs: a registered mapping, else the installed deriver, else throw\n * - Context propagation onto the task headers (a credential is never a context key, so none can ride along)\n * - Handing a fully-built TaskRequest to the bound {@link TaskInvoker}\n */\n@provideFrameworkTransient()\nexport class TaskProxyClient {\n // Assigned by init(), which the factory calls immediately after construction.\n private plans!: Map<string, EndpointPlan>;\n private apiName!: string;\n private apiClass!: ApiPrototype<object>;\n private config!: TaskClientConfig;\n\n /**\n * The api-call log seam, built around this node-only package's OWN RequestContext-backed\n * ApiCallContext. Constructed here rather than installed at startup, so a plain NestJS/Express\n * host needs no webpieces STARTUP INSTALL to enqueue — it only has to run the call inside a\n * `RequestContext.run(...)` scope, which such a host already opens per request. That is the defect\n * that made every enqueue throw `ApiCallContext is not installed`.\n */\n private readonly logApiCall = new LogApiCallImpl(new RequestContextApiCallContext());\n\n constructor(\n @inject(TaskInvoker) private readonly invoker: TaskInvoker,\n @inject(RequestContextHeaders) private readonly headers: RequestContextHeaders,\n ) {}\n\n /** Bind this client to one @PubSub contract + target. */\n init(apiClass: ApiPrototype<object>, config: TaskClientConfig): void {\n assertNotInternalApi(apiClass, 'Cloud Tasks client');\n if (!isApiPath(apiClass)) {\n throw new Error(\n `Class ${apiClass.name || 'Unknown'} must be decorated with @ApiPath()`,\n );\n }\n assertPubSubConventions(apiClass);\n assertEveryEndpointHasAuthMode(apiClass);\n\n this.config = config;\n this.apiClass = apiClass;\n this.apiName = apiClass.name || 'UnknownApi';\n this.plans = this.buildPlans(apiClass);\n }\n\n /** Check whether the contract declares a @PubSub endpoint with this method name. */\n hasEndpoint(methodName: string): boolean {\n return this.plans.has(methodName);\n }\n\n /**\n * Enqueue one task for the named endpoint. Must run inside a CloudTaskScheduler lambda\n * (which supplies the ScheduleInfo) within an active RequestContext, e.g.:\n * scheduler.addToQueue(() => taskClient.foo(req), { dedupName });\n */\n // webpieces-disable no-any-unknown -- the request DTO's type is erased at the proxy boundary\n async enqueue(methodName: string, requestDto: unknown): Promise<void> {\n const plan = this.plans.get(methodName);\n if (!plan) {\n throw new Error(`No @PubSub endpoint '${methodName}' on ${this.apiName}`);\n }\n\n const frame = currentScheduleFrame();\n if (!frame) {\n throw new Error(\n 'Cloud task enqueue must run inside a CloudTaskScheduler lambda, e.g. ' +\n 'scheduler.addToQueue(() => taskClient.method(req), { dedupName }).',\n );\n }\n\n // Route through LogApiCall exactly like ProxyClient does for http. TaskProxyClient.enqueue is\n // the ONE seam above BOTH GcpTaskInvoker (prod) and InMemoryTaskInvoker (localhost) — whichever\n // TaskInvoker is bound, this method sits above it — so the enqueue edge gets structured\n // `jsonPayload.api.*` logging (and, once recording is unified at LogApiCall, is recorded)\n // uniformly, without touching either invoker impl.\n const info = new ApiMethodInfo('client', this.apiName, methodName, undefined, plan.mask);\n await this.logApiCall.execute(info, requestDto, async () => {\n const callContext = new CallContext(this.apiName, methodName, plan.operation);\n const jobRef = await CallRegistry.execute(\n this.apiClass,\n callContext,\n (timeoutMs: number) =>\n CallDeadline.run(timeoutMs, callContext, async (signal: AbortSignal) => {\n // Resolved lazily (not at client construction) so building a client stays synchronous.\n // Every metadata read beneath resolveUrl is memoized process-wide, so only the first\n // enqueue in the process pays a lookup.\n const targetUrl = await this.config.resolveUrl();\n CallDeadline.throwIfAborted(signal);\n\n const request = new TaskRequest(\n targetUrl,\n plan.path,\n plan.queueName,\n requestDto,\n this.buildContextHeaders(plan.authMode),\n plan.authMode,\n frame.info ?? new ScheduleInfo(),\n );\n\n // svcName, not the URL, is the stable name across demo/qa/prod.\n log.debug(\n `enqueue task ${plan.queueName} -> ${this.config.svcName}${plan.path}`,\n );\n return this.invoker.enqueue(request);\n }),\n 30_000,\n );\n // Only the winning attempt may publish the reference. A late enqueue cannot overwrite it.\n frame.jobRef = jobRef;\n\n // Fire-and-forget: the enqueue ack is not a meaningful response. Return a non-null value so\n // LogApiCall's null-response guard passes; a recorded test asserts the enqueued REQUEST\n // (payload), never this ack.\n return frame.jobRef ?? { enqueued: true };\n });\n }\n\n /** Endpoint name -> its resolved path / queue / auth mode, read once from the decorators. */\n private buildPlans(apiClass: ApiPrototype<object>): Map<string, EndpointPlan> {\n const basePath = getApiPath(apiClass) ?? '';\n const endpoints = getEndpoints(apiClass) ?? {};\n const plans = new Map<string, EndpointPlan>();\n\n for (const methodName of Object.keys(endpoints)) {\n const authMeta = getAuthMeta(apiClass, methodName);\n const authMode = authMeta ? this.invoker.selectAuthentication(authMeta) : undefined;\n if (!authMode) {\n throw new Error(`Endpoint '${methodName}' on ${this.apiName} has no auth mode`);\n }\n const plan = new EndpointPlan(\n basePath + endpoints[methodName],\n getQueueName(apiClass, methodName),\n authMode,\n getEndpointOperation(apiClass, methodName),\n getMaskSpec(apiClass, methodName),\n );\n plans.set(methodName, plan);\n }\n return plans;\n }\n\n /**\n * Every transferred context key (txId/requestId/tenant…) the DELIVERY endpoint may receive,\n * request-id chained. Throws if there is no active RequestContext — an enqueue with no trace is\n * a bug.\n *\n * No credential can appear here: `authorization` is read off the inbound HttpRequest and is not\n * a ContextKey, so it never enters the RequestContext to be transferred. The invoker mints the\n * task's own delivery auth per the endpoint's oidc(...) / sharedSecret(...) mode.\n *\n * TRUSTED keys DO flow on this path, and that is the point of deriving {@link DestinationTrust}\n * from `plan.authMode` rather than hardcoding it: a task is delivered under the OIDC token the\n * invoker mints, so the delivery endpoint authenticates its caller and its AuthFilter admits the\n * userId/orgId we vouched for. A @PubSub endpoint declared @WpAuthPublic or jwt() would not, and it\n * correctly stops receiving them — the enqueue keeps working, minus context the callee would\n * have 401'd on.\n */\n private buildContextHeaders(authMode: AuthMode): Map<string, string> {\n return this.headers.buildOutboundHeaders(DestinationTrust.forAuthMode(authMode));\n }\n}\n\n/**\n * DI token for the `Provider<TaskProxyClient>` that hands out enqueue clients — one per @PubSub\n * contract. `Provider<T>` is erased at runtime, so it cannot be its own token; this Symbol names T.\n *\n * Because TaskProxyClient is bound TRANSIENT, every `get()` constructs a new one. (Were it bound\n * `@provideFrameworkSingleton`, the very same Provider would hand back one lazily-created instance\n * instead — the provider caches nothing, so the target's scope decides.)\n */\n// webpieces-disable no-symbol-di-tokens -- Provider<T> is erased at runtime; the Symbol names T\nexport const TASK_PROXY_CLIENT_PROVIDER = Symbol.for('Provider<TaskProxyClient>');\n"]}
|
|
1
|
+
{"version":3,"file":"TaskProxyClient.js","sourceRoot":"","sources":["../../../../../packages/cloud/cloudtasks-client/src/TaskProxyClient.ts"],"names":[],"mappings":"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","sourcesContent":["import { inject } from 'inversify';\nimport {\n isApiPath,\n getApiPath,\n getEndpoints,\n getAuthMeta,\n getQueueName,\n getMaskSpec,\n assertPubSubConventions,\n assertEveryEndpointHasAuthMode,\n assertNotInternalApi,\n AuthMode,\n DestinationTrust,\n MaskSpec,\n LogManager,\n LogApiCallImpl,\n ApiMethodInfo,\n CallRegistry,\n CallDeadline,\n CallContext,\n RetryStrategy,\n TransportDefault,\n WRITE_IDEMPOTENT,\n toError,\n} from '@webpieces/core-util';\nimport {\n RequestContextApiCallContext,\n RequestContextHeaders,\n provideFrameworkTransient,\n} from '@webpieces/core-context';\nimport { TaskInvoker, TaskRequest, ScheduleInfo, EnqueuePlan, JobReference } from './TaskTypes';\nimport { TaskAlreadyExistsError } from './TaskAlreadyExistsError';\nimport { GcpTaskRegistry } from './GcpTaskRegistry';\nimport { currentScheduleFrame } from './ScheduleContext';\nimport { ApiPrototype, TaskClientConfig } from './TaskClientConfig';\n\nconst log = LogManager.getLogger('TaskProxyClient');\n\n/** Per-endpoint routing plan resolved once from the contract's decorators. */\nclass EndpointPlan {\n path: string;\n queueName: string;\n authMode: AuthMode;\n /** @MaskLog spec for this endpoint's request DTO, read once here so the enqueue log path masks\n * a secret on the task payload without a per-enqueue reflection hit. Undefined = log verbatim. */\n mask?: MaskSpec;\n\n constructor(path: string, queueName: string, authMode: AuthMode, mask?: MaskSpec) {\n this.path = path;\n this.queueName = queueName;\n this.authMode = authMode;\n this.mask = mask;\n }\n}\n\n/**\n * TaskProxyClient - the enqueue engine behind one @PubSub API contract's client proxy.\n *\n * The fire-and-forget twin of http-client-core's ProxyClient, and TWO-PHASE for the same reason:\n * its COLLABORATORS (invoker, headers) come from the container, while the PER-CLIENT state (which\n * contract, which target) arrives on {@link init}. That is what lets {@link ClientCloudTasksFactory}\n * hold a `Provider<TaskProxyClient>` and hand out a fresh, independently-configured client per\n * contract.\n *\n * Calling an endpoint ENQUEUES a task (it does not call remotely); the task is later delivered to\n * the same endpoint's controller through the full server filter chain.\n *\n * It owns:\n * - @ApiPath / @PubSub convention validation + the endpoint plans from the contract's decorators\n * - Resolving the callee's base URL from svcName through `ClientRegistry.resolve` — the SAME chain the\n * RPC client runs: a registered mapping, else the installed deriver, else throw\n * - Context propagation onto the task headers (a credential is never a context key, so none can ride along)\n * - Handing a fully-built TaskRequest to the bound {@link TaskInvoker}\n */\n@provideFrameworkTransient()\nexport class TaskProxyClient {\n /**\n * The enqueue transport's default for `CallRegistry.execute` (Path A): 30 s if a caller only\n * registers a strategy-free timeout ladder, otherwise webpieces' shared `RetryStrategy` — 5 s per\n * attempt, 3 retries, backoff 500 ms x2 capped at 4 s. RPC passes `new TransportDefault(30_000)`\n * (no retry); this is the ONLY place the two transports differ.\n */\n static readonly ENQUEUE_TRANSPORT_DEFAULT = new TransportDefault(\n 30_000,\n new RetryStrategy(5_000, 3, 500, 2, 4_000).run,\n );\n\n /** Path B's safety cap when no timeout is registered: caller-owned gax timing still ends. */\n static readonly ENQUEUE_SAFETY_CAP_MS = 30_000;\n\n // Assigned by init(), which the factory calls immediately after construction.\n private plans!: Map<string, EndpointPlan>;\n private apiName!: string;\n private apiClass!: ApiPrototype<object>;\n private config!: TaskClientConfig;\n\n /**\n * The api-call log seam, built around this node-only package's OWN RequestContext-backed\n * ApiCallContext. Constructed here rather than installed at startup, so a plain NestJS/Express\n * host needs no webpieces STARTUP INSTALL to enqueue — it only has to run the call inside a\n * `RequestContext.run(...)` scope, which such a host already opens per request. That is the defect\n * that made every enqueue throw `ApiCallContext is not installed`.\n */\n private readonly logApiCall = new LogApiCallImpl(new RequestContextApiCallContext());\n\n constructor(\n @inject(TaskInvoker) private readonly invoker: TaskInvoker,\n @inject(RequestContextHeaders) private readonly headers: RequestContextHeaders,\n ) {}\n\n /** Bind this client to one @PubSub contract + target. */\n init(apiClass: ApiPrototype<object>, config: TaskClientConfig): void {\n assertNotInternalApi(apiClass, 'Cloud Tasks client');\n if (!isApiPath(apiClass)) {\n throw new Error(\n `Class ${apiClass.name || 'Unknown'} must be decorated with @ApiPath()`,\n );\n }\n assertPubSubConventions(apiClass);\n assertEveryEndpointHasAuthMode(apiClass);\n\n this.config = config;\n this.apiClass = apiClass;\n this.apiName = apiClass.name || 'UnknownApi';\n this.plans = this.buildPlans(apiClass);\n }\n\n /** Check whether the contract declares a @PubSub endpoint with this method name. */\n hasEndpoint(methodName: string): boolean {\n return this.plans.has(methodName);\n }\n\n /**\n * Enqueue one task for the named endpoint. Must run inside a CloudTaskScheduler lambda\n * (which supplies the ScheduleInfo) within an active RequestContext, e.g.:\n * scheduler.addToQueue(() => taskClient.foo(req), new ScheduleOptions(TaskName.dedup(id)));\n *\n * Before any attempt it resolves, ONCE: the task name (so every attempt carries the same one),\n * and the effective customizer and gax options (call site, else `GcpTaskRegistry` method -> API\n * -> ALL). Then exactly ONE retry layer runs:\n *\n * - **Path A, no gax — webpieces owns timing.** `CallRegistry.execute` with\n * {@link TaskProxyClient.ENQUEUE_TRANSPORT_DEFAULT}: a registered strategy (method -> API -> ALL), else a\n * registered timeout, else `RetryStrategy(5_000, 3, 500, 2, 4_000)`. Each attempt sends\n * `createTask(request, { timeout: attemptMs })`.\n * - **Path B, gax set — you own timing.** No strategy lookup and ONE attempt, sending\n * `createTask(request, yourGax)` under a safety cap from the timeout ladder (method -> API ->\n * ALL -> 30 s) so a hung channel cannot block forever.\n *\n * Either way the endpoint reports `WRITE_IDEMPOTENT`: one name for every attempt makes a repeat\n * safe (delivery is at-least-once anyway, and `TaskName.serverAssigned()` accepts duplicates).\n * A `TaskAlreadyExistsError` is caught HERE, above the retry layer: `WARN [ENQUEUE-DEDUP]`, then\n * success with `JobReference.alreadyExisted = true`.\n */\n // webpieces-disable no-any-unknown -- the request DTO's type is erased at the proxy boundary\n async enqueue(methodName: string, requestDto: unknown): Promise<void> {\n const plan = this.plans.get(methodName);\n if (!plan) {\n throw new Error(`No @PubSub endpoint '${methodName}' on ${this.apiName}`);\n }\n\n const frame = currentScheduleFrame();\n if (!frame) {\n throw new Error(\n 'Cloud task enqueue must run inside a CloudTaskScheduler lambda, e.g. ' +\n 'scheduler.addToQueue(() => taskClient.method(req), new ScheduleOptions(TaskName.dedup(id))).',\n );\n }\n const enqueuePlan = new EnqueuePlan(\n frame.info.taskName.resolve(),\n frame.info.customizeTask ?? GcpTaskRegistry.customizerFor(this.apiClass, methodName),\n frame.info.gax ?? GcpTaskRegistry.gaxFor(this.apiClass, methodName),\n );\n\n // Route through LogApiCall exactly like ProxyClient does for http. TaskProxyClient.enqueue is\n // the ONE seam above BOTH GcpTaskInvoker (prod) and InMemoryTaskInvoker (localhost) — whichever\n // TaskInvoker is bound, this method sits above it — so the enqueue edge gets structured\n // `jsonPayload.api.*` logging uniformly, without touching either invoker impl.\n const info = new ApiMethodInfo('client', this.apiName, methodName, undefined, plan.mask);\n await this.logApiCall.execute(info, requestDto, async () => {\n const callContext = new CallContext(this.apiName, methodName, WRITE_IDEMPOTENT);\n const attempt = (timeoutMs: number): Promise<JobReference> =>\n CallDeadline.run(timeoutMs, callContext, (signal: AbortSignal) =>\n this.attempt(plan, enqueuePlan, frame.info, requestDto, timeoutMs, signal),\n );\n // Only the winning attempt may publish the reference. A late enqueue cannot overwrite it.\n frame.jobRef = await this.dedupAware(methodName, () =>\n enqueuePlan.gax === undefined\n ? CallRegistry.execute(this.apiClass, callContext, attempt, TaskProxyClient.ENQUEUE_TRANSPORT_DEFAULT)\n : attempt(CallRegistry.resolveTimeout(this.apiClass, methodName, TaskProxyClient.ENQUEUE_SAFETY_CAP_MS)),\n );\n // Fire-and-forget: the enqueue ack is not a meaningful response, but it is non-null so\n // LogApiCall's null-response guard passes; a recorded test asserts the enqueued REQUEST.\n return frame.jobRef;\n });\n }\n\n /** ALREADY_EXISTS is the task the caller asked for, already there: success, logged once. */\n private async dedupAware(\n methodName: string,\n enqueue: () => Promise<JobReference>,\n ): Promise<JobReference> {\n // webpieces-disable no-unmanaged-exceptions -- ALREADY_EXISTS is success; everything else propagates unchanged\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return await enqueue();\n } catch (err: unknown) {\n const error = toError(err);\n if (!(error instanceof TaskAlreadyExistsError)) {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- rethrows the enqueue's own failure unchanged\n throw err;\n }\n log.warn(\n `[ENQUEUE-DEDUP] ${this.apiName}.${methodName} task ${error.taskName} already exists ` +\n '(a retry whose earlier attempt landed, or a dedup name reused) — treated as success',\n );\n return new JobReference(error.taskName, true);\n }\n }\n\n /** ONE enqueue attempt: a fresh TaskRequest, handed to the bound invoker. */\n private async attempt(\n plan: EndpointPlan,\n enqueuePlan: EnqueuePlan,\n scheduleInfo: ScheduleInfo,\n // webpieces-disable no-any-unknown -- the request DTO's type is erased at the proxy boundary\n requestDto: unknown,\n timeoutMs: number,\n signal: AbortSignal,\n ): Promise<JobReference> {\n // Resolved lazily (not at client construction) so building a client stays synchronous.\n // Every metadata read beneath resolveUrl is memoized process-wide, so only the first\n // enqueue in the process pays a lookup.\n const targetUrl = await this.config.resolveUrl();\n CallDeadline.throwIfAborted(signal);\n\n const request = new TaskRequest(\n targetUrl,\n plan.path,\n plan.queueName,\n requestDto,\n this.buildContextHeaders(plan.authMode),\n plan.authMode,\n scheduleInfo,\n enqueuePlan,\n );\n\n // svcName, not the URL, is the stable name across demo/qa/prod.\n log.debug(`enqueue task ${plan.queueName} -> ${this.config.svcName}${plan.path}`);\n return this.invoker.enqueue(request, timeoutMs, signal);\n }\n\n /** Endpoint name -> its resolved path / queue / auth mode, read once from the decorators. */\n private buildPlans(apiClass: ApiPrototype<object>): Map<string, EndpointPlan> {\n const basePath = getApiPath(apiClass) ?? '';\n const endpoints = getEndpoints(apiClass) ?? {};\n const plans = new Map<string, EndpointPlan>();\n\n for (const methodName of Object.keys(endpoints)) {\n const authMeta = getAuthMeta(apiClass, methodName);\n const authMode = authMeta ? this.invoker.selectAuthentication(authMeta) : undefined;\n if (!authMode) {\n throw new Error(`Endpoint '${methodName}' on ${this.apiName} has no auth mode`);\n }\n const plan = new EndpointPlan(\n basePath + endpoints[methodName],\n getQueueName(apiClass, methodName),\n authMode,\n getMaskSpec(apiClass, methodName),\n );\n plans.set(methodName, plan);\n }\n return plans;\n }\n\n /**\n * Every transferred context key (txId/requestId/tenant…) the DELIVERY endpoint may receive,\n * request-id chained. Throws if there is no active RequestContext — an enqueue with no trace is\n * a bug.\n *\n * No credential can appear here: `authorization` is read off the inbound HttpRequest and is not\n * a ContextKey, so it never enters the RequestContext to be transferred. The invoker mints the\n * task's own delivery auth per the endpoint's oidc(...) / sharedSecret(...) mode.\n *\n * TRUSTED keys DO flow on this path, and that is the point of deriving {@link DestinationTrust}\n * from `plan.authMode` rather than hardcoding it: a task is delivered under the OIDC token the\n * invoker mints, so the delivery endpoint authenticates its caller and its AuthFilter admits the\n * userId/orgId we vouched for. A @PubSub endpoint declared @WpAuthPublic or jwt() would not, and it\n * correctly stops receiving them — the enqueue keeps working, minus context the callee would\n * have 401'd on.\n */\n private buildContextHeaders(authMode: AuthMode): Map<string, string> {\n return this.headers.buildOutboundHeaders(DestinationTrust.forAuthMode(authMode));\n }\n}\n\n/**\n * DI token for the `Provider<TaskProxyClient>` that hands out enqueue clients — one per @PubSub\n * contract. `Provider<T>` is erased at runtime, so it cannot be its own token; this Symbol names T.\n *\n * Because TaskProxyClient is bound TRANSIENT, every `get()` constructs a new one. (Were it bound\n * `@provideFrameworkSingleton`, the very same Provider would hand back one lazily-created instance\n * instead — the provider caches nothing, so the target's scope decides.)\n */\n// webpieces-disable no-symbol-di-tokens -- Provider<T> is erased at runtime; the Symbol names T\nexport const TASK_PROXY_CLIENT_PROVIDER = Symbol.for('Provider<TaskProxyClient>');\n"]}
|
package/src/TaskTypes.d.ts
CHANGED
|
@@ -1,25 +1,82 @@
|
|
|
1
|
-
import { AuthMode } from '@webpieces/core-util';
|
|
1
|
+
import { AuthMeta, AuthMode } from '@webpieces/core-util';
|
|
2
|
+
import type { protos } from '@google-cloud/tasks';
|
|
3
|
+
import type { CallOptions } from 'google-gax';
|
|
4
|
+
import { TaskName } from './TaskName';
|
|
5
|
+
/** The Google `CreateTask` request: `{ parent, task }`, built by webpieces on every attempt. */
|
|
6
|
+
export type ICreateTaskRequest = protos.google.cloud.tasks.v2.ICreateTaskRequest;
|
|
7
|
+
/**
|
|
8
|
+
* Changes the Google request of ONE enqueue attempt. It runs on every attempt, LAST, on a freshly
|
|
9
|
+
* built `ICreateTaskRequest` (url, body, headers, auth, name, scheduleTime and dispatchDeadline are
|
|
10
|
+
* already set) and returns the request to send — the same object mutated, or a new one.
|
|
11
|
+
*
|
|
12
|
+
* It is Google-request-only and never touches timing: gax `CallOptions` are
|
|
13
|
+
* `ScheduleOptions.gax` / `GcpTaskRegistry.setGax`. If it changes `task.name`, it must stay under the
|
|
14
|
+
* same queue path and return the same name on every attempt, or webpieces' own retries can create
|
|
15
|
+
* duplicates. The in-memory invoker has no Google request and ignores it.
|
|
16
|
+
*/
|
|
17
|
+
export type TaskCustomizer = (request: ICreateTaskRequest) => ICreateTaskRequest;
|
|
2
18
|
/**
|
|
3
19
|
* Scheduling options for an enqueued task. Travels out-of-band (via the scheduler
|
|
4
20
|
* frame in RequestContext) so the shared API method signature stays `foo(req)` on
|
|
5
21
|
* both the client and the controller.
|
|
6
22
|
*/
|
|
7
23
|
export declare class ScheduleInfo {
|
|
24
|
+
/** How the task is named; resolved ONCE per logical enqueue by `TaskProxyClient`. */
|
|
25
|
+
readonly taskName: TaskName;
|
|
26
|
+
/** Absolute epoch-millis to run at; omitted = run as soon as possible. */
|
|
27
|
+
readonly epochMsToRunAt?: number | undefined;
|
|
28
|
+
/** Per-task dispatch deadline in seconds (`task.dispatchDeadline`). */
|
|
29
|
+
readonly taskTimeoutSeconds?: number | undefined;
|
|
30
|
+
/** The call site's {@link TaskCustomizer}; wins over `GcpTaskRegistry.setCustomizer`. */
|
|
31
|
+
readonly customizeTask?: TaskCustomizer | undefined;
|
|
32
|
+
/** The call site's gax `CallOptions`; set ⇒ the caller owns timing for this enqueue. */
|
|
33
|
+
readonly gax?: CallOptions | undefined;
|
|
34
|
+
constructor(
|
|
35
|
+
/** How the task is named; resolved ONCE per logical enqueue by `TaskProxyClient`. */
|
|
36
|
+
taskName: TaskName,
|
|
8
37
|
/** Absolute epoch-millis to run at; omitted = run as soon as possible. */
|
|
9
|
-
epochMsToRunAt?: number
|
|
10
|
-
/** Per-task dispatch deadline in seconds. */
|
|
11
|
-
taskTimeoutSeconds?: number
|
|
38
|
+
epochMsToRunAt?: number | undefined,
|
|
39
|
+
/** Per-task dispatch deadline in seconds (`task.dispatchDeadline`). */
|
|
40
|
+
taskTimeoutSeconds?: number | undefined,
|
|
41
|
+
/** The call site's {@link TaskCustomizer}; wins over `GcpTaskRegistry.setCustomizer`. */
|
|
42
|
+
customizeTask?: TaskCustomizer | undefined,
|
|
43
|
+
/** The call site's gax `CallOptions`; set ⇒ the caller owns timing for this enqueue. */
|
|
44
|
+
gax?: CallOptions | undefined);
|
|
45
|
+
}
|
|
46
|
+
/** Handle to an enqueued task (its Cloud Tasks name), returned by the scheduler. */
|
|
47
|
+
export declare class JobReference {
|
|
48
|
+
/** The task's full resource name (`projects/.../tasks/<id>`), or the in-memory task id. */
|
|
49
|
+
readonly taskId: string;
|
|
12
50
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
51
|
+
* True when the task already existed (ALREADY_EXISTS): a retry whose earlier attempt landed,
|
|
52
|
+
* or a `TaskName.dedup` collision. Reported as success, never as an error.
|
|
15
53
|
*/
|
|
16
|
-
|
|
17
|
-
constructor(
|
|
54
|
+
readonly alreadyExisted: boolean;
|
|
55
|
+
constructor(
|
|
56
|
+
/** The task's full resource name (`projects/.../tasks/<id>`), or the in-memory task id. */
|
|
57
|
+
taskId: string,
|
|
58
|
+
/**
|
|
59
|
+
* True when the task already existed (ALREADY_EXISTS): a retry whose earlier attempt landed,
|
|
60
|
+
* or a `TaskName.dedup` collision. Reported as success, never as an error.
|
|
61
|
+
*/
|
|
62
|
+
alreadyExisted: boolean);
|
|
18
63
|
}
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
64
|
+
/**
|
|
65
|
+
* What `TaskProxyClient` resolved ONCE for a logical enqueue, before any attempt: the task id every
|
|
66
|
+
* attempt carries, and the EFFECTIVE customizer and gax options (call site, else `GcpTaskRegistry`
|
|
67
|
+
* method -> API -> ALL).
|
|
68
|
+
*/
|
|
69
|
+
export declare class EnqueuePlan {
|
|
70
|
+
/** The task id (not the full path); undefined = let Cloud Tasks assign one. */
|
|
71
|
+
readonly taskId: string | undefined;
|
|
72
|
+
readonly customizeTask?: TaskCustomizer | undefined;
|
|
73
|
+
/** Set ⇒ the caller owns timing: one attempt, `createTask(request, gax)` as given. */
|
|
74
|
+
readonly gax?: CallOptions | undefined;
|
|
75
|
+
constructor(
|
|
76
|
+
/** The task id (not the full path); undefined = let Cloud Tasks assign one. */
|
|
77
|
+
taskId: string | undefined, customizeTask?: TaskCustomizer | undefined,
|
|
78
|
+
/** Set ⇒ the caller owns timing: one attempt, `createTask(request, gax)` as given. */
|
|
79
|
+
gax?: CallOptions | undefined);
|
|
23
80
|
}
|
|
24
81
|
/**
|
|
25
82
|
* Everything an invoker needs to enqueue one task. Built by the enqueue proxy from
|
|
@@ -27,20 +84,38 @@ export declare class JobReference {
|
|
|
27
84
|
*/
|
|
28
85
|
export declare class TaskRequest {
|
|
29
86
|
/** Callee base URL (e.g. https://email-svc-123.us-central1.run.app). */
|
|
30
|
-
targetUrl: string;
|
|
87
|
+
readonly targetUrl: string;
|
|
31
88
|
/** Endpoint path the task is delivered to (basePath + endpoint, e.g. /email/send). */
|
|
32
|
-
path: string;
|
|
89
|
+
readonly path: string;
|
|
33
90
|
/** Cloud Tasks queue name (getQueueName: @Queue override or `${Api}-${method}`). */
|
|
34
|
-
queueName: string;
|
|
91
|
+
readonly queueName: string;
|
|
35
92
|
/** The request DTO (serialized to the POST body on delivery). */
|
|
36
|
-
body: unknown;
|
|
93
|
+
readonly body: unknown;
|
|
37
94
|
/** Context headers to propagate (txId/requestId/tenant…), already resolved. */
|
|
38
|
-
contextHeaders: Map<string, string>;
|
|
95
|
+
readonly contextHeaders: Map<string, string>;
|
|
39
96
|
/** The endpoint's auth mode — how the invoker authenticates delivery. */
|
|
40
|
-
authMode: AuthMode;
|
|
41
|
-
/** Scheduling options (
|
|
42
|
-
scheduleInfo: ScheduleInfo;
|
|
43
|
-
|
|
97
|
+
readonly authMode: AuthMode;
|
|
98
|
+
/** Scheduling options (run-at, dispatch deadline). */
|
|
99
|
+
readonly scheduleInfo: ScheduleInfo;
|
|
100
|
+
/** The resolved name, customizer and gax for this logical enqueue. */
|
|
101
|
+
readonly plan: EnqueuePlan;
|
|
102
|
+
constructor(
|
|
103
|
+
/** Callee base URL (e.g. https://email-svc-123.us-central1.run.app). */
|
|
104
|
+
targetUrl: string,
|
|
105
|
+
/** Endpoint path the task is delivered to (basePath + endpoint, e.g. /email/send). */
|
|
106
|
+
path: string,
|
|
107
|
+
/** Cloud Tasks queue name (getQueueName: @Queue override or `${Api}-${method}`). */
|
|
108
|
+
queueName: string,
|
|
109
|
+
/** The request DTO (serialized to the POST body on delivery). */
|
|
110
|
+
body: unknown,
|
|
111
|
+
/** Context headers to propagate (txId/requestId/tenant…), already resolved. */
|
|
112
|
+
contextHeaders: Map<string, string>,
|
|
113
|
+
/** The endpoint's auth mode — how the invoker authenticates delivery. */
|
|
114
|
+
authMode: AuthMode,
|
|
115
|
+
/** Scheduling options (run-at, dispatch deadline). */
|
|
116
|
+
scheduleInfo: ScheduleInfo,
|
|
117
|
+
/** The resolved name, customizer and gax for this logical enqueue. */
|
|
118
|
+
plan: EnqueuePlan);
|
|
44
119
|
}
|
|
45
120
|
/**
|
|
46
121
|
* The transport that actually enqueues a task. Abstract class so it doubles as the
|
|
@@ -48,9 +123,21 @@ export declare class TaskRequest {
|
|
|
48
123
|
* InMemoryTaskInvoker in tests/local dev. Both deliver a task the same way — a plain
|
|
49
124
|
* HTTP POST to `targetUrl + path` — GcpTaskInvoker via Google Cloud Tasks, and
|
|
50
125
|
* InMemoryTaskInvoker via an in-process queue that fetches the target directly.
|
|
126
|
+
*
|
|
127
|
+
* An invoker performs ONE attempt and never retries: retries belong to `CallRegistry` (webpieces'
|
|
128
|
+
* `RetryStrategy` by default) or, when `plan.gax` is set, to gax. It throws
|
|
129
|
+
* `TaskAlreadyExistsError` for a name that already exists.
|
|
51
130
|
*/
|
|
52
131
|
export declare abstract class TaskInvoker {
|
|
53
|
-
selectAuthentication(meta:
|
|
54
|
-
|
|
132
|
+
selectAuthentication(meta: AuthMeta): AuthMode;
|
|
133
|
+
/**
|
|
134
|
+
* Enqueue ONE attempt.
|
|
135
|
+
*
|
|
136
|
+
* @param request - the task, including the resolved {@link EnqueuePlan}
|
|
137
|
+
* @param timeoutMs - this attempt's deadline (webpieces' attempt timeout, or the safety cap when
|
|
138
|
+
* the caller owns timing); the GCP invoker sends it as the gRPC deadline
|
|
139
|
+
* @param signal - aborted when the attempt's deadline fires
|
|
140
|
+
*/
|
|
141
|
+
abstract enqueue(request: TaskRequest, timeoutMs: number, signal: AbortSignal): Promise<JobReference>;
|
|
55
142
|
abstract delete(ref: JobReference): Promise<void>;
|
|
56
143
|
}
|