Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/deploy/functions/release/fabricator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ const eventarcPollerOptions: Omit<poller.OperationPollerOptions, "operationResou
};

const CLOUD_RUN_RESOURCE_EXHAUSTED_CODE = 8;
// Bail out quickly when waiting for source tokens so we don't serialize builds
// if the backend never returns a token (observed with some GCFv2 responses).
const SOURCE_TOKEN_FETCH_TIMEOUT_MS = 2_000;

export interface FabricatorArgs {
executor: Executor;
Expand Down Expand Up @@ -375,7 +378,9 @@ export class Fabricator {
resultFunction = await this.functionExecutor
.run(async () => {
if (experiments.isEnabled("functionsv2deployoptimizations")) {
apiFunction.buildConfig.sourceToken = await scraper.getToken();
apiFunction.buildConfig.sourceToken = await scraper.getToken({
timeoutMs: SOURCE_TOKEN_FETCH_TIMEOUT_MS,
});
}
const op: { name: string } = await gcfV2.createFunction(apiFunction);
return await poller.pollOperation<gcfV2.OutputCloudFunction>({
Expand Down Expand Up @@ -515,7 +520,9 @@ export class Fabricator {
.run(
async () => {
if (experiments.isEnabled("functionsv2deployoptimizations")) {
apiFunction.buildConfig.sourceToken = await scraper.getToken();
apiFunction.buildConfig.sourceToken = await scraper.getToken({
timeoutMs: SOURCE_TOKEN_FETCH_TIMEOUT_MS,
});
}
const op: { name: string } = await gcfV2.updateFunction(apiFunction);
return await poller.pollOperation<gcfV2.OutputCloudFunction>({
Expand Down
18 changes: 18 additions & 0 deletions src/deploy/functions/release/sourceTokenScraper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,24 @@ describe("SourceTokenScraper", () => {
await expect(scraper.getToken()).to.eventually.equal("magic token");
});

it("returns early when configured with a timeout", async () => {
const scraper = new SourceTokenScraper();
// Put the scraper into FETCHING state.
await scraper.getToken();

const start = Date.now();
await expect(scraper.getToken({ timeoutMs: 5 })).to.eventually.be.undefined;
expect(Date.now() - start).to.be.lessThan(100);

scraper.poller({
metadata: {
sourceToken: "magic token",
target: "projects/p/locations/l/functions/f",
},
});
await expect(scraper.getToken()).to.eventually.equal("magic token");
});

it("refreshes token after timer expires", async () => {
const scraper = new SourceTokenScraper(10);
await expect(scraper.getToken()).to.eventually.be.undefined;
Expand Down
30 changes: 27 additions & 3 deletions src/deploy/functions/release/sourceTokenScraper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ import { assertExhaustive } from "../../../functional";
import { logger } from "../../../logger";

type TokenFetchState = "NONE" | "FETCHING" | "VALID";
interface TokenFetchOptions {
/**
* Maximum time to wait for a token before returning undefined.
* Keeps callers from blocking the entire deploy when the backend
* never returns a source token (as is possible with some APIs).
*/
timeoutMs?: number;
}
interface TokenFetchResult {
token?: string;
aborted: boolean;
Expand Down Expand Up @@ -30,19 +38,25 @@ export class SourceTokenScraper {
this.resolve({ aborted: true });
}

async getToken(): Promise<string | undefined> {
async getToken(options: TokenFetchOptions = {}): Promise<string | undefined> {
if (this.fetchState === "NONE") {
this.fetchState = "FETCHING";
return undefined;
} else if (this.fetchState === "FETCHING") {
const tokenResult = await this.promise;
const tokenResult = await this.waitForToken(options.timeoutMs);
if (!tokenResult) {
return undefined;
}
if (tokenResult.aborted) {
this.promise = new Promise((resolve) => (this.resolve = resolve));
return undefined;
}
return tokenResult.token;
} else if (this.fetchState === "VALID") {
const tokenResult = await this.promise;
const tokenResult = await this.waitForToken(options.timeoutMs);
if (!tokenResult) {
return undefined;
}
if (this.isTokenExpired()) {
this.fetchState = "FETCHING";
this.promise = new Promise((resolve) => (this.resolve = resolve));
Expand All @@ -54,6 +68,16 @@ export class SourceTokenScraper {
}
}

private async waitForToken(timeoutMs?: number): Promise<TokenFetchResult | undefined> {
if (timeoutMs === undefined) {
return this.promise;
}
return Promise.race([
this.promise,
new Promise<undefined>((resolve) => setTimeout(resolve, timeoutMs)),
]);
}

isTokenExpired(): boolean {
if (this.expiry === undefined) {
throw new FirebaseError(
Expand Down