Skip to content

Commit a683252

Browse files
authored
Scheduled functions via pubsub (#227)
* Scheduler * unit tests * Adds integration tests for scheduled functions * removing uninented commit * fixing version number * adding labels and test for labels * trigger tests off of call to cloud scheduler:run * adding region/runtime opts tests * Predictions Functions (#220) * Firebase Predictions Integration with Functions - Initial check in * Add predictions getter * Make API match what was in the review, and add unit tests. * Fix imports. * Add error handling. * Handle process.env.GCLOUD_PROJECT correctly. * Fix region. * Upper case RiskToleranceName. * Add changelog message. * formatting * cleaning up * Revert "Predictions Functions (#220)" (#226) This reverts commit 838597aa6df973462a50484ec1f50d625da3f633. * pr fixes * adding clarifying comments * starting on pubsub impl * adds pusbub.schedule() * switching tests over from https to pubsub * Change timezone to a method instead of an optional argument * use real eventType * remove extra slash * changelog * switch signature to onRun(context) * switches changelog to present tense
1 parent d7fd3d1 commit a683252

File tree

9 files changed

+308
-16
lines changed

9 files changed

+308
-16
lines changed

changelog.txt

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1 @@
1-
feature - Adds functions.app() api to access the app instance used by functions
2-
fixed - improved types of the `Change` class to describe both `before` and `after` fields as non-optional
3-
fixed - Improve type of express.Request to include rawBody
1+
feature - Adds pubsub.schedule()

integration_test/functions/src/index.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,33 @@ function callHttpsTrigger(name: string, data: any, baseUrl) {
4646
});
4747
}
4848

