Skip to content

Commit 233ef0c

Browse files
authored
Merge pull request #32 from fastly/kats/improve-kv-store-publish
Improve KV Store Publishing algorithm
2 parents 55df88b + 8139c76 commit 233ef0c

File tree

8 files changed

+218
-54
lines changed

8 files changed

+218
-54
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Using a static site generator to build your website? Do you simply need to serve
44

55
## Prerequisites
66

7-
Node 18 or newer is required during the build step, as we now rely on its `experimental-fetch` feature.
7+
Although your published application runs on a Fastly Compute service, the publishing process offered by this package requires Node.js 20 or newer.
88

99
## How it works
1010

package-lock.json

Lines changed: 30 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,12 @@
4141
"@fastly/js-compute": "^3.0.0",
4242
"@types/command-line-args": "^5.2.0",
4343
"@types/glob-to-regexp": "^0.4.1",
44-
"@types/node": "^18.0.0",
44+
"@types/node": "^20.0.0",
4545
"rimraf": "^4.3.0",
4646
"typescript": "^5.0.2"
4747
},
4848
"engines": {
49-
"node": ">=18"
49+
"node": ">=20"
5050
},
5151
"files": [
5252
"build",

src/cli/commands/build-static.ts

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ import { applyDefaults } from "../util/data.js";
6868
import { calculateFileSizeAndHash } from "../util/hash.js";
6969
import { getFiles } from "../util/files.js";
7070
import { generateOrLoadPublishId } from "../util/publish-id.js";
71-
import { FastlyApiContext, loadApiKey } from "../util/fastly-api.js";
71+
import { FastlyApiContext, FetchError, loadApiKey } from "../util/fastly-api.js";
7272
import { kvStoreEntryExists, kvStoreSubmitFile } from "../util/kv-store.js";
7373
import { mergeContentTypes, testFileContentType } from "../../util/content-types.js";
7474
import { algs } from "../compression/index.js";
@@ -94,6 +94,9 @@ import type {
9494
ContentFileInfoForWasmInline,
9595
ContentFileInfoForKVStore,
9696
} from "../../types/content-assets.js";
97+
import {
98+
attemptWithRetries,
99+
} from "../util/retryable.js";
97100

98101
type AssetInfo =
99102
ContentTypeTestResult &
@@ -123,17 +126,54 @@ type KVStoreItemDesc = {
123126
};
124127

125128
async function uploadFilesToKVStore(fastlyApiContext: FastlyApiContext, kvStoreName: string, kvStoreItems: KVStoreItemDesc[]) {
126-
for (const { kvStoreKey, staticFilePath, text } of kvStoreItems) {
127-
if (await kvStoreEntryExists(fastlyApiContext, kvStoreName, kvStoreKey)) {
128-
// Already exists in KV Store
129-
console.log(`✔️ Asset already exists in KV Store with key "${kvStoreKey}".`)
130-
} else {
131-
// Upload to KV Store
132-
const fileData = fs.readFileSync(staticFilePath);
133-
await kvStoreSubmitFile(fastlyApiContext!, kvStoreName!, kvStoreKey, fileData);
134-
console.log(`✔️ Submitted ${text ? 'text' : 'binary'} asset "${staticFilePath}" to KV Store with key "${kvStoreKey}".`)
129+
130+
const maxConcurrent = 12;
131+
let index = 0; // Shared among workers
132+
133+
async function worker() {
134+
while (index < kvStoreItems.length) {
135+
const currentIndex = index;
136+
index = index + 1;
137+
const { kvStoreKey, staticFilePath, text } = kvStoreItems[currentIndex];
138+
139+
try {
140+
await attemptWithRetries(
141+
async() => {
142+
if (await kvStoreEntryExists(fastlyApiContext, kvStoreName, kvStoreKey)) {
143+
console.log(`✔️ Asset already exists in KV Store with key "${kvStoreKey}".`);
144+
return;
145+
}
146+
const fileData = fs.readFileSync(staticFilePath);
147+
await kvStoreSubmitFile(fastlyApiContext, kvStoreName, kvStoreKey, fileData);
148+
console.log(`✔️ Submitted ${text ? 'text' : 'binary'} asset "${staticFilePath}" to KV Store with key "${kvStoreKey}".`)
149+
},
150+
{
151+
onAttempt(attempt) {
152+
if (attempt > 0) {
153+
console.log(`Attempt ${attempt + 1} for: ${kvStoreKey}`);
154+
}
155+
},
156+
onRetry(attempt, err, delay) {
157+
let statusMessage = 'unknown';
158+
if (err instanceof FetchError) {
159+
statusMessage = `HTTP ${err.status}`;
160+
} else if (err instanceof TypeError) {
161+
statusMessage = 'transport';
162+
}
163+
console.log(`Attempt ${attempt + 1} for ${kvStoreKey} gave retryable error (${statusMessage}), delaying ${delay} ms`);
164+
},
165+
}
166+
);
167+
} catch (err) {
168+
const e = err instanceof Error ? err : new Error(String(err));
169+
console.error(`❌ Failed: ${kvStoreKey}${e.message}`);
170+
console.error(e.stack);
171+
}
135172
}
136173
}
174+
175+
const workers = Array.from({ length: maxConcurrent }, () => worker());
176+
await Promise.all(workers);
137177
}
138178

139179
function writeKVStoreEntriesToFastlyToml(kvStoreName: string, kvStoreItems: KVStoreItemDesc[]) {
@@ -154,7 +194,7 @@ function writeKVStoreEntriesToFastlyToml(kvStoreName: string, kvStoreItems: KVSt
154194

155195
if (fastlyToml.indexOf(kvStoreName) !== -1) {
156196
// don't do this!
157-
console.error("improperly configured entry for '${kvStoreName}' in fastly.toml");
197+
console.error(`improperly configured entry for '${kvStoreName}' in fastly.toml`);
158198
// TODO: handle thrown exception from callers
159199
throw "No"!
160200
}

src/cli/commands/init-app.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,7 @@ ${staticFiles}
543543
'@fastly/js-compute': '^3.0.0',
544544
},
545545
engines: {
546-
node: '>=18.0.0',
546+
node: '>=20.0.0',
547547
},
548548
license: 'UNLICENSED',
549549
private: true,

src/cli/util/fastly-api.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { execSync } from 'child_process';
2+
import { makeRetryable } from './retryable.js';
23

34
export interface FastlyApiContext {
45
apiToken: string,
@@ -42,7 +43,33 @@ export function loadApiKey(): LoadApiKeyResult | null {
4243

4344
}
4445

45-
export async function callFastlyApi(fastlyApiContext: FastlyApiContext, endpoint: string, queryParams?: URLSearchParams | null, requestInit?: RequestInit): Promise<Response> {
46+
const RETRYABLE_STATUS_CODES = [
47+
408, // Request Timeout
48+
409, // Conflict (depends)
49+
423, // Locked
50+
429, // Too Many Requests
51+
500, // Internal Server Error
52+
502, // Bad Gateway
53+
503, // Service Unavailable
54+
504, // Gateway Timeout
55+
];
56+
57+
export class FetchError extends Error {
58+
constructor(message: string, status: number) {
59+
super(message);
60+
this.name = 'FetchError';
61+
this.status = status;
62+
}
63+
status: number;
64+
}
65+
66+
export async function callFastlyApi(
67+
fastlyApiContext: FastlyApiContext,
68+
endpoint: string,
69+
operationName: string,
70+
queryParams?: URLSearchParams | null,
71+
requestInit?: RequestInit,
72+
): Promise<Response> {
4673

4774
let finalEndpoint = endpoint;
4875
if (queryParams != null) {
@@ -60,8 +87,24 @@ export async function callFastlyApi(fastlyApiContext: FastlyApiContext, endpoint
6087
const request = new Request(url, {
6188
...requestInit,
6289
headers,
90+
redirect: 'error',
6391
});
64-
const response = await fetch(request);
92+
let response;
93+
try {
94+
response = await fetch(request);
95+
} catch(err) {
96+
if (err instanceof TypeError) {
97+
throw makeRetryable(err);
98+
} else {
99+
throw err;
100+
}
101+
}
102+
if (!response.ok) {
103+
if (!RETRYABLE_STATUS_CODES.includes(response.status)) {
104+
throw new FetchError(`${operationName} failed: ${response.status}`, response.status);
105+
}
106+
throw makeRetryable(new FetchError(`Retryable ${operationName} error: ${response.status}`, response.status));
107+
}
65108
return response;
66109

67110
}

0 commit comments

Comments
 (0)