Skip to content

Commit b9eaae6

Browse files
authored
feat(functions): Add features to task queue functions (#2216)
* enhance tq functions api to allow naming of tasks and deleting tasks * tidy up docstrings * make task already exists error consistent w rest of admin sdk * minor comment fixes
1 parent b0e65c4 commit b9eaae6

7 files changed

+256
-48
lines changed

etc/firebase-admin.functions.api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export function getFunctions(app?: App): Functions;
4040
// @public
4141
export type TaskOptions = DeliverySchedule & TaskOptionsExperimental & {
4242
dispatchDeadlineSeconds?: number;
43+
id?: string;
44+
headers?: Record<string, string>;
4345
};
4446

4547
// @public
@@ -50,6 +52,7 @@ export interface TaskOptionsExperimental {
5052

5153
// @public
5254
export class TaskQueue<Args = Record<string, any>> {
55+
delete(id: string): Promise<void>;
5356
enqueue(data: Args, opts?: TaskOptions): Promise<void>;
5457
}
5558

src/functions/functions-api-client-internal.ts

Lines changed: 111 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@ import * as validator from '../utils/validator';
2626
import { TaskOptions } from './functions-api';
2727
import { ComputeEngineCredential } from '../app/credential-internal';
2828

29-
const CLOUD_TASKS_API_URL_FORMAT = 'https://cloudtasks.googleapis.com/v2/projects/{projectId}/locations/{locationId}/queues/{resourceId}/tasks';
29+
const CLOUD_TASKS_API_RESOURCE_PATH = 'projects/{projectId}/locations/{locationId}/queues/{resourceId}/tasks';
30+
const CLOUD_TASKS_API_URL_FORMAT = 'https://cloudtasks.googleapis.com/v2/' + CLOUD_TASKS_API_RESOURCE_PATH;
3031
const FIREBASE_FUNCTION_URL_FORMAT = 'https://{locationId}-{projectId}.cloudfunctions.net/{resourceId}';
3132

3233
const FIREBASE_FUNCTIONS_CONFIG_HEADERS = {
@@ -54,6 +55,61 @@ export class FunctionsApiClient {
5455
}
5556
this.httpClient = new AuthorizedHttpClient(app as FirebaseApp);
5657
}
58+
/**
59+
* Deletes a task from a queue.
60+
*
61+
* @param id - The ID of the task to delete.
62+
* @param functionName - The function name of the queue.
63+
* @param extensionId - Optional canonical ID of the extension.
64+
*/
65+
public async delete(id: string, functionName: string, extensionId?: string): Promise<void> {
66+
if (!validator.isNonEmptyString(functionName)) {
67+
throw new FirebaseFunctionsError(
68+
'invalid-argument', 'Function name must be a non empty string');
69+
}
70+
if (!validator.isTaskId(id)) {
71+
throw new FirebaseFunctionsError(
72+
'invalid-argument', 'id can contain only letters ([A-Za-z]), numbers ([0-9]), '
73+
+ 'hyphens (-), or underscores (_). The maximum length is 500 characters.');
74+
}
75+
76+
let resources: utils.ParsedResource;
77+
try {
78+
resources = utils.parseResourceName(functionName, 'functions');
79+
} catch (err) {
80+
throw new FirebaseFunctionsError(
81+
'invalid-argument', 'Function name must be a single string or a qualified resource name');
82+
}
83+
resources.projectId = resources.projectId || await this.getProjectId();
84+
resources.locationId = resources.locationId || DEFAULT_LOCATION;
85+
if (!validator.isNonEmptyString(resources.resourceId)) {
86+
throw new FirebaseFunctionsError(
87+
'invalid-argument', 'No valid function name specified to enqueue tasks for.');
88+
}
89+
if (typeof extensionId !== 'undefined' && validator.isNonEmptyString(extensionId)) {
90+
resources.resourceId = `ext-${extensionId}-${resources.resourceId}`;
91+
}
92+
93+
try {
94+
const serviceUrl = await this.getUrl(resources, CLOUD_TASKS_API_URL_FORMAT.concat('/', id));
95+
const request: HttpRequestConfig = {
96+
method: 'DELETE',
97+
url: serviceUrl,
98+
headers: FIREBASE_FUNCTIONS_CONFIG_HEADERS,
99+
};
100+
await this.httpClient.send(request);
101+
} catch (err: unknown) {
102+
if (err instanceof HttpError) {
103+
if (err.response.status === 404) {
104+
// if no task with the provided ID exists, then ignore the delete.
105+
return;
106+
}
107+
throw this.toFirebaseError(err);
108+
} else {
109+
throw err;
110+
}
111+
}
112+
}
57113

58114
/**
59115
* Creates a task and adds it to a queue.
@@ -63,47 +119,53 @@ export class FunctionsApiClient {
63119
* @param extensionId - Optional canonical ID of the extension.
64120
* @param opts - Optional options when enqueuing a new task.
65121
*/
66-
public enqueue(data: any, functionName: string, extensionId?: string, opts?: TaskOptions): Promise<void> {
122+
public async enqueue(data: any, functionName: string, extensionId?: string, opts?: TaskOptions): Promise<void> {
67123
if (!validator.isNonEmptyString(functionName)) {
68124
throw new FirebaseFunctionsError(
69125
'invalid-argument', 'Function name must be a non empty string');
70126
}
71127

72-
const task = this.validateTaskOptions(data, opts);
73128
let resources: utils.ParsedResource;
74129
try {
75130
resources = utils.parseResourceName(functionName, 'functions');
76-
}
77-
catch (err) {
131+
} catch (err) {
78132
throw new FirebaseFunctionsError(
79133
'invalid-argument', 'Function name must be a single string or a qualified resource name');
80134
}
81-
135+
resources.projectId = resources.projectId || await this.getProjectId();
136+
resources.locationId = resources.locationId || DEFAULT_LOCATION;
137+
if (!validator.isNonEmptyString(resources.resourceId)) {
138+
throw new FirebaseFunctionsError(
139+
'invalid-argument', 'No valid function name specified to enqueue tasks for.');
140+
}
82141
if (typeof extensionId !== 'undefined' && validator.isNonEmptyString(extensionId)) {
83142
resources.resourceId = `ext-${extensionId}-${resources.resourceId}`;
84143
}
85-
86-
return this.getUrl(resources, CLOUD_TASKS_API_URL_FORMAT)
87-
.then((serviceUrl) => {
88-
return this.updateTaskPayload(task, resources, extensionId)
89-
.then((task) => {
90-
const request: HttpRequestConfig = {
91-
method: 'POST',
92-
url: serviceUrl,
93-
headers: FIREBASE_FUNCTIONS_CONFIG_HEADERS,
94-
data: {
95-
task,
96-
}
97-
};
98-
return this.httpClient.send(request);
99-
})
100-
})
101-
.then(() => {
102-
return;
103-
})
104-
.catch((err) => {
105-
throw this.toFirebaseError(err);
106-
});
144+
145+
const task = this.validateTaskOptions(data, resources, opts);
146+
try {
147+
const serviceUrl = await this.getUrl(resources, CLOUD_TASKS_API_URL_FORMAT);
148+
const taskPayload = await this.updateTaskPayload(task, resources, extensionId);
149+
const request: HttpRequestConfig = {
150+
method: 'POST',
151+
url: serviceUrl,
152+
headers: FIREBASE_FUNCTIONS_CONFIG_HEADERS,
153+
data: {
154+
task: taskPayload,
155+
}
156+
};
157+
await this.httpClient.send(request);
158+
} catch (err: unknown) {
159+
if (err instanceof HttpError) {
160+
if (err.response.status === 409) {
161+
throw new FirebaseFunctionsError('task-already-exists', `A task with ID ${opts?.id} already exists`);
162+
} else {
163+
throw this.toFirebaseError(err);
164+
}
165+
} else {
166+
throw err;
167+
}
168+
}
107169
}
108170

109171
private getUrl(resourceName: utils.ParsedResource, urlFormat: string): Promise<string> {
@@ -167,15 +229,18 @@ export class FunctionsApiClient {
167229
});
168230
}
169231

170-
private validateTaskOptions(data: any, opts?: TaskOptions): Task {
232+
private validateTaskOptions(data: any, resources: utils.ParsedResource, opts?: TaskOptions): Task {
171233
const task: Task = {
172234
httpRequest: {
173235
url: '',
174236
oidcToken: {
175237
serviceAccountEmail: '',
176238
},
177239
body: Buffer.from(JSON.stringify({ data })).toString('base64'),
178-
headers: { 'Content-Type': 'application/json' }
240+
headers: {
241+
'Content-Type': 'application/json',
242+
...opts?.headers,
243+
}
179244
}
180245
}
181246

@@ -214,6 +279,19 @@ export class FunctionsApiClient {
214279
}
215280
task.dispatchDeadline = `${opts.dispatchDeadlineSeconds}s`;
216281
}
282+
if ('id' in opts && typeof opts.id !== 'undefined') {
283+
if (!validator.isTaskId(opts.id)) {
284+
throw new FirebaseFunctionsError(
285+
'invalid-argument', 'id can contain only letters ([A-Za-z]), numbers ([0-9]), '
286+
+ 'hyphens (-), or underscores (_). The maximum length is 500 characters.');
287+
}
288+
const resourcePath = utils.formatString(CLOUD_TASKS_API_RESOURCE_PATH, {
289+
projectId: resources.projectId,
290+
locationId: resources.locationId,
291+
resourceId: resources.resourceId,
292+
});
293+
task.name = resourcePath.concat('/', opts.id);
294+
}
217295
if (typeof opts.uri !== 'undefined') {
218296
if (!validator.isURL(opts.uri)) {
219297
throw new FirebaseFunctionsError(
@@ -280,6 +358,7 @@ interface Error {
280358
* containing the relevant fields for enqueueing tasks that tirgger Cloud Functions.
281359
*/
282360
export interface Task {
361+
name?: string;
283362
// A timestamp in RFC3339 UTC "Zulu" format, with nanosecond resolution and up to nine fractional
284363
// digits. Examples: "2014-10-02T15:01:23Z" and "2014-10-02T15:01:23.045123456Z".
285364
scheduleTime?: string;
@@ -317,7 +396,8 @@ export type FunctionsErrorCode =
317396
| 'permission-denied'
318397
| 'unauthenticated'
319398
| 'not-found'
320-
| 'unknown-error';
399+
| 'unknown-error'
400+
| 'task-already-exists';
321401

322402
/**
323403
* Firebase Functions error code structure. This extends PrefixedFirebaseError.

src/functions/functions-api.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,40 @@ export type TaskOptions = DeliverySchedule & TaskOptionsExperimental & {
5858
* The default is 10 minutes. The deadline must be in the range of 15 seconds and 30 minutes.
5959
*/
6060
dispatchDeadlineSeconds?: number;
61+
62+
/**
63+
* The ID to use for the enqueued event.
64+
* If not provided, one will be automatically generated.
65+
* If provided, an explicitly specified task ID enables task de-duplication. If a task's ID is
66+
* identical to that of an existing task or a task that was deleted or executed recently then
67+
* the call will throw an error with code "functions/task-already-exists". Another task with
68+
* the same ID can't be created for ~1hour after the original task was deleted or executed.
69+
*
70+
* Because there is an extra lookup cost to identify duplicate task IDs, setting ID
71+
* significantly increases latency. Using hashed strings for the task ID or for the prefix of
72+
* the task ID is recommended. Choosing task IDs that are sequential or have sequential
73+
* prefixes, for example using a timestamp, causes an increase in latency and error rates in
74+
* all task commands. The infrastructure relies on an approximately uniform distribution of
75+
* task IDs to store and serve tasks efficiently.
76+
*
77+
* "Push IDs" from the Firebase Realtime Database make poor IDs because they are based on
78+
* timestamps and will cause contention (slowdowns) in your task queue. Reversed push IDs
79+
* however form a perfect distribution and are an ideal key. To reverse a string in
80+
* javascript use `someString.split("").reverse().join("")`
81+
*/
82+
id?: string;
83+
84+
/**
85+
* HTTP request headers to include in the request to the task queue function.
86+
* These headers represent a subset of the headers that will accompany the task's HTTP
87+
* request. Some HTTP request headers will be ignored or replaced, e.g. Authorization, Host, Content-Length,
88+
* User-Agent etc. cannot be overridden.
89+
*
90+
* By default, Content-Type is set to 'application/json'.
91+
*
92+
* The size of the headers must be less than 80KB.
93+
*/
94+
headers?: Record<string, string>;
6195
}
6296

6397
/**

src/functions/functions.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,13 @@ export class TaskQueue<Args = Record<string, any>> {
102102
public enqueue(data: Args, opts?: TaskOptions): Promise<void> {
103103
return this.client.enqueue(data, this.functionName, this.extensionId, opts);
104104
}
105+
106+
/**
107+
* Deletes an enqueued task if it has not yet completed.
108+
* @param id - the ID of the task, relative to this queue.
109+
* @returns A promise that resolves when the task has been deleted.
110+
*/
111+
public delete(id: string): Promise<void> {
112+
return this.client.delete(id, this.functionName, this.extensionId);
113+
}
105114
}

src/functions/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export {
3030
AbsoluteDelivery,
3131
DeliverySchedule,
3232
TaskOptions,
33-
TaskOptionsExperimental
33+
TaskOptionsExperimental,
3434
} from './functions-api';
3535
export {
3636
Functions,

src/utils/validator.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,3 +278,19 @@ export function isTopic(topic: any): boolean {
278278
const VALID_TOPIC_REGEX = /^(\/topics\/)?(private\/)?[a-zA-Z0-9-_.~%]+$/;
279279
return VALID_TOPIC_REGEX.test(topic);
280280
}
281+
282+
/**
283+
* Validates that the provided string can be used as a task ID
284+
* for Cloud Tasks.
285+
*
286+
* @param taskId - the task ID to validate.
287+
* @returns Whether the provided task ID is valid.
288+
*/
289+
export function isTaskId(taskId: any): boolean {
290+
if (typeof taskId !== 'string') {
291+
return false;
292+
}
293+
294+
const VALID_TASK_ID_REGEX = /^[A-Za-z0-9_-]+$/;
295+
return VALID_TASK_ID_REGEX.test(taskId);
296+
}

0 commit comments

Comments
 (0)