@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.
Files changed (41) hide show
  1. package/README.md +74 -13
  2. package/package.json +10 -5
  3. package/src/ClientCloudTasksFactory.d.ts +1 -1
  4. package/src/ClientCloudTasksFactory.js +1 -1
  5. package/src/ClientCloudTasksFactory.js.map +1 -1
  6. package/src/CloudTaskScheduler.d.ts +37 -13
  7. package/src/CloudTaskScheduler.js +38 -14
  8. package/src/CloudTaskScheduler.js.map +1 -1
  9. package/src/CloudTasksClientProvider.d.ts +29 -0
  10. package/src/CloudTasksClientProvider.js +41 -0
  11. package/src/CloudTasksClientProvider.js.map +1 -0
  12. package/src/GcpTaskInvoker.d.ts +27 -4
  13. package/src/GcpTaskInvoker.js +61 -19
  14. package/src/GcpTaskInvoker.js.map +1 -1
  15. package/src/GcpTaskRegistry.d.ts +31 -0
  16. package/src/GcpTaskRegistry.js +79 -0
  17. package/src/GcpTaskRegistry.js.map +1 -0
  18. package/src/GrpcErrorMapper.d.ts +27 -0
  19. package/src/GrpcErrorMapper.js +52 -0
  20. package/src/GrpcErrorMapper.js.map +1 -0
  21. package/src/InMemoryTaskInvoker.d.ts +9 -1
  22. package/src/InMemoryTaskInvoker.js +16 -3
  23. package/src/InMemoryTaskInvoker.js.map +1 -1
  24. package/src/TaskAlreadyExistsError.d.ts +18 -0
  25. package/src/TaskAlreadyExistsError.js +26 -0
  26. package/src/TaskAlreadyExistsError.js.map +1 -0
  27. package/src/TaskName.d.ts +33 -0
  28. package/src/TaskName.js +71 -0
  29. package/src/TaskName.js.map +1 -0
  30. package/src/TaskProxyClient.d.ts +32 -1
  31. package/src/TaskProxyClient.js +77 -27
  32. package/src/TaskProxyClient.js.map +1 -1
  33. package/src/TaskTypes.d.ts +110 -23
  34. package/src/TaskTypes.js +72 -22
  35. package/src/TaskTypes.js.map +1 -1
  36. package/src/__tests__/FakeCloudTasksServer.d.ts +51 -0
  37. package/src/__tests__/FakeCloudTasksServer.js +121 -0
  38. package/src/__tests__/FakeCloudTasksServer.js.map +1 -0
  39. package/src/index.d.ts +7 -1
  40. package/src/index.js +13 -1
  41. package/src/index.js.map +1 -1