49+
function callScheduleTrigger(functionName: string, region: string) {
50+
return new Promise((resolve, reject) => {
51+
const request = https.request(
52+
{
53+
method: 'POST',
54+
host: 'cloudscheduler.googleapis.com',
55+
path: `projects/${
56+
firebaseConfig.projectId
57+
}/locations/us-central1/jobs/firebase-schedule-${functionName}-${region}:run`,
58+
headers: {
59+
'Content-Type': 'application/json',
60+
},
61+
},
62+
response => {
63+
let body = '';
64+
response.on('data', chunk => {
65+
body += chunk;
66+
});
67+
response.on('end', () => resolve(body));
68+
}
69+
);
70+
request.on('error', reject);
71+
request.write('{}');
72+
request.end();
73+
});
74+
}
75+
4976
export const integrationTests: any = functions
5077
.runWith({
5178
timeoutSeconds: 540,
@@ -62,6 +89,10 @@ export const integrationTests: any = functions
6289
.database()
6390
.ref()
6491
.push().key;
92+
admin
93+
.database()
94+
.ref(`testRuns/${testId}/timestamp`)
95+
.set(Date.now());
6596
console.log('testId is: ', testId);
6697
fs.writeFile('/tmp/' + testId + '.txt', 'test', () => {});
6798
return Promise.all([
@@ -120,6 +151,9 @@ export const integrationTests: any = functions
120151
.bucket()
121152
.upload('/tmp/' + testId + '.txt'),
122153
// Invoke a callable HTTPS trigger.
154+
callHttpsTrigger('callableTests', { foo: 'bar', testId }),
155+
// Invoke the schedule for our scheduled function to fire
156+
callScheduleTrigger('schedule', 'us-central1'),
123157
])
124158
.then(() => {
125159
// On test completion, check that all tests pass and reply "PASS", or provide further details.
@@ -129,7 +163,7 @@ export const integrationTests: any = functions
129163
let testsExecuted = 0;
130164
ref.on('child_added', snapshot => {
131165
testsExecuted += 1;
132-
if (!snapshot.val().passed) {
166+
if (snapshot.key != 'timestamp' && !snapshot.val().passed) {
133167
reject(
134168
new Error(
135169
`test ${snapshot.key} failed; see database for details.`

integration_test/functions/src/pubsub-tests.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as functions from 'firebase-functions';
2-
import { TestSuite, expectEq, evaluate } from './testing';
2+
import * as admin from 'firebase-admin';
3+
import { TestSuite, expectEq, evaluate, success } from './testing';
34
import PubsubMessage = functions.pubsub.Message;
45

56
// TODO(inlined) use multiple queues to run inline.
@@ -57,3 +58,24 @@ export const pubsubTests: any = functions.pubsub
5758

5859
.run(testId, m, c);
5960
});
61+
62+
export const schedule: any = functions.pubsub
63+
.schedule('every 10 hours') // This is a dummy schedule, since we need to put a valid one in.
64+
// For the test, the job is triggered by the jobs:run api
65+
.onRun(context => {
66+
let testId;
67+
let db = admin.database();
68+
return new Promise(async (resolve, reject) => {
69+
await db
70+
.ref('testRuns')
71+
.orderByChild('timestamp')
72+
.limitToLast(1)
73+
.on('value', snap => {
74+
testId = Object.keys(snap.val())[0];
75+
new TestSuite('pubsub scheduleOnRun')
76+
.it('should trigger when the scheduler fires', () => success())
77+
.run(testId, null);
78+
});
79+
resolve();
80+
});
81+
});

integration_test/functions/src/testing.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export class TestSuite<T> {
5656
}
5757
}
5858

59-
function success() {
59+
export function success() {
6060
return Promise.resolve().then(() => true);
6161
}
6262

spec/providers/https.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,13 @@
2323
import { expect } from 'chai';
2424
import * as express from 'express';
2525
import * as firebase from 'firebase-admin';
26-
import * as https from '../../src/providers/https';
2726
import * as jwt from 'jsonwebtoken';
28-
import * as mocks from '../fixtures/credential/key.json';
29-
import * as nock from 'nock';
3027
import * as _ from 'lodash';
28+
import * as nock from 'nock';
3129
import { apps as appsNamespace } from '../../src/apps';
3230
import * as functions from '../../src/index';
31+
import * as https from '../../src/providers/https';
32+
import * as mocks from '../fixtures/credential/key.json';
3333

3434
describe('CloudHttpsBuilder', () => {
3535
describe('#onRequest', () => {

spec/providers/pubsub.spec.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,158 @@ describe('Pubsub Functions', () => {
129129
});
130130
});
131131
});
132+
describe('#schedule', () => {
133+
it('should return a trigger with schedule', () => {
134+
let result = pubsub.schedule('every 5 minutes').onRun(context => null);
135+
expect(result.__trigger.schedule).to.deep.equal({
136+
schedule: 'every 5 minutes',
137+
});
138+
});
139+
it('should return a trigger with schedule and timeZone when one is chosen', () => {
140+
let result = pubsub
141+
.schedule('every 5 minutes')
142+
.timeZone('America/New_York')
143+
.onRun(context => null);
144+
expect(result.__trigger.schedule).to.deep.equal({
145+
schedule: 'every 5 minutes',
146+
timeZone: 'America/New_York',
147+
});
148+
});
149+
it('should return a trigger with schedule and retry config when called with retryConfig', () => {
150+
let retryConfig = {
151+
retryCount: 3,
152+
maxRetryDuration: '10 minutes',
153+
minBackoffDuration: '10 minutes',
154+
maxBackoffDuration: '10 minutes',
155+
maxDoublings: 5,
156+
};
157+
let result = pubsub
158+
.schedule('every 5 minutes')
159+
.retryConfig(retryConfig)
160+
.onRun(() => null);
161+
expect(result.__trigger.schedule).to.deep.equal({
162+
schedule: 'every 5 minutes',
163+
retryConfig: retryConfig,
164+
});
165+
expect(result.__trigger.labels).to.deep.equal({
166+
'deployment-scheduled': 'true',
167+
});
168+
});
169+
it('should return a trigger with schedule, timeZone and retry config when called with retryConfig and timeout', () => {
170+
let retryConfig = {
171+
retryCount: 3,
172+
maxRetryDuration: '10 minutes',
173+
minBackoffDuration: '10 minutes',
174+
maxBackoffDuration: '10 minutes',
175+
maxDoublings: 5,
176+
};
177+
let result = pubsub
178+
.schedule('every 5 minutes')
179+
.timeZone('America/New_York')
180+
.retryConfig(retryConfig)
181+
.onRun(() => null);
182+
expect(result.__trigger.schedule).to.deep.equal({
183+
schedule: 'every 5 minutes',
184+
retryConfig: retryConfig,
185+
timeZone: 'America/New_York',
186+
});
187+
expect(result.__trigger.labels).to.deep.equal({
188+
'deployment-scheduled': 'true',
189+
});
190+
});
191+
it('should return an appropriate trigger when called with region and options', () => {
192+
let result = functions
193+
.region('us-east1')
194+
.runWith({
195+
timeoutSeconds: 90,
196+
memory: '256MB',
197+
})
198+
.pubsub.schedule('every 5 minutes')
199+
.onRun(() => null);
200+
expect(result.__trigger.schedule).to.deep.equal({
201+
schedule: 'every 5 minutes',
202+
});
203+
expect(result.__trigger.regions).to.deep.equal(['us-east1']);
204+
expect(result.__trigger.availableMemoryMb).to.deep.equal(256);
205+
expect(result.__trigger.timeout).to.deep.equal('90s');
206+
});
207+
it('should return an appropriate trigger when called with region, timeZone, and options', () => {
208+
let result = functions
209+
.region('us-east1')
210+
.runWith({
211+
timeoutSeconds: 90,
212+
memory: '256MB',
213+
})
214+
.pubsub.schedule('every 5 minutes')
215+
.timeZone('America/New_York')
216+
.onRun(() => null);
217+
expect(result.__trigger.schedule).to.deep.equal({
218+
schedule: 'every 5 minutes',
219+
timeZone: 'America/New_York',
220+
});
221+
expect(result.__trigger.regions).to.deep.equal(['us-east1']);
222+
expect(result.__trigger.availableMemoryMb).to.deep.equal(256);
223+
expect(result.__trigger.timeout).to.deep.equal('90s');
224+
});
225+
it('should return an appropriate trigger when called with region, options and retryConfig', () => {
226+
let retryConfig = {
227+
retryCount: 3,
228+
maxRetryDuration: '10 minutes',
229+
minBackoffDuration: '10 minutes',
230+
maxBackoffDuration: '10 minutes',
231+
maxDoublings: 5,
232+
};
233+
let result = functions
234+
.region('us-east1')
235+
.runWith({
236+
timeoutSeconds: 90,
237+
memory: '256MB',
238+
})
239+
.pubsub.schedule('every 5 minutes')
240+
.retryConfig(retryConfig)
241+
.onRun(() => null);
242+
expect(result.__trigger.schedule).to.deep.equal({
243+
schedule: 'every 5 minutes',
244+
retryConfig: retryConfig,
245+
});
246+
expect(result.__trigger.labels).to.deep.equal({
247+
'deployment-scheduled': 'true',
248+
});
249+
expect(result.__trigger.regions).to.deep.equal(['us-east1']);
250+
expect(result.__trigger.availableMemoryMb).to.deep.equal(256);
251+
expect(result.__trigger.timeout).to.deep.equal('90s');
252+
});
253+
it('should return an appropriate trigger when called with region, options, retryConfig, and timeZone', () => {
254+
let retryConfig = {
255+
retryCount: 3,
256+
maxRetryDuration: '10 minutes',
257+
minBackoffDuration: '10 minutes',
258+
maxBackoffDuration: '10 minutes',
259+
maxDoublings: 5,
260+
};
261+
let result = functions
262+
.region('us-east1')
263+
.runWith({
264+
timeoutSeconds: 90,
265+
memory: '256MB',
266+
})
267+
.pubsub.schedule('every 5 minutes')
268+
.timeZone('America/New_York')
269+
.retryConfig(retryConfig)
270+
.onRun(() => null);
271+
expect(result.__trigger.schedule).to.deep.equal({
272+
schedule: 'every 5 minutes',
273+
timeZone: 'America/New_York',
274+
retryConfig: retryConfig,
275+
});
276+
expect(result.__trigger.labels).to.deep.equal({
277+
'deployment-scheduled': 'true',
278+
});
279+
expect(result.__trigger.regions).to.deep.equal(['us-east1']);
280+
expect(result.__trigger.availableMemoryMb).to.deep.equal(256);
281+
expect(result.__trigger.timeout).to.deep.equal('90s');
282+
});
283+
});
132284
});
133285

134286
describe('handler namespace', () => {

src/cloud-functions.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@
2020
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121
// SOFTWARE.
2222

23-
import { apps } from './apps';
24-
import * as _ from 'lodash';
2523
import { Request, Response } from 'express';
24+
import * as _ from 'lodash';
25+
import { apps } from './apps';
2626
import { DeploymentOptions } from './function-builder';
2727
export { Request, Response };
2828

@@ -177,9 +177,24 @@ export interface TriggerAnnotated {
177177
regions?: string[];
178178
timeout?: string;
179179
availableMemoryMb?: number;
180+
schedule?: Schedule;
180181
};
181182
}
182183

184+
export interface ScheduleRetryConfig {
185+
retryCount?: number;
186+
maxRetryDuration?: string;
187+
minBackoffDuration?: string;
188+
maxBackoffDuration?: string;
189+
maxDoublings?: number;
190+
}
191+
192+
export interface Schedule {
193+
schedule: string;
194+
timeZone?: string;
195+
retryConfig?: ScheduleRetryConfig;
196+
}
197+
183198
/** A Runnable has a `run` method which directly invokes the user-defined function - useful for unit testing. */
184199
export interface Runnable<T> {
185200
run: (data: T, context: any) => PromiseLike<any> | any;
@@ -209,11 +224,13 @@ export interface MakeCloudFunctionArgs<EventData> {
209224
triggerResource: () => string;
210225
service: string;
211226
dataConstructor?: (raw: Event) => EventData;
212-
handler: (data: EventData, context: EventContext) => PromiseLike<any> | any;
227+
handler?: (data: EventData, context: EventContext) => PromiseLike<any> | any;
228+
contextOnlyHandler?: (context: EventContext) => PromiseLike<any> | any;
213229
before?: (raw: Event) => void;
214230
after?: (raw: Event) => void;
215231
legacyEventType?: string;
216232
opts?: { [key: string]: any };
233+
labels?: { [key: string]: any };
217234
}
218235

219236
/** @internal */
@@ -224,6 +241,7 @@ export function makeCloudFunction<EventData>({
224241
service,
225242
dataConstructor = (raw: Event) => raw.data,
226243
handler,
244+
contextOnlyHandler,
227245
before = () => {
228246
return;
229247
},
@@ -232,6 +250,7 @@ export function makeCloudFunction<EventData>({
232250
},
233251
legacyEventType,
234252
opts = {},
253+
labels = {},
235254
}: MakeCloudFunctionArgs<EventData>): CloudFunction<EventData> {
236255
let cloudFunction;
237256

@@ -273,8 +292,14 @@ export function makeCloudFunction<EventData>({
273292

274293
before(event);
275294

276-
let dataOrChange = dataConstructor(event);
277-
let promise = handler(dataOrChange, context);
295+
let promise;
296+
if (labels && labels['deployment-scheduled']) {
297+
// Scheduled function do not have meaningful data, so exclude it
298+
promise = contextOnlyHandler(context);
299+
} else {
300+
const dataOrChange = dataConstructor(event);
301+
promise = handler(dataOrChange, context);
302+
}
278303
if (typeof promise === 'undefined') {
279304
console.warn('Function returned undefined, expected Promise or value');
280305
}
@@ -320,12 +345,14 @@ export function makeCloudFunction<EventData>({
320345
service,
321346
},
322347
});
323-
348+
if (!_.isEmpty(labels)) {
349+
trigger.labels = labels;
350+
}
324351
return trigger;
325352
},
326353
});
327354

328-
cloudFunction.run = handler;
355+
cloudFunction.run = handler || contextOnlyHandler;
329356
return cloudFunction;
330357
}
331358

@@ -398,5 +425,8 @@ export function optsToTrigger(opts: DeploymentOptions) {
398425
};
399426
trigger.availableMemoryMb = _.get(memoryLookup, opts.memory);
400427
}
428+
if (opts.schedule) {
429+
trigger.schedule = opts.schedule;
430+
}
401431
return trigger;
402432
}

0 commit comments

Comments
 (0)