package/README.md CHANGED
@@ -17,11 +17,11 @@ abstract class EmailApi {
17
17
 
18
18
  // build the client once (sync); 'email-svc' is the callee's Cloud Run service name
19
19
  const emailTasks = factory.createPubSubClient(EmailApi, new TaskClientConfig('email-svc'));
20
- // ...or pin a URL lookup cannot describe (other region/project); svcName stays the log name
21
- const other = factory.createPubSubClient(EmailApi, new TaskClientConfig('email-svc', 'https://email.eu.example'));
22
20
 
23
21
  // producer (inside a request → RequestContext active)
24
- await scheduler.addToQueue(() => emailTasks.sendEmail(req), { dedupName: req.id });
22
+ await scheduler.addToQueue(() => emailTasks.sendEmail(req)); // retried, auto-named
23
+ await scheduler.addToQueue(() => emailTasks.sendEmail(req), new ScheduleOptions(TaskName.dedup(req.id), 900));
24
+ await scheduler.schedule(() => emailTasks.sendEmail(req), Date.now() + 60_000, new ScheduleOptions(TaskName.dedup(req.id)));
25
25
  ```
26
26
 
27
27
  - `ClientCloudTasksFactory.createPubSubClient(Api, TaskClientConfig)` — builds the enqueue proxy. It
@@ -35,13 +35,74 @@ await scheduler.addToQueue(() => emailTasks.sendEmail(req), { dedupName: req.id
35
35
  - An enqueue outside `RequestContext.run(...)` **throws**: a task with no caller trace is a bug
36
36
  - `CloudTaskScheduler` — `addToQueue` / `schedule` / `cancelJob`; carries scheduling
37
37
  options out-of-band so the contract signature stays identical on both sides
38
- - `TaskInvoker` (abstract token) with two impls:
39
- - `GcpTaskInvoker` — real `@google-cloud/tasks` delivery (OIDC / shared-secret)
40
- - `InMemoryTaskInvoker` — dispatches through the real server filter chain in-process
41
- (tests + local dev, no GCP, production parity)
42
-
43
- Enqueue calls have a **30-second default timeout**. `CallRegistry` from `@webpieces/core-util` shares
44
- ALL, API and API-method timeout/strategy overrides with the HTTP clients. Any strategy replaces the
45
- entire timeout ladder; there is no default retry. These are **enqueue** retries, separate from queue
46
- delivery retries and task execution deadlines. A timed-out enqueue may still complete remotely;
47
- retry only with application-owned idempotency/deduplication. See [client timeouts](../../../docs/client-timeouts.md).
38
+ - `TaskInvoker` (abstract token) with two impls, each making ONE attempt per call:
39
+ - `GcpTaskInvoker` — real `@google-cloud/tasks` 6.3.0 (google-gax 5) delivery (OIDC / shared-secret)
40
+ - `InMemoryTaskInvoker` — delivers over real HTTP to the target's filter chain (tests + local dev,
41
+ no GCP, production parity); ignores the customizer and gax, but still runs the same retry paths
42
+
43
+ ## Enqueue retries (this is ENQUEUE, not delivery)
44
+
45
+ The `createTask` gRPC call is retried by webpieces' shared `RetryStrategy` — the same
46
+ `CallRegistry.execute` the RPC clients use, with the enqueue transport default
47
+ `RetryStrategy(5_000, 3, 500, 2, 4_000)`: 5 s per attempt, 3 retries, backoff 500 ms ×2 capped at
48
+ 4 s. Only a transient failure is retried (gRPC UNAVAILABLE, DEADLINE_EXCEEDED, RESOURCE_EXHAUSTED →
49
+ `GrpcErrorMapper`); giving up throws a 502 `ApiRetriesExhaustedError`. Each retried attempt logs
50
+ `WARN [API-client-attempt-TRANSIENT]`. Delivery retries (Cloud Tasks re-POSTing the target) are the
51
+ queue's `retryConfig` and are out of scope here.
52
+
53
+ Retries are safe because every enqueue is NAMED ONCE, before the first attempt:
54
+
55
+ | `ScheduleOptions.taskName` | name | a retry after a lost response |
56
+ |---|---|---|
57
+ | `TaskName.generated()` (default) | one UUID per logical enqueue | ALREADY_EXISTS → success |
58
+ | `TaskName.dedup(key)` | your key (MessageSid, org+period, …): true dedup across callers | ALREADY_EXISTS → success |
59
+ | `TaskName.serverAssigned()` | none — fastest `createTask` | may create a duplicate |
60
+
61
+ ALREADY_EXISTS is success: `WARN [ENQUEUE-DEDUP]`, and the `JobReference` comes back with
62
+ `alreadyExisted: true`. Delete any hand-rolled `isAlreadyExists` catch. A used name stays blocked
63
+ for about an hour after its task runs or is deleted.
64
+
65
+ ## Three levels of Google settings
66
+
67
+ ```ts
68
+ new ScheduleOptions(taskName, taskTimeoutSeconds?, customizeTask?, gax?)
69
+ ```
70
+
71
+ - **`customizeTask`** (or `GcpTaskRegistry.setCustomizer(c, ...scope)`) — the Google
72
+ `ICreateTaskRequest`, run LAST on every attempt's freshly built request. Request only, never timing.
73
+ Call site wins over the registry (method → API → ALL).
74
+ - **`gax`** (or `GcpTaskRegistry.setGax(options, ...scope)`) — `CallOptions` for the one `createTask`
75
+ RPC. **Set ⇒ you own timing:** no webpieces strategy, ONE attempt, `createTask(request, yourGax)`
76
+ as given, under a safety cap from `CallRegistry`'s timeout ladder (method → API → ALL → 30 s). Raise
77
+ the cap with `CallRegistry.setTimeout(60_000, EmailApi)` to give gax retries room. Exactly one retry
78
+ layer per enqueue — ours or gax's, never both.
79
+ - **The client** — override `CloudTasksClientProvider` for channel/client settings:
80
+
81
+ ```ts
82
+ class KeepaliveCloudTasks extends CloudTasksClientProvider {
83
+ override create(): CloudTasksClient {
84
+ return new CloudTasksClient({ 'grpc.keepalive_time_ms': 30_000 });
85
+ }
86
+ }
87
+ (await options.rebind(CloudTasksClientProvider)).to(KeepaliveCloudTasks).inSingletonScope();
88
+ ```
89
+
90
+ Unbound, it is `new CloudTasksClient()` — nobody has to bind anything. (A provider class rather than
91
+ an optional `CloudTasksClient` binding, because the app container's inversify `autobind` tries to
92
+ construct any unbound class token, `@optional()` included, and fails on `CloudTasksClient`.)
93
+
94
+ ```ts
95
+ // you own timing AND retries (gax retries UNAVAILABLE); raise the safety cap
96
+ await scheduler.addToQueue(() => api.sendEmail(req),
97
+ new ScheduleOptions(TaskName.dedup(id), undefined, undefined, { timeout: 10_000, retry: { retryCodes: [14] } }));
98
+ CallRegistry.setTimeout(60_000, EmailApi);
99
+
100
+ // process-wide defaults, no call-site edits
101
+ GcpTaskRegistry.setCustomizer((request) => { request.task!.dispatchDeadline = { seconds: 900 }; return request; }, 'ALL');
102
+ CallRegistry.setStrategy(new RetryStrategy(2_000, 5, 200, 2, 2_000).run, WhatsAppProcessApi); // tune OUR retries
103
+ ```
104
+
105
+ `createTask`'s public promise exposes no `cancel()`; on the default path the gRPC deadline equals the
106
+ attempt deadline, so gRPC cancels a hung RPC itself. See [client timeouts](../../../docs/client-timeouts.md).
107
+ `GcpTaskInvoker.grpc.spec.ts` proves all of it against a local fake gRPC server with the real
108
+ `CloudTasksClient`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/cloudtasks-client",
3
- "version": "0.4.893",
3
+ "version": "0.4.895",
4
4
  "description": "Cloud Tasks enqueue client generated from a shared @PubSub API contract (twin of http-client)",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -23,11 +23,16 @@
23
23
  "access": "public"
24
24
  },
25
25
  "dependencies": {
26
- "@webpieces/core-context": "0.4.893",
27
- "@webpieces/core-util": "0.4.893",
28
- "@webpieces/gcp-identity": "0.4.893",
29
- "@google-cloud/tasks": "5.5.2",
26
+ "@webpieces/core-context": "0.4.895",
27
+ "@webpieces/core-util": "0.4.895",
28
+ "@webpieces/gcp-identity": "0.4.895",
29
+ "@google-cloud/tasks": "6.3.0",
30
+ "google-gax": "5.0.8",
30
31
  "inversify": "7.10.4",
31
32
  "reflect-metadata": "0.2.2"
33
+ },
34
+ "devDependencies": {
35
+ "@grpc/grpc-js": "1.14.4",
36
+ "@grpc/proto-loader": "0.8.1"
32
37
  }
33
38
  }
@@ -11,7 +11,7 @@ import { ApiPrototype, TaskClientConfig } from './TaskClientConfig';
11
11
  * ```typescript
12
12
  * // same project + region as this container; the URL is derived, you maintain nothing
13
13
  * const emailTasks = factory.createPubSubClient(EmailApi, new TaskClientConfig('email-svc'));
14
- * await scheduler.addToQueue(() => emailTasks.sendEmail(req), { dedupName });
14
+ * await scheduler.addToQueue(() => emailTasks.sendEmail(req), new ScheduleOptions(TaskName.dedup(id)));
15
15
  * ```
16
16
  *
17
17
  * Every client it builds gets its OWN {@link TaskProxyClient} from the injected
@@ -27,7 +27,7 @@ const FRAMEWORK_INSPECTION_PROPERTIES = new Set([
27
27
  * ```typescript
28
28
  * // same project + region as this container; the URL is derived, you maintain nothing
29
29
  * const emailTasks = factory.createPubSubClient(EmailApi, new TaskClientConfig('email-svc'));
30
- * await scheduler.addToQueue(() => emailTasks.sendEmail(req), { dedupName });
30
+ * await scheduler.addToQueue(() => emailTasks.sendEmail(req), new ScheduleOptions(TaskName.dedup(id)));
31
31
  * ```
32
32
  *
33
33
  * Every client it builds gets its OWN {@link TaskProxyClient} from the injected
@@ -1 +1 @@
1
- {"version":3,"file":"ClientCloudTasksFactory.js","sourceRoot":"","sources":["../../../../../packages/cloud/cloudtasks-client/src/ClientCloudTasksFactory.ts"],"names":[],"mappings":";;;;AAAA,yCAAmC;AACnC,oDAAmE;AACnE,0DAAqG;AACrG,uDAAgF;AAGhF,iGAAiG;AACjG,6EAA6E;AAC7E,IAAA,oCAAqB,EAAC,4CAA0B,EAAE,iCAAe,CAAC,CAAC;AAEnE;;;GAGG;AACH,MAAM,+BAA+B,GAAG,IAAI,GAAG,CAAS;IACpD,aAAa,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS;IAC3E,QAAQ,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS,EAAE,UAAU;CACrE,CAAC,CAAC;AAEH;;;;;;;;;;;;;;;;;;;GAmBG;AAGI,IAAM,uBAAuB,GAA7B,MAAM,uBAAuB;IAEyB;IADzD,YACyD,uBAAkD;QAAlD,4BAAuB,GAAvB,uBAAuB,CAA2B;IACxG,CAAC;IAEJ,kFAAkF;IAClF,kBAAkB,CAAmB,QAAyB,EAAE,MAAwB;QACpF,8DAA8D;QAC9D,MAAM,WAAW,GAAG,IAAI,CAAC,uBAAuB,CAAC,GAAG,EAAE,CAAC;QACvD,WAAW,CAAC,IAAI,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;QACnC,MAAM,OAAO,GAAG,QAAQ,CAAC,IAAI,IAAI,YAAY,CAAC;QAE9C,OAAO,IAAI,KAAK,CAAC,EAAO,EAAE;YACtB,oGAAoG;YACpG,GAAG,CAAC,MAAS,EAAE,IAAqB;gBAChC,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,+BAA+B,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;oBACxE,OAAO,SAAS,CAAC;gBACrB,CAAC;gBACD,IAAI,CAAC,WAAW,CAAC,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC;oBACjC,MAAM,IAAI,KAAK,CACX,wBAAwB,IAAI,QAAQ,QAAQ,CAAC,IAAI,IAAI,SAAS,IAAI;wBAClE,qDAAqD,CACxD,CAAC;gBACN,CAAC;gBACD,kFAAkF;gBAClF,gDAAgD;gBAChD,oFAAoF;gBACpF,MAAM,YAAY,GAAG,CAAC,UAAmB,EAAiB,EAAE;oBACxD,MAAM,QAAQ,GAAG,uBAAW,CAAC,OAAO,CAAC,OAAO,EAAE,IAAI,EAAE,YAAY,CAAC,CAAC;oBAClE,6EAA6E;oBAC7E,OAAO,WAAW,CAAC,OAAO,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC,KAAK,CAAC,CAAC,OAAgB,EAAE,EAAE;wBACpE,QAAQ,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;wBAC3B,6GAA6G;wBAC7G,MAAM,OAAO,CAAC;oBAClB,CAAC,CAAC,CAAC;gBACP,CAAC,CAAC;gBACF,OAAO,YAAY,CAAC;YACxB,CAAC;SACJ,CAAC,CAAC;IACP,CAAC;CACJ,CAAA;AAxCY,0DAAuB;kCAAvB,uBAAuB;IAFnC,IAAA,0BAAc,GAAE;IAChB,IAAA,wCAAyB,GAAE;IAGnB,mBAAA,IAAA,kBAAM,EAAC,4CAA0B,CAAC,CAAA;6CAA2C,uBAAQ;GAFjF,uBAAuB,CAwCnC","sourcesContent":["import { inject } from 'inversify';\nimport { ApiCallSite, DocumentDesign } from '@webpieces/core-util';\nimport { Provider, bindFrameworkProvider, provideFrameworkSingleton } from '@webpieces/core-context';\nimport { TASK_PROXY_CLIENT_PROVIDER, TaskProxyClient } from './TaskProxyClient';\nimport { ApiPrototype, TaskClientConfig } from './TaskClientConfig';\n\n// Teach the container how to hand out fresh TaskProxyClients. TaskProxyClient is bound TRANSIENT\n// (@provideFrameworkTransient), so each provider.get() constructs a new one.\nbindFrameworkProvider(TASK_PROXY_CLIENT_PROVIDER, TaskProxyClient);\n\n/**\n * Properties DI frameworks / Promise checks / serializers probe on the proxy; return\n * undefined instead of treating them as endpoints. Mirrors http-client-core's buildClientProxy.\n */\nconst FRAMEWORK_INSPECTION_PROPERTIES = new Set<string>([\n 'constructor', 'prototype', '__proto__', 'name', 'then', 'catch', 'finally',\n 'toJSON', 'valueOf', 'toString', 'nodeType', 'tagName', '$$typeof',\n]);\n\n/**\n * ClientCloudTasksFactory - builds Cloud Tasks enqueue clients from a shared @PubSub API\n * contract. The fire-and-forget twin of http-client-node's ClientHttpFactory.\n *\n * Calling a method on the returned client ENQUEUES a task (it does not call remotely); the task\n * is later delivered to the same endpoint's controller through the full server filter chain.\n *\n * ```typescript\n * // same project + region as this container; the URL is derived, you maintain nothing\n * const emailTasks = factory.createPubSubClient(EmailApi, new TaskClientConfig('email-svc'));\n * await scheduler.addToQueue(() => emailTasks.sendEmail(req), { dedupName });\n * ```\n *\n * Every client it builds gets its OWN {@link TaskProxyClient} from the injected\n * `Provider<TaskProxyClient>` (bound transient), which `createPubSubClient` then `init`s for one\n * contract. Their collaborators (TaskInvoker, RequestContextHeaders) come from the container.\n *\n * Node-only, so the factory IS the inversify entry point. An enqueue outside\n * `RequestContext.run(...)` throws rather than silently dropping the caller's trace.\n */\n@DocumentDesign()\n@provideFrameworkSingleton()\nexport class ClientCloudTasksFactory {\n constructor(\n @inject(TASK_PROXY_CLIENT_PROVIDER) private readonly taskProxyClientProvider: Provider<TaskProxyClient>,\n ) {}\n\n /** Typed enqueue client for a @PubSub contract, delivered to `config.svcName`. */\n createPubSubClient<T extends object>(apiClass: ApiPrototype<T>, config: TaskClientConfig): T {\n // Fresh instance per contract — TaskProxyClient is transient.\n const proxyClient = this.taskProxyClientProvider.get();\n proxyClient.init(apiClass, config);\n const apiName = apiClass.name || 'UnknownApi';\n\n return new Proxy({} as T, {\n // webpieces-disable no-any-unknown -- proxy get trap returns either an endpoint method or undefined\n get(target: T, prop: string | symbol): unknown {\n if (typeof prop !== 'string' || FRAMEWORK_INSPECTION_PROPERTIES.has(prop)) {\n return undefined;\n }\n if (!proxyClient.hasEndpoint(prop)) {\n throw new Error(\n `No @PubSub endpoint '${prop}' on ${apiClass.name || 'Unknown'}. ` +\n `Check for typos or a missing @Endpoint() decorator.`,\n );\n }\n // Same call-site capture as the HTTP clients (#1175): synchronous, trimmed to the\n // caller, parked on the SAME rejected instance.\n // webpieces-disable no-any-unknown -- request DTO type is erased at the proxy layer\n const clientMethod = (requestDto: unknown): Promise<void> => {\n const callSite = ApiCallSite.capture(apiName, prop, clientMethod);\n // webpieces-disable no-any-unknown -- a rejection value is genuinely unknown\n return proxyClient.enqueue(prop, requestDto).catch((failure: unknown) => {\n callSite.attachTo(failure);\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- rethrows the caller's own failure unchanged\n throw failure;\n });\n };\n return clientMethod;\n },\n });\n }\n}\n"]}
1
+ {"version":3,"file":"ClientCloudTasksFactory.js","sourceRoot":"","sources":["../../../../../packages/cloud/cloudtasks-client/src/ClientCloudTasksFactory.ts"],"names":[],"mappings":";;;;AAAA,yCAAmC;AACnC,oDAAmE;AACnE,0DAAqG;AACrG,uDAAgF;AAGhF,iGAAiG;AACjG,6EAA6E;AAC7E,IAAA,oCAAqB,EAAC,4CAA0B,EAAE,iCAAe,CAAC,CAAC;AAEnE;;;GAGG;AACH,MAAM,+BAA+B,GAAG,IAAI,GAAG,CAAS;IACpD,aAAa,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS;IAC3E,QAAQ,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,EAAE,SAAS,EAAE,UAAU;CACrE,CAAC,CAAC;AAEH;;;;;;;;;;;;;;;;;;;GAmBG;AAGI,IAAM,uBAAuB,GAA7B,MAAM,uBAAuB;IAEyB;IADzD,YACyD,uBAAkD;QAAlD,4BAAuB,GAAvB,uBAAuB,CAA2B;IACxG,CAAC;IAEJ,kFAAkF;IAClF,kBAAkB,CAAmB,QAAyB,EAAE,MAAwB;QACpF,8DAA8D;QAC9D,MAAM,WAAW,GAAG,IAAI,CAAC,uBAAuB,CAAC,GAAG,EAAE,CAAC;QACvD,WAAW,CAAC,IAAI,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;QACnC,MAAM,OAAO,GAAG,QAAQ,CAAC,IAAI,IAAI,YAAY,CAAC;QAE9C,OAAO,IAAI,KAAK,CAAC,EAAO,EAAE;YACtB,oGAAoG;YACpG,GAAG,CAAC,MAAS,EAAE,IAAqB;gBAChC,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,+BAA+B,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC;oBACxE,OAAO,SAAS,CAAC;gBACrB,CAAC;gBACD,IAAI,CAAC,WAAW,CAAC,WAAW,CAAC,IAAI,CAAC,EAAE,CAAC;oBACjC,MAAM,IAAI,KAAK,CACX,wBAAwB,IAAI,QAAQ,QAAQ,CAAC,IAAI,IAAI,SAAS,IAAI;wBAClE,qDAAqD,CACxD,CAAC;gBACN,CAAC;gBACD,kFAAkF;gBAClF,gDAAgD;gBAChD,oFAAoF;gBACpF,MAAM,YAAY,GAAG,CAAC,UAAmB,EAAiB,EAAE;oBACxD,MAAM,QAAQ,GAAG,uBAAW,CAAC,OAAO,CAAC,OAAO,EAAE,IAAI,EAAE,YAAY,CAAC,CAAC;oBAClE,6EAA6E;oBAC7E,OAAO,WAAW,CAAC,OAAO,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC,KAAK,CAAC,CAAC,OAAgB,EAAE,EAAE;wBACpE,QAAQ,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;wBAC3B,6GAA6G;wBAC7G,MAAM,OAAO,CAAC;oBAClB,CAAC,CAAC,CAAC;gBACP,CAAC,CAAC;gBACF,OAAO,YAAY,CAAC;YACxB,CAAC;SACJ,CAAC,CAAC;IACP,CAAC;CACJ,CAAA;AAxCY,0DAAuB;kCAAvB,uBAAuB;IAFnC,IAAA,0BAAc,GAAE;IAChB,IAAA,wCAAyB,GAAE;IAGnB,mBAAA,IAAA,kBAAM,EAAC,4CAA0B,CAAC,CAAA;6CAA2C,uBAAQ;GAFjF,uBAAuB,CAwCnC","sourcesContent":["import { inject } from 'inversify';\nimport { ApiCallSite, DocumentDesign } from '@webpieces/core-util';\nimport { Provider, bindFrameworkProvider, provideFrameworkSingleton } from '@webpieces/core-context';\nimport { TASK_PROXY_CLIENT_PROVIDER, TaskProxyClient } from './TaskProxyClient';\nimport { ApiPrototype, TaskClientConfig } from './TaskClientConfig';\n\n// Teach the container how to hand out fresh TaskProxyClients. TaskProxyClient is bound TRANSIENT\n// (@provideFrameworkTransient), so each provider.get() constructs a new one.\nbindFrameworkProvider(TASK_PROXY_CLIENT_PROVIDER, TaskProxyClient);\n\n/**\n * Properties DI frameworks / Promise checks / serializers probe on the proxy; return\n * undefined instead of treating them as endpoints. Mirrors http-client-core's buildClientProxy.\n */\nconst FRAMEWORK_INSPECTION_PROPERTIES = new Set<string>([\n 'constructor', 'prototype', '__proto__', 'name', 'then', 'catch', 'finally',\n 'toJSON', 'valueOf', 'toString', 'nodeType', 'tagName', '$$typeof',\n]);\n\n/**\n * ClientCloudTasksFactory - builds Cloud Tasks enqueue clients from a shared @PubSub API\n * contract. The fire-and-forget twin of http-client-node's ClientHttpFactory.\n *\n * Calling a method on the returned client ENQUEUES a task (it does not call remotely); the task\n * is later delivered to the same endpoint's controller through the full server filter chain.\n *\n * ```typescript\n * // same project + region as this container; the URL is derived, you maintain nothing\n * const emailTasks = factory.createPubSubClient(EmailApi, new TaskClientConfig('email-svc'));\n * await scheduler.addToQueue(() => emailTasks.sendEmail(req), new ScheduleOptions(TaskName.dedup(id)));\n * ```\n *\n * Every client it builds gets its OWN {@link TaskProxyClient} from the injected\n * `Provider<TaskProxyClient>` (bound transient), which `createPubSubClient` then `init`s for one\n * contract. Their collaborators (TaskInvoker, RequestContextHeaders) come from the container.\n *\n * Node-only, so the factory IS the inversify entry point. An enqueue outside\n * `RequestContext.run(...)` throws rather than silently dropping the caller's trace.\n */\n@DocumentDesign()\n@provideFrameworkSingleton()\nexport class ClientCloudTasksFactory {\n constructor(\n @inject(TASK_PROXY_CLIENT_PROVIDER) private readonly taskProxyClientProvider: Provider<TaskProxyClient>,\n ) {}\n\n /** Typed enqueue client for a @PubSub contract, delivered to `config.svcName`. */\n createPubSubClient<T extends object>(apiClass: ApiPrototype<T>, config: TaskClientConfig): T {\n // Fresh instance per contract — TaskProxyClient is transient.\n const proxyClient = this.taskProxyClientProvider.get();\n proxyClient.init(apiClass, config);\n const apiName = apiClass.name || 'UnknownApi';\n\n return new Proxy({} as T, {\n // webpieces-disable no-any-unknown -- proxy get trap returns either an endpoint method or undefined\n get(target: T, prop: string | symbol): unknown {\n if (typeof prop !== 'string' || FRAMEWORK_INSPECTION_PROPERTIES.has(prop)) {\n return undefined;\n }\n if (!proxyClient.hasEndpoint(prop)) {\n throw new Error(\n `No @PubSub endpoint '${prop}' on ${apiClass.name || 'Unknown'}. ` +\n `Check for typos or a missing @Endpoint() decorator.`,\n );\n }\n // Same call-site capture as the HTTP clients (#1175): synchronous, trimmed to the\n // caller, parked on the SAME rejected instance.\n // webpieces-disable no-any-unknown -- request DTO type is erased at the proxy layer\n const clientMethod = (requestDto: unknown): Promise<void> => {\n const callSite = ApiCallSite.capture(apiName, prop, clientMethod);\n // webpieces-disable no-any-unknown -- a rejection value is genuinely unknown\n return proxyClient.enqueue(prop, requestDto).catch((failure: unknown) => {\n callSite.attachTo(failure);\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- rethrows the caller's own failure unchanged\n throw failure;\n });\n };\n return clientMethod;\n },\n });\n }\n}\n"]}
@@ -1,23 +1,45 @@
1
- import { TaskInvoker, JobReference } from './TaskTypes';
1
+ import type { CallOptions } from 'google-gax';
2
+ import { TaskInvoker, JobReference, TaskCustomizer } from './TaskTypes';
3
+ import { TaskName } from './TaskName';
2
4
  /**
3
- * Options for a scheduled task. These are QUEUE/TRANSPORT knobs (dedup, deadline)
4
- * that belong on the enqueue CALL, never on the shared API method — the controller
5
- * implementing that method has no use for them. See CloudTaskScheduler's class doc
6
- * for the full rationale.
5
+ * Options for a scheduled task. These are QUEUE/TRANSPORT knobs (naming, deadline, the Google
6
+ * request, gax) that belong on the enqueue CALL, never on the shared API method — the controller
7
+ * implementing that method has no use for them. See CloudTaskScheduler's class doc for the full
8
+ * rationale.
9
+ *
10
+ * Three levels, three owners:
11
+ * - the `CloudTasksClient` (via an overridden `CloudTasksClientProvider`): the channel and client
12
+ * defaults (keepalive, endpoint, clientConfig);
13
+ * - `gax` here, or `GcpTaskRegistry.setGax(...)`: how the ONE `createTask` RPC of this enqueue
14
+ * behaves. Set ⇒ YOU own timing: no webpieces retries, one attempt, under a safety cap;
15
+ * - `customizeTask` here, or `GcpTaskRegistry.setCustomizer(...)`: the Google request itself.
7
16
  */
8
17
  export declare class ScheduleOptions {
9
- /** Deterministic Cloud Task name → idempotent dedup (duplicate = ALREADY_EXISTS). */
10
- dedupName?: string;
11
- /** Per-task dispatch deadline in seconds. */
12
- taskTimeoutSeconds?: number;
13
- constructor(dedupName?: string, taskTimeoutSeconds?: number);
18
+ /** How the task is named. Default: one generated UUID per logical enqueue, reused on retry. */
19
+ readonly taskName: TaskName;
20
+ /** Per-task dispatch deadline in seconds (`task.dispatchDeadline`). */
21
+ readonly taskTimeoutSeconds?: number | undefined;
22
+ /** Changes the Google request on every attempt; NEVER gax. See {@link TaskCustomizer}. */
23
+ readonly customizeTask?: TaskCustomizer | undefined;
24
+ /** gax `CallOptions` for `createTask`. Set ⇒ you own timing/retries for this enqueue. */
25
+ readonly gax?: CallOptions | undefined;
26
+ constructor(
27
+ /** How the task is named. Default: one generated UUID per logical enqueue, reused on retry. */
28
+ taskName?: TaskName,
29
+ /** Per-task dispatch deadline in seconds (`task.dispatchDeadline`). */
30
+ taskTimeoutSeconds?: number | undefined,
31
+ /** Changes the Google request on every attempt; NEVER gax. See {@link TaskCustomizer}. */
32
+ customizeTask?: TaskCustomizer | undefined,
33
+ /** gax `CallOptions` for `createTask`. Set ⇒ you own timing/retries for this enqueue. */
34
+ gax?: CallOptions | undefined);
14
35
  }
15
36
  /**
16
37
  * Schedules Cloud Tasks. You wrap an enqueue-client call in a lambda so the shared
17
38
  * contract's method signature stays `foo(req)` on both client and controller; the
18
39
  * scheduling options ride out-of-band in the schedule frame:
19
40
  *
20
- * await scheduler.addToQueue(() => taskClient.sendEmail(req), { dedupName: id });
41
+ * await scheduler.addToQueue(() => taskClient.sendEmail(req));
42
+ * await scheduler.addToQueue(() => taskClient.sendEmail(req), new ScheduleOptions(TaskName.dedup(id), 900));
21
43
  * await scheduler.schedule(() => taskClient.sendEmail(req), runAtEpochMs);
22
44
  *
23
45
  * Must be called inside an active RequestContext (a server request scope).
@@ -25,12 +47,12 @@ export declare class ScheduleOptions {
25
47
  * WHY scheduling options are here and NOT parameters on the API method
26
48
  * ------------------------------------------------------------------------
27
49
  * The enqueue client and the controller implement the SAME abstract API method
28
- * (`sendEmail(req): Promise<void>`). Scheduling options — `dedupName`, "run in the
50
+ * (`sendEmail(req): Promise<void>`). Scheduling options — the task name, "run in the
29
51
  * future", `taskTimeoutSeconds` — are QUEUE/TRANSPORT concerns, not part of the
30
52
  * business contract, so they must not appear on the method signature:
31
53
  *
32
54
  * - They are meaningless on the SERVER side. The controller's `sendEmail` just does
33
- * the work (send the email). It has no use for `dedupName` (a Cloud Tasks resource
55
+ * the work (send the email). It has no use for a task name (a Cloud Tasks resource
34
56
  * name), a future run-time (Cloud Tasks already delivered it), or a dispatch
35
57
  * deadline. Putting them on the method would force the controller to accept and
36
58
  * ignore transport metadata it can't act on.
@@ -56,5 +78,7 @@ export declare class CloudTaskScheduler {
56
78
  schedule(runnable: () => Promise<void>, epochMsToRunAt: number, opts?: ScheduleOptions): Promise<JobReference>;
57
79
  /** Cancel a previously scheduled task. */
58
80
  cancelJob(ref: JobReference): Promise<void>;
81
+ /** No options = a generated name, no deadline, no customizer, webpieces-owned timing. */
82
+ private infoFor;
59
83
  private runWithFrame;
60
84
  }
@@ -7,22 +7,40 @@ const core_context_1 = require("@webpieces/core-context");
7
7
  const core_context_2 = require("@webpieces/core-context");
8
8
  const core_util_1 = require("@webpieces/core-util");
9
9
  const TaskTypes_1 = require("./TaskTypes");
10
+ const TaskName_1 = require("./TaskName");
10
11
  const ScheduleContext_1 = require("./ScheduleContext");
11
12
  const log = core_util_1.LogManager.getLogger('CloudTaskScheduler');
12
13
  /**
13
- * Options for a scheduled task. These are QUEUE/TRANSPORT knobs (dedup, deadline)
14
- * that belong on the enqueue CALL, never on the shared API method — the controller
15
- * implementing that method has no use for them. See CloudTaskScheduler's class doc
16
- * for the full rationale.
14
+ * Options for a scheduled task. These are QUEUE/TRANSPORT knobs (naming, deadline, the Google
15
+ * request, gax) that belong on the enqueue CALL, never on the shared API method — the controller
16
+ * implementing that method has no use for them. See CloudTaskScheduler's class doc for the full
17
+ * rationale.
18
+ *
19
+ * Three levels, three owners:
20
+ * - the `CloudTasksClient` (via an overridden `CloudTasksClientProvider`): the channel and client
21
+ * defaults (keepalive, endpoint, clientConfig);
22
+ * - `gax` here, or `GcpTaskRegistry.setGax(...)`: how the ONE `createTask` RPC of this enqueue
23
+ * behaves. Set ⇒ YOU own timing: no webpieces retries, one attempt, under a safety cap;
24
+ * - `customizeTask` here, or `GcpTaskRegistry.setCustomizer(...)`: the Google request itself.
17
25
  */
18
26
  class ScheduleOptions {
19
- /** Deterministic Cloud Task name → idempotent dedup (duplicate = ALREADY_EXISTS). */
20
- dedupName;
21
- /** Per-task dispatch deadline in seconds. */
27
+ taskName;
22
28
  taskTimeoutSeconds;
23
- constructor(dedupName, taskTimeoutSeconds) {
24
- this.dedupName = dedupName;
29
+ customizeTask;
30
+ gax;
31
+ constructor(
32
+ /** How the task is named. Default: one generated UUID per logical enqueue, reused on retry. */
33
+ taskName = TaskName_1.TaskName.generated(),
34
+ /** Per-task dispatch deadline in seconds (`task.dispatchDeadline`). */
35
+ taskTimeoutSeconds,
36
+ /** Changes the Google request on every attempt; NEVER gax. See {@link TaskCustomizer}. */
37
+ customizeTask,
38
+ /** gax `CallOptions` for `createTask`. Set ⇒ you own timing/retries for this enqueue. */
39
+ gax) {
40
+ this.taskName = taskName;
25
41
  this.taskTimeoutSeconds = taskTimeoutSeconds;
42
+ this.customizeTask = customizeTask;
43
+ this.gax = gax;
26
44
  }
27
45
  }
28
46
  exports.ScheduleOptions = ScheduleOptions;
@@ -31,7 +49,8 @@ exports.ScheduleOptions = ScheduleOptions;
31
49
  * contract's method signature stays `foo(req)` on both client and controller; the
32
50
  * scheduling options ride out-of-band in the schedule frame:
33
51
  *
34
- * await scheduler.addToQueue(() => taskClient.sendEmail(req), { dedupName: id });
52
+ * await scheduler.addToQueue(() => taskClient.sendEmail(req));
53
+ * await scheduler.addToQueue(() => taskClient.sendEmail(req), new ScheduleOptions(TaskName.dedup(id), 900));
35
54
  * await scheduler.schedule(() => taskClient.sendEmail(req), runAtEpochMs);
36
55
  *
37
56
  * Must be called inside an active RequestContext (a server request scope).
@@ -39,12 +58,12 @@ exports.ScheduleOptions = ScheduleOptions;
39
58
  * WHY scheduling options are here and NOT parameters on the API method
40
59
  * ------------------------------------------------------------------------
41
60
  * The enqueue client and the controller implement the SAME abstract API method
42
- * (`sendEmail(req): Promise<void>`). Scheduling options — `dedupName`, "run in the
61
+ * (`sendEmail(req): Promise<void>`). Scheduling options — the task name, "run in the
43
62
  * future", `taskTimeoutSeconds` — are QUEUE/TRANSPORT concerns, not part of the
44
63
  * business contract, so they must not appear on the method signature:
45
64
  *
46
65
  * - They are meaningless on the SERVER side. The controller's `sendEmail` just does
47
- * the work (send the email). It has no use for `dedupName` (a Cloud Tasks resource
66
+ * the work (send the email). It has no use for a task name (a Cloud Tasks resource
48
67
  * name), a future run-time (Cloud Tasks already delivered it), or a dispatch
49
68
  * deadline. Putting them on the method would force the controller to accept and
50
69
  * ignore transport metadata it can't act on.
@@ -68,16 +87,21 @@ let CloudTaskScheduler = class CloudTaskScheduler {
68
87
  }
69
88
  /** Enqueue a task to run as soon as possible. Returns its JobReference. */
70
89
  async addToQueue(runnable, opts) {
71
- return this.runWithFrame(new TaskTypes_1.ScheduleInfo(undefined, opts?.taskTimeoutSeconds, opts?.dedupName), runnable);
90
+ return this.runWithFrame(this.infoFor(undefined, opts), runnable);
72
91
  }
73
92
  /** Enqueue a task to run at an absolute epoch-millis time. Returns its JobReference. */
74
93
  async schedule(runnable, epochMsToRunAt, opts) {
75
- return this.runWithFrame(new TaskTypes_1.ScheduleInfo(epochMsToRunAt, opts?.taskTimeoutSeconds, opts?.dedupName), runnable);
94
+ return this.runWithFrame(this.infoFor(epochMsToRunAt, opts), runnable);
76
95
  }
77
96
  /** Cancel a previously scheduled task. */
78
97
  async cancelJob(ref) {
79
98
  await this.invoker.delete(ref);
80
99
  }
100
+ /** No options = a generated name, no deadline, no customizer, webpieces-owned timing. */
101
+ infoFor(epochMsToRunAt, opts) {
102
+ const options = opts ?? new ScheduleOptions();
103
+ return new TaskTypes_1.ScheduleInfo(options.taskName, epochMsToRunAt, options.taskTimeoutSeconds, options.customizeTask, options.gax);
104
+ }
81
105
  async runWithFrame(info, runnable) {
82
106
  if (!core_context_2.RequestContext.isActive()) {
83
107
  throw new Error('CloudTaskScheduler must run inside a RequestContext (a server request scope).');
@@ -1 +1 @@
1
- {"version":3,"file":"CloudTaskScheduler.js","sourceRoot":"","sources":["../../../../../packages/cloud/cloudtasks-client/src/CloudTaskScheduler.ts"],"names":[],"mappings":";;;;AAAA,yCAAmC;AACnC,0DAAoE;AACpE,0DAAyD;AACzD,oDAAkD;AAClD,2CAAsE;AACtE,uDAAwF;AAExF,MAAM,GAAG,GAAG,sBAAU,CAAC,SAAS,CAAC,oBAAoB,CAAC,CAAC;AAEvD;;;;;GAKG;AACH,MAAa,eAAe;IACxB,qFAAqF;IACrF,SAAS,CAAU;IACnB,6CAA6C;IAC7C,kBAAkB,CAAU;IAE5B,YAAY,SAAkB,EAAE,kBAA2B;QACvD,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;QAC3B,IAAI,CAAC,kBAAkB,GAAG,kBAAkB,CAAC;IACjD,CAAC;CACJ;AAVD,0CAUC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAEI,IAAM,kBAAkB,GAAxB,MAAM,kBAAkB;IAEe;IAD1C,YAC0C,OAAoB;QAApB,YAAO,GAAP,OAAO,CAAa;IAC3D,CAAC;IAEJ,2EAA2E;IAC3E,KAAK,CAAC,UAAU,CAAC,QAA6B,EAAE,IAAsB;QAClE,OAAO,IAAI,CAAC,YAAY,CACpB,IAAI,wBAAY,CAAC,SAAS,EAAE,IAAI,EAAE,kBAAkB,EAAE,IAAI,EAAE,SAAS,CAAC,EACtE,QAAQ,CACX,CAAC;IACN,CAAC;IAED,wFAAwF;IACxF,KAAK,CAAC,QAAQ,CACV,QAA6B,EAC7B,cAAsB,EACtB,IAAsB;QAEtB,OAAO,IAAI,CAAC,YAAY,CACpB,IAAI,wBAAY,CAAC,cAAc,EAAE,IAAI,EAAE,kBAAkB,EAAE,IAAI,EAAE,SAAS,CAAC,EAC3E,QAAQ,CACX,CAAC;IACN,CAAC;IAED,0CAA0C;IAC1C,KAAK,CAAC,SAAS,CAAC,GAAiB;QAC7B,MAAM,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IACnC,CAAC;IAEO,KAAK,CAAC,YAAY,CACtB,IAAkB,EAClB,QAA6B;QAE7B,IAAI,CAAC,6BAAc,CAAC,QAAQ,EAAE,EAAE,CAAC;YAC7B,MAAM,IAAI,KAAK,CACX,+EAA+E,CAClF,CAAC;QACN,CAAC;QACD,MAAM,KAAK,GAAG,IAAI,+BAAa,CAAC,IAAI,CAAC,CAAC;QACtC,IAAA,kCAAgB,EAAC,KAAK,CAAC,CAAC;QACxB,mIAAmI;QACnI,IAAI,CAAC;YACD,MAAM,QAAQ,EAAE,CAAC;QACrB,CAAC;gBAAS,CAAC;YACP,IAAA,oCAAkB,GAAE,CAAC;QACzB,CAAC;QACD,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC;YAChB,MAAM,IAAI,KAAK,CACX,4EAA4E;gBAC5E,oCAAoC,CACvC,CAAC;QACN,CAAC;QACD,GAAG,CAAC,KAAK,CAAC,kBAAkB,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC;QACnD,OAAO,KAAK,CAAC,MAAM,CAAC;IACxB,CAAC;CACJ,CAAA;AAxDY,gDAAkB;6BAAlB,kBAAkB;IAD9B,IAAA,wCAAyB,GAAE;IAGnB,mBAAA,IAAA,kBAAM,EAAC,uBAAW,CAAC,CAAA;6CAA2B,uBAAW;GAFrD,kBAAkB,CAwD9B","sourcesContent":["import { inject } from 'inversify';\nimport { provideFrameworkSingleton } from '@webpieces/core-context';\nimport { RequestContext } from '@webpieces/core-context';\nimport { LogManager } from '@webpieces/core-util';\nimport { TaskInvoker, JobReference, ScheduleInfo } from './TaskTypes';\nimport { ScheduleFrame, setScheduleFrame, clearScheduleFrame } from './ScheduleContext';\n\nconst log = LogManager.getLogger('CloudTaskScheduler');\n\n/**\n * Options for a scheduled task. These are QUEUE/TRANSPORT knobs (dedup, deadline)\n * that belong on the enqueue CALL, never on the shared API method — the controller\n * implementing that method has no use for them. See CloudTaskScheduler's class doc\n * for the full rationale.\n */\nexport class ScheduleOptions {\n /** Deterministic Cloud Task name → idempotent dedup (duplicate = ALREADY_EXISTS). */\n dedupName?: string;\n /** Per-task dispatch deadline in seconds. */\n taskTimeoutSeconds?: number;\n\n constructor(dedupName?: string, taskTimeoutSeconds?: number) {\n this.dedupName = dedupName;\n this.taskTimeoutSeconds = taskTimeoutSeconds;\n }\n}\n\n/**\n * Schedules Cloud Tasks. You wrap an enqueue-client call in a lambda so the shared\n * contract's method signature stays `foo(req)` on both client and controller; the\n * scheduling options ride out-of-band in the schedule frame:\n *\n * await scheduler.addToQueue(() => taskClient.sendEmail(req), { dedupName: id });\n * await scheduler.schedule(() => taskClient.sendEmail(req), runAtEpochMs);\n *\n * Must be called inside an active RequestContext (a server request scope).\n *\n * WHY scheduling options are here and NOT parameters on the API method\n * ------------------------------------------------------------------------\n * The enqueue client and the controller implement the SAME abstract API method\n * (`sendEmail(req): Promise<void>`). Scheduling options — `dedupName`, \"run in the\n * future\", `taskTimeoutSeconds` — are QUEUE/TRANSPORT concerns, not part of the\n * business contract, so they must not appear on the method signature:\n *\n * - They are meaningless on the SERVER side. The controller's `sendEmail` just does\n * the work (send the email). It has no use for `dedupName` (a Cloud Tasks resource\n * name), a future run-time (Cloud Tasks already delivered it), or a dispatch\n * deadline. Putting them on the method would force the controller to accept and\n * ignore transport metadata it can't act on.\n * - They vary per CALL SITE, not per method. The same `sendEmail` might be enqueued\n * plain in one place, deduped in another, and scheduled for later in a third. That\n * is a property of THIS enqueue, not of the API — so it belongs on the enqueue\n * call (the scheduler), exactly like Cloud Tasks itself separates the task's\n * schedule/dedup/deadline from the HTTP body it delivers.\n * - Symmetry with RPC: an http-client RPC call is `foo(req)` with no transport knobs\n * in the contract; the async (queue) client keeps that same clean contract and\n * moves the queue knobs to the scheduler wrapper.\n *\n * So the rule is: the API method is `method(req)` on BOTH sides forever; anything\n * queue-shaped (dedup, delay, timeout, cancel) lives on CloudTaskScheduler /\n * ScheduleOptions here, never on the API.\n */\n@provideFrameworkSingleton()\nexport class CloudTaskScheduler {\n constructor(\n @inject(TaskInvoker) private readonly invoker: TaskInvoker,\n ) {}\n\n /** Enqueue a task to run as soon as possible. Returns its JobReference. */\n async addToQueue(runnable: () => Promise<void>, opts?: ScheduleOptions): Promise<JobReference> {\n return this.runWithFrame(\n new ScheduleInfo(undefined, opts?.taskTimeoutSeconds, opts?.dedupName),\n runnable,\n );\n }\n\n /** Enqueue a task to run at an absolute epoch-millis time. Returns its JobReference. */\n async schedule(\n runnable: () => Promise<void>,\n epochMsToRunAt: number,\n opts?: ScheduleOptions,\n ): Promise<JobReference> {\n return this.runWithFrame(\n new ScheduleInfo(epochMsToRunAt, opts?.taskTimeoutSeconds, opts?.dedupName),\n runnable,\n );\n }\n\n /** Cancel a previously scheduled task. */\n async cancelJob(ref: JobReference): Promise<void> {\n await this.invoker.delete(ref);\n }\n\n private async runWithFrame(\n info: ScheduleInfo,\n runnable: () => Promise<void>,\n ): Promise<JobReference> {\n if (!RequestContext.isActive()) {\n throw new Error(\n 'CloudTaskScheduler must run inside a RequestContext (a server request scope).',\n );\n }\n const frame = new ScheduleFrame(info);\n setScheduleFrame(frame);\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- always clear the frame; the runnable's error propagates unchanged\n try {\n await runnable();\n } finally {\n clearScheduleFrame();\n }\n if (!frame.jobRef) {\n throw new Error(\n 'CloudTaskScheduler runnable did not enqueue a task — the lambda must call ' +\n 'a task-client method exactly once.',\n );\n }\n log.debug(`scheduled task ${frame.jobRef.taskId}`);\n return frame.jobRef;\n }\n}\n"]}
1
+ {"version":3,"file":"CloudTaskScheduler.js","sourceRoot":"","sources":["../../../../../packages/cloud/cloudtasks-client/src/CloudTaskScheduler.ts"],"names":[],"mappings":";;;;AAAA,yCAAmC;AACnC,0DAAoE;AACpE,0DAAyD;AACzD,oDAAkD;AAElD,2CAAsF;AACtF,yCAAsC;AACtC,uDAAwF;AAExF,MAAM,GAAG,GAAG,sBAAU,CAAC,SAAS,CAAC,oBAAoB,CAAC,CAAC;AAEvD;;;;;;;;;;;;GAYG;AACH,MAAa,eAAe;IAGX;IAEA;IAEA;IAEA;IARb;IACI,+FAA+F;IACtF,WAAqB,mBAAQ,CAAC,SAAS,EAAE;IAClD,uEAAuE;IAC9D,kBAA2B;IACpC,0FAA0F;IACjF,aAA8B;IACvC,yFAAyF;IAChF,GAAiB;QANjB,aAAQ,GAAR,QAAQ,CAAiC;QAEzC,uBAAkB,GAAlB,kBAAkB,CAAS;QAE3B,kBAAa,GAAb,aAAa,CAAiB;QAE9B,QAAG,GAAH,GAAG,CAAc;IAC3B,CAAC;CACP;AAXD,0CAWC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEI,IAAM,kBAAkB,GAAxB,MAAM,kBAAkB;IAEe;IAD1C,YAC0C,OAAoB;QAApB,YAAO,GAAP,OAAO,CAAa;IAC3D,CAAC;IAEJ,2EAA2E;IAC3E,KAAK,CAAC,UAAU,CAAC,QAA6B,EAAE,IAAsB;QAClE,OAAO,IAAI,CAAC,YAAY,CACpB,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,IAAI,CAAC,EAC7B,QAAQ,CACX,CAAC;IACN,CAAC;IAED,wFAAwF;IACxF,KAAK,CAAC,QAAQ,CACV,QAA6B,EAC7B,cAAsB,EACtB,IAAsB;QAEtB,OAAO,IAAI,CAAC,YAAY,CACpB,IAAI,CAAC,OAAO,CAAC,cAAc,EAAE,IAAI,CAAC,EAClC,QAAQ,CACX,CAAC;IACN,CAAC;IAED,0CAA0C;IAC1C,KAAK,CAAC,SAAS,CAAC,GAAiB;QAC7B,MAAM,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IACnC,CAAC;IAED,yFAAyF;IACjF,OAAO,CAAC,cAAkC,EAAE,IAAiC;QACjF,MAAM,OAAO,GAAG,IAAI,IAAI,IAAI,eAAe,EAAE,CAAC;QAC9C,OAAO,IAAI,wBAAY,CACnB,OAAO,CAAC,QAAQ,EAChB,cAAc,EACd,OAAO,CAAC,kBAAkB,EAC1B,OAAO,CAAC,aAAa,EACrB,OAAO,CAAC,GAAG,CACd,CAAC;IACN,CAAC;IAEO,KAAK,CAAC,YAAY,CACtB,IAAkB,EAClB,QAA6B;QAE7B,IAAI,CAAC,6BAAc,CAAC,QAAQ,EAAE,EAAE,CAAC;YAC7B,MAAM,IAAI,KAAK,CACX,+EAA+E,CAClF,CAAC;QACN,CAAC;QACD,MAAM,KAAK,GAAG,IAAI,+BAAa,CAAC,IAAI,CAAC,CAAC;QACtC,IAAA,kCAAgB,EAAC,KAAK,CAAC,CAAC;QACxB,mIAAmI;QACnI,IAAI,CAAC;YACD,MAAM,QAAQ,EAAE,CAAC;QACrB,CAAC;gBAAS,CAAC;YACP,IAAA,oCAAkB,GAAE,CAAC;QACzB,CAAC;QACD,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC;YAChB,MAAM,IAAI,KAAK,CACX,4EAA4E;gBAC5E,oCAAoC,CACvC,CAAC;QACN,CAAC;QACD,GAAG,CAAC,KAAK,CAAC,kBAAkB,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC;QACnD,OAAO,KAAK,CAAC,MAAM,CAAC;IACxB,CAAC;CACJ,CAAA;AApEY,gDAAkB;6BAAlB,kBAAkB;IAD9B,IAAA,wCAAyB,GAAE;IAGnB,mBAAA,IAAA,kBAAM,EAAC,uBAAW,CAAC,CAAA;6CAA2B,uBAAW;GAFrD,kBAAkB,CAoE9B","sourcesContent":["import { inject } from 'inversify';\nimport { provideFrameworkSingleton } from '@webpieces/core-context';\nimport { RequestContext } from '@webpieces/core-context';\nimport { LogManager } from '@webpieces/core-util';\nimport type { CallOptions } from 'google-gax';\nimport { TaskInvoker, JobReference, ScheduleInfo, TaskCustomizer } from './TaskTypes';\nimport { TaskName } from './TaskName';\nimport { ScheduleFrame, setScheduleFrame, clearScheduleFrame } from './ScheduleContext';\n\nconst log = LogManager.getLogger('CloudTaskScheduler');\n\n/**\n * Options for a scheduled task. These are QUEUE/TRANSPORT knobs (naming, deadline, the Google\n * request, gax) that belong on the enqueue CALL, never on the shared API method — the controller\n * implementing that method has no use for them. See CloudTaskScheduler's class doc for the full\n * rationale.\n *\n * Three levels, three owners:\n * - the `CloudTasksClient` (via an overridden `CloudTasksClientProvider`): the channel and client\n * defaults (keepalive, endpoint, clientConfig);\n * - `gax` here, or `GcpTaskRegistry.setGax(...)`: how the ONE `createTask` RPC of this enqueue\n * behaves. Set ⇒ YOU own timing: no webpieces retries, one attempt, under a safety cap;\n * - `customizeTask` here, or `GcpTaskRegistry.setCustomizer(...)`: the Google request itself.\n */\nexport class ScheduleOptions {\n constructor(\n /** How the task is named. Default: one generated UUID per logical enqueue, reused on retry. */\n readonly taskName: TaskName = TaskName.generated(),\n /** Per-task dispatch deadline in seconds (`task.dispatchDeadline`). */\n readonly taskTimeoutSeconds?: number,\n /** Changes the Google request on every attempt; NEVER gax. See {@link TaskCustomizer}. */\n readonly customizeTask?: TaskCustomizer,\n /** gax `CallOptions` for `createTask`. Set ⇒ you own timing/retries for this enqueue. */\n readonly gax?: CallOptions,\n ) {}\n}\n\n/**\n * Schedules Cloud Tasks. You wrap an enqueue-client call in a lambda so the shared\n * contract's method signature stays `foo(req)` on both client and controller; the\n * scheduling options ride out-of-band in the schedule frame:\n *\n * await scheduler.addToQueue(() => taskClient.sendEmail(req));\n * await scheduler.addToQueue(() => taskClient.sendEmail(req), new ScheduleOptions(TaskName.dedup(id), 900));\n * await scheduler.schedule(() => taskClient.sendEmail(req), runAtEpochMs);\n *\n * Must be called inside an active RequestContext (a server request scope).\n *\n * WHY scheduling options are here and NOT parameters on the API method\n * ------------------------------------------------------------------------\n * The enqueue client and the controller implement the SAME abstract API method\n * (`sendEmail(req): Promise<void>`). Scheduling options — the task name, \"run in the\n * future\", `taskTimeoutSeconds` — are QUEUE/TRANSPORT concerns, not part of the\n * business contract, so they must not appear on the method signature:\n *\n * - They are meaningless on the SERVER side. The controller's `sendEmail` just does\n * the work (send the email). It has no use for a task name (a Cloud Tasks resource\n * name), a future run-time (Cloud Tasks already delivered it), or a dispatch\n * deadline. Putting them on the method would force the controller to accept and\n * ignore transport metadata it can't act on.\n * - They vary per CALL SITE, not per method. The same `sendEmail` might be enqueued\n * plain in one place, deduped in another, and scheduled for later in a third. That\n * is a property of THIS enqueue, not of the API — so it belongs on the enqueue\n * call (the scheduler), exactly like Cloud Tasks itself separates the task's\n * schedule/dedup/deadline from the HTTP body it delivers.\n * - Symmetry with RPC: an http-client RPC call is `foo(req)` with no transport knobs\n * in the contract; the async (queue) client keeps that same clean contract and\n * moves the queue knobs to the scheduler wrapper.\n *\n * So the rule is: the API method is `method(req)` on BOTH sides forever; anything\n * queue-shaped (dedup, delay, timeout, cancel) lives on CloudTaskScheduler /\n * ScheduleOptions here, never on the API.\n */\n@provideFrameworkSingleton()\nexport class CloudTaskScheduler {\n constructor(\n @inject(TaskInvoker) private readonly invoker: TaskInvoker,\n ) {}\n\n /** Enqueue a task to run as soon as possible. Returns its JobReference. */\n async addToQueue(runnable: () => Promise<void>, opts?: ScheduleOptions): Promise<JobReference> {\n return this.runWithFrame(\n this.infoFor(undefined, opts),\n runnable,\n );\n }\n\n /** Enqueue a task to run at an absolute epoch-millis time. Returns its JobReference. */\n async schedule(\n runnable: () => Promise<void>,\n epochMsToRunAt: number,\n opts?: ScheduleOptions,\n ): Promise<JobReference> {\n return this.runWithFrame(\n this.infoFor(epochMsToRunAt, opts),\n runnable,\n );\n }\n\n /** Cancel a previously scheduled task. */\n async cancelJob(ref: JobReference): Promise<void> {\n await this.invoker.delete(ref);\n }\n\n /** No options = a generated name, no deadline, no customizer, webpieces-owned timing. */\n private infoFor(epochMsToRunAt: number | undefined, opts: ScheduleOptions | undefined): ScheduleInfo {\n const options = opts ?? new ScheduleOptions();\n return new ScheduleInfo(\n options.taskName,\n epochMsToRunAt,\n options.taskTimeoutSeconds,\n options.customizeTask,\n options.gax,\n );\n }\n\n private async runWithFrame(\n info: ScheduleInfo,\n runnable: () => Promise<void>,\n ): Promise<JobReference> {\n if (!RequestContext.isActive()) {\n throw new Error(\n 'CloudTaskScheduler must run inside a RequestContext (a server request scope).',\n );\n }\n const frame = new ScheduleFrame(info);\n setScheduleFrame(frame);\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- always clear the frame; the runnable's error propagates unchanged\n try {\n await runnable();\n } finally {\n clearScheduleFrame();\n }\n if (!frame.jobRef) {\n throw new Error(\n 'CloudTaskScheduler runnable did not enqueue a task — the lambda must call ' +\n 'a task-client method exactly once.',\n );\n }\n log.debug(`scheduled task ${frame.jobRef.taskId}`);\n return frame.jobRef;\n }\n}\n"]}
@@ -0,0 +1,29 @@
1
+ import { CloudTasksClient } from '@google-cloud/tasks';
2
+ /**
3
+ * Builds the ONE `CloudTasksClient` that `GcpTaskInvoker` enqueues through — the place for
4
+ * process-wide CLIENT settings: the gRPC channel (keepalive), the endpoint, `clientConfig`.
5
+ *
6
+ * The default builds `new CloudTasksClient()`, exactly what `GcpTaskInvoker` always did, so nobody has
7
+ * to bind anything. An app that needs client-level settings overrides it at startup:
8
+ *
9
+ * ```ts
10
+ * class KeepaliveCloudTasks extends CloudTasksClientProvider {
11
+ * override create(): CloudTasksClient {
12
+ * return new CloudTasksClient({ 'grpc.keepalive_time_ms': 30_000 });
13
+ * }
14
+ * }
15
+ * (await options.rebind(CloudTasksClientProvider)).to(KeepaliveCloudTasks).inSingletonScope();
16
+ * ```
17
+ *
18
+ * Why a provider class and not an optional `CloudTasksClient` binding: the app container is created
19
+ * with inversify `autobind`, which auto-constructs ANY unbound class token — `@optional()` included —
20
+ * and `CloudTasksClient` has no injectable metadata, so resolving it unbound throws
21
+ * ("CloudTasksClient constructor requires at least 2 arguments"). A provider that is always bound
22
+ * keeps the same contract — optional to override, today's client by default — with nothing to throw.
23
+ *
24
+ * Per-CALL settings are not here: gax `CallOptions` belong on `ScheduleOptions.gax` /
25
+ * `GcpTaskRegistry.setGax`, and the Google request on a `TaskCustomizer`.
26
+ */
27
+ export declare class CloudTasksClientProvider {
28
+ create(): CloudTasksClient;
29
+ }
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CloudTasksClientProvider = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const tasks_1 = require("@google-cloud/tasks");
6
+ const core_context_1 = require("@webpieces/core-context");
7
+ /**
8
+ * Builds the ONE `CloudTasksClient` that `GcpTaskInvoker` enqueues through — the place for
9
+ * process-wide CLIENT settings: the gRPC channel (keepalive), the endpoint, `clientConfig`.
10
+ *
11
+ * The default builds `new CloudTasksClient()`, exactly what `GcpTaskInvoker` always did, so nobody has
12
+ * to bind anything. An app that needs client-level settings overrides it at startup:
13
+ *
14
+ * ```ts
15
+ * class KeepaliveCloudTasks extends CloudTasksClientProvider {
16
+ * override create(): CloudTasksClient {
17
+ * return new CloudTasksClient({ 'grpc.keepalive_time_ms': 30_000 });
18
+ * }
19
+ * }
20
+ * (await options.rebind(CloudTasksClientProvider)).to(KeepaliveCloudTasks).inSingletonScope();
21
+ * ```
22
+ *
23
+ * Why a provider class and not an optional `CloudTasksClient` binding: the app container is created
24
+ * with inversify `autobind`, which auto-constructs ANY unbound class token — `@optional()` included —
25
+ * and `CloudTasksClient` has no injectable metadata, so resolving it unbound throws
26
+ * ("CloudTasksClient constructor requires at least 2 arguments"). A provider that is always bound
27
+ * keeps the same contract — optional to override, today's client by default — with nothing to throw.
28
+ *
29
+ * Per-CALL settings are not here: gax `CallOptions` belong on `ScheduleOptions.gax` /
30
+ * `GcpTaskRegistry.setGax`, and the Google request on a `TaskCustomizer`.
31
+ */
32
+ let CloudTasksClientProvider = class CloudTasksClientProvider {
33
+ create() {
34
+ return new tasks_1.CloudTasksClient();
35
+ }
36
+ };
37
+ exports.CloudTasksClientProvider = CloudTasksClientProvider;
38
+ exports.CloudTasksClientProvider = CloudTasksClientProvider = tslib_1.__decorate([
39
+ (0, core_context_1.provideFrameworkSingleton)()
40
+ ], CloudTasksClientProvider);
41
+ //# sourceMappingURL=CloudTasksClientProvider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CloudTasksClientProvider.js","sourceRoot":"","sources":["../../../../../packages/cloud/cloudtasks-client/src/CloudTasksClientProvider.ts"],"names":[],"mappings":";;;;AAAA,+CAAuD;AACvD,0DAAoE;AAEpE;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEI,IAAM,wBAAwB,GAA9B,MAAM,wBAAwB;IACjC,MAAM;QACF,OAAO,IAAI,wBAAgB,EAAE,CAAC;IAClC,CAAC;CACJ,CAAA;AAJY,4DAAwB;mCAAxB,wBAAwB;IADpC,IAAA,wCAAyB,GAAE;GACf,wBAAwB,CAIpC","sourcesContent":["import { CloudTasksClient } from '@google-cloud/tasks';\nimport { provideFrameworkSingleton } from '@webpieces/core-context';\n\n/**\n * Builds the ONE `CloudTasksClient` that `GcpTaskInvoker` enqueues through — the place for\n * process-wide CLIENT settings: the gRPC channel (keepalive), the endpoint, `clientConfig`.\n *\n * The default builds `new CloudTasksClient()`, exactly what `GcpTaskInvoker` always did, so nobody has\n * to bind anything. An app that needs client-level settings overrides it at startup:\n *\n * ```ts\n * class KeepaliveCloudTasks extends CloudTasksClientProvider {\n * override create(): CloudTasksClient {\n * return new CloudTasksClient({ 'grpc.keepalive_time_ms': 30_000 });\n * }\n * }\n * (await options.rebind(CloudTasksClientProvider)).to(KeepaliveCloudTasks).inSingletonScope();\n * ```\n *\n * Why a provider class and not an optional `CloudTasksClient` binding: the app container is created\n * with inversify `autobind`, which auto-constructs ANY unbound class token — `@optional()` included —\n * and `CloudTasksClient` has no injectable metadata, so resolving it unbound throws\n * (\"CloudTasksClient constructor requires at least 2 arguments\"). A provider that is always bound\n * keeps the same contract — optional to override, today's client by default — with nothing to throw.\n *\n * Per-CALL settings are not here: gax `CallOptions` belong on `ScheduleOptions.gax` /\n * `GcpTaskRegistry.setGax`, and the Google request on a `TaskCustomizer`.\n */\n@provideFrameworkSingleton()\nexport class CloudTasksClientProvider {\n create(): CloudTasksClient {\n return new CloudTasksClient();\n }\n}\n"]}
@@ -1,20 +1,43 @@
1
1
  import { AuthMeta, AuthMode } from '@webpieces/core-util';
2
2
  import { Secrets } from '@webpieces/core-util';
3
3
  import { TaskInvoker, TaskRequest, JobReference } from './TaskTypes';
4
+ import { CloudTasksClientProvider } from './CloudTasksClientProvider';
4
5
  /**
5
6
  * TaskInvoker that enqueues to Google Cloud Tasks (`@google-cloud/tasks`). Builds an
6
7
  * HTTP-target task delivered as POST to `targetUrl + path`, authenticated per the
7
8
  * endpoint's auth mode (OIDC token minted as this service's runtime SA, or a
8
- * shared-secret header). The task name is derived from the dedup name for idempotency
9
- * (a duplicate enqueue is rejected ALREADY_EXISTS = idempotent success upstream).
9
+ * shared-secret header).
10
+ *
11
+ * ONE attempt per call, never a retry of its own:
12
+ * - it builds a FRESH `ICreateTaskRequest` (gax mutates what it is handed), names the task with the
13
+ * id `TaskProxyClient` resolved once for the whole logical enqueue, then applies the effective
14
+ * {@link TaskCustomizer} last;
15
+ * - it sends `createTask(request, { timeout: timeoutMs })` — a real gRPC deadline per attempt, and no
16
+ * gax retry — unless the caller owns timing (`plan.gax` set), in which case it sends a shallow copy
17
+ * of the caller's `CallOptions`, untouched;
18
+ * - it maps the gRPC failure with {@link GrpcErrorMapper}: ALREADY_EXISTS becomes
19
+ * `TaskAlreadyExistsError` (success, upstream), UNAVAILABLE / DEADLINE_EXCEEDED /
20
+ * RESOURCE_EXHAUSTED become transient webpieces errors that `RetryStrategy` retries.
21
+ *
22
+ * Cancellation: the gRPC deadline IS the attempt deadline, so on Path A gRPC itself cancels the RPC
23
+ * at the same moment `CallDeadline` gives up on it. `createTask`'s public promise carries no
24
+ * `cancel()` (the client chains `.then` on gax's cancellable promise, which drops it), so the abort
25
+ * signal is checked before sending rather than wired to a cancel.
10
26
  */
11
27
  export declare class GcpTaskInvoker extends TaskInvoker {
12
28
  private readonly secrets?;
13
29
  private readonly client;
14
- constructor(secrets?: Secrets | undefined);
30
+ private readonly grpcErrorMapper;
31
+ constructor(clientProvider: CloudTasksClientProvider, secrets?: Secrets | undefined);
15
32
  selectAuthentication(meta: AuthMeta): AuthMode;
16
- enqueue(request: TaskRequest): Promise<JobReference>;
33
+ enqueue(request: TaskRequest, timeoutMs: number, signal: AbortSignal): Promise<JobReference>;
17
34
  delete(ref: JobReference): Promise<void>;
35
+ /**
36
+ * A NEW object on every attempt. webpieces owns timing → `{ timeout }` only: a real gRPC deadline
37
+ * and no gax retry loop. The caller owns timing → a shallow copy of their options, so gax's own
38
+ * mutation (`otherArgs.headers`) never lands on an object the caller may reuse.
39
+ */
40
+ private callOptions;
18
41
  private buildTask;
19
42
  private applyAuth;
20
43
  }