Skip to content

Commit e9fb806

Browse files
authored
fix(kvs): write record metadata sidecars for input files (#1416)
1 parent 7c97865 commit e9fb806

6 files changed

Lines changed: 274 additions & 25 deletions

File tree

src/commands/run.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,12 @@ import {
2222
SUPPORTED_NODEJS_VERSION,
2323
} from '../lib/consts.js';
2424
import { execWithLog } from '../lib/exec.js';
25-
import { deleteFile } from '../lib/files.js';
2625
import { useActorConfig } from '../lib/hooks/useActorConfig.js';
2726
import { ProjectLanguage, useCwdProject } from '../lib/hooks/useCwdProject.js';
2827
import { useModuleVersion } from '../lib/hooks/useModuleVersion.js';
2928
import { CRAWLEE_INPUT_KEY_ENV, resolveInputKey, TEMP_INPUT_KEY_PREFIX } from '../lib/input-key.js';
3029
import { getAjvValidator, getDefaultsFromInputSchema, readInputSchema } from '../lib/input_schema.js';
30+
import { deleteKvsRecord, writeKvsRecord } from '../lib/kvs-metadata.js';
3131
import { error, info, warning } from '../lib/outputs.js';
3232
import { replaceSecretsValue } from '../lib/secrets.js';
3333
import {
@@ -39,6 +39,7 @@ import {
3939
getLocalUserInfo,
4040
isNodeVersionSupported,
4141
isPythonVersionSupported,
42+
type LocalInput,
4243
purgeDefaultDataset,
4344
purgeDefaultKeyValueStore,
4445
purgeDefaultQueue,
@@ -50,13 +51,27 @@ interface TempInputResult {
5051
}
5152

5253
interface OverwrittenInputResult {
53-
existingInput: ReturnType<typeof getLocalInput>;
54+
existingInput: LocalInput | undefined;
55+
inputKey: string;
5456
inputFilePath: string;
5557
writtenAt: number;
5658
}
5759

5860
type ValidateAndStoreInputResult = TempInputResult | OverwrittenInputResult;
5961

62+
/**
63+
* Write the input as a record, so a storage client resolves the bare key to `<key>.json`
64+
* through the sidecar instead of probing extensions.
65+
*/
66+
const writeInputRecord = async (storePath: string, key: string, input: Record<string, unknown>) =>
67+
writeKvsRecord({
68+
storePath,
69+
key,
70+
fileName: `${key}.json`,
71+
contentType: 'application/json; charset=utf-8',
72+
body: JSON.stringify(input, null, 2),
73+
});
74+
6075
enum RunType {
6176
DirectFile = 0,
6277
Module = 1,
@@ -456,8 +471,8 @@ export class RunCommand extends ApifyCommand<typeof RunCommand> {
456471
} finally {
457472
if (storedInputResults) {
458473
if ('tempInputKey' in storedInputResults) {
459-
// Temp input file: just delete it, user's INPUT.json was never touched
460-
await deleteFile(storedInputResults.tempInputFilePath);
474+
// Temp input record: just delete it, user's INPUT.json was never touched
475+
await deleteKvsRecord(storedInputResults.tempInputFilePath, storedInputResults.tempInputKey);
461476
} else if (storedInputResults.existingInput) {
462477
// Check if the input file was modified since we modified it. If it was, we abort the re-overwrite and warn the user
463478
const stats = await stat(storedInputResults.inputFilePath);
@@ -478,7 +493,7 @@ export class RunCommand extends ApifyCommand<typeof RunCommand> {
478493
await writeFile(storedInputResults.inputFilePath, storedInputResults.existingInput.body);
479494
} else {
480495
// No file -> we made it -> we delete it
481-
await deleteFile(storedInputResults.inputFilePath);
496+
await deleteKvsRecord(storedInputResults.inputFilePath, storedInputResults.inputKey);
482497
}
483498
}
484499
}
@@ -511,7 +526,7 @@ export class RunCommand extends ApifyCommand<typeof RunCommand> {
511526
const tempInputKey = `${TEMP_INPUT_KEY_PREFIX}${resolvedInputKey}`;
512527
const tempInputFilePath = join(localStorePath, `${tempInputKey}.json`);
513528

514-
await writeFile(tempInputFilePath, JSON.stringify(inputOverride.input, null, 2));
529+
await writeInputRecord(localStorePath, tempInputKey, inputOverride.input);
515530

516531
return {
517532
tempInputKey,
@@ -570,7 +585,7 @@ export class RunCommand extends ApifyCommand<typeof RunCommand> {
570585
const tempInputFilePath = join(localStorePath, `${tempInputKey}.json`);
571586

572587
await mkdir(localStorePath, { recursive: true });
573-
await writeFile(tempInputFilePath, JSON.stringify(fullInputOverride, null, 2));
588+
await writeInputRecord(localStorePath, tempInputKey, fullInputOverride);
574589

575590
return {
576591
tempInputKey,
@@ -581,10 +596,11 @@ export class RunCommand extends ApifyCommand<typeof RunCommand> {
581596
if (!existingInput) {
582597
await mkdir(localStorePath, { recursive: true });
583598
// No input -> use defaults for this run
584-
await writeFile(inputFilePath, JSON.stringify(defaults, null, 2));
599+
await writeInputRecord(localStorePath, resolvedInputKey, defaults);
585600

586601
return {
587602
existingInput,
603+
inputKey: resolvedInputKey,
588604
inputFilePath,
589605
writtenAt: Date.now(),
590606
};
@@ -616,7 +632,7 @@ export class RunCommand extends ApifyCommand<typeof RunCommand> {
616632
const tempInputKey = `${TEMP_INPUT_KEY_PREFIX}${resolvedInputKey}`;
617633
const tempInputFilePath = join(localStorePath, `${tempInputKey}.json`);
618634

619-
await writeFile(tempInputFilePath, JSON.stringify(fullInput, null, 2));
635+
await writeInputRecord(localStorePath, tempInputKey, fullInput);
620636

621637
return {
622638
tempInputKey,

src/lib/input_schema.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { existsSync, writeFileSync } from 'node:fs';
1+
import { existsSync } from 'node:fs';
22
import { join } from 'node:path';
33

44
import type { Ajv, ErrorObject } from 'ajv';
@@ -12,6 +12,7 @@ import {
1212
} from '@apify/json_schemas';
1313

1414
import { ACTOR_SPECIFICATION_FOLDER, LOCAL_CONFIG_PATH } from './consts.js';
15+
import { writeKvsRecord } from './kvs-metadata.js';
1516
import { info, warning } from './outputs.js';
1617
import { Ajv2019, getJsonFileContent, getLocalConfig, getLocalKeyValueStorePath } from './utils.js';
1718

@@ -273,9 +274,13 @@ export const createPrefilledInputFileFromInputSchema = async (actorFolderDir: st
273274
}`,
274275
});
275276
} finally {
276-
const keyValueStorePath = getLocalKeyValueStorePath();
277-
const inputJsonPath = join(actorFolderDir, keyValueStorePath, `${KEY_VALUE_STORE_KEYS.INPUT}.json`);
278-
writeFileSync(inputJsonPath, JSON.stringify(inputFile, null, '\t'));
277+
await writeKvsRecord({
278+
storePath: join(actorFolderDir, getLocalKeyValueStorePath()),
279+
key: KEY_VALUE_STORE_KEYS.INPUT,
280+
fileName: `${KEY_VALUE_STORE_KEYS.INPUT}.json`,
281+
contentType: 'application/json; charset=utf-8',
282+
body: JSON.stringify(inputFile, null, '\t'),
283+
});
279284
}
280285
};
281286

src/lib/kvs-metadata.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { readFileSync } from 'node:fs';
2+
import { rm, writeFile } from 'node:fs/promises';
3+
import { basename, dirname, join } from 'node:path';
4+
5+
/** Name of a key-value store's own metadata file, as written by crawlee's storage clients. */
6+
const STORE_METADATA_FILE_NAME = '__metadata__.json';
7+
8+
/** Suffix of a key-value store record's metadata sidecar, as written by crawlee's storage clients. */
9+
const RECORD_METADATA_SUFFIX = `.${STORE_METADATA_FILE_NAME}`;
10+
11+
export interface KvsRecordMetadata {
12+
key: string;
13+
contentType: string;
14+
/** Name of the value file on disk, when it is not the encoded key. */
15+
filename?: string;
16+
}
17+
18+
/**
19+
* Percent-encode a record key into its on-disk name, the way crawlee's storage clients do
20+
* (Python's `quote(key, safe='')`, which leaves `-._~` alone).
21+
*/
22+
export function encodeRecordKey(key: string) {
23+
return encodeURIComponent(key).replaceAll(
24+
/[!'()*]/g,
25+
(character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
26+
);
27+
}
28+
29+
export const recordMetadataFileName = (key: string) => `${encodeRecordKey(key)}${RECORD_METADATA_SUFFIX}`;
30+
31+
/**
32+
* Write a record's value file plus the sidecar binding `key` to it.
33+
*
34+
* The binding is what lets a reader open `INPUT.json` under the bare `INPUT` key instead of
35+
* probing extensions. `size` is deliberately omitted: readers fall back to the value file's
36+
* length, so an Actor overwriting the value cannot leave a stale size behind.
37+
*/
38+
export async function writeKvsRecord({
39+
storePath,
40+
key,
41+
fileName,
42+
contentType,
43+
body,
44+
}: {
45+
storePath: string;
46+
key: string;
47+
fileName: string;
48+
contentType: string;
49+
body: string | Buffer;
50+
}) {
51+
const metadata: KvsRecordMetadata = { key, contentType };
52+
53+
if (fileName !== encodeRecordKey(key)) {
54+
metadata.filename = fileName;
55+
}
56+
57+
await Promise.all([
58+
writeFile(join(storePath, fileName), body),
59+
writeFile(join(storePath, recordMetadataFileName(key)), JSON.stringify(metadata, null, 2)),
60+
]);
61+
}
62+
63+
/** Read a record's metadata sidecar, or undefined when there is no usable one. */
64+
export function readKvsRecordMetadata(storePath: string, key: string): KvsRecordMetadata | undefined {
65+
let metadata: KvsRecordMetadata;
66+
67+
try {
68+
metadata = JSON.parse(readFileSync(join(storePath, recordMetadataFileName(key)), 'utf8'));
69+
} catch {
70+
return undefined;
71+
}
72+
73+
if (typeof metadata?.contentType !== 'string') {
74+
return undefined;
75+
}
76+
77+
const { filename } = metadata;
78+
79+
// A sidecar may only bind its key to a plain file in the store itself.
80+
const bindsToStoreFile =
81+
filename === undefined ||
82+
(typeof filename === 'string' &&
83+
!['', '.', '..'].includes(filename) &&
84+
basename(filename) === filename &&
85+
filename !== STORE_METADATA_FILE_NAME &&
86+
!filename.endsWith(RECORD_METADATA_SUFFIX));
87+
88+
return bindsToStoreFile ? metadata : undefined;
89+
}
90+
91+
/** Delete a record's value file and its metadata sidecar. Missing files are not an error. */
92+
export async function deleteKvsRecord(valueFilePath: string, key: string) {
93+
await Promise.all([
94+
rm(valueFilePath, { force: true }),
95+
rm(join(dirname(valueFilePath), recordMetadataFileName(key)), { force: true }),
96+
]);
97+
}

src/lib/utils.ts

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { ensureMigrated, getBackend, getProxyPassword, getToken, setProxyPasswor
4545
import { deleteFile, ensureApifyDirectory, ensureFolderExistsSync, rimrafPromised } from './files.js';
4646
import { useCLIMetadata } from './hooks/useCLIMetadata.js';
4747
import { inputFileRegExp, TEMP_INPUT_KEY_PREFIX } from './input-key.js';
48+
import { encodeRecordKey, readKvsRecordMetadata, recordMetadataFileName } from './kvs-metadata.js';
4849
import type { AuthJSON } from './types.js';
4950
import { cliDebugPrint } from './utils/cliDebugPrint.js';
5051

@@ -523,18 +524,36 @@ export const createActZip = async (zipName: string, pathsToZip: string[], cwd: s
523524
await archive.finalize();
524525
};
525526

527+
export interface LocalInput {
528+
body: Buffer;
529+
/** Null when the input file is a bare, sidecar-less file with no telling extension. */
530+
contentType: string | null;
531+
fileName: string;
532+
}
533+
526534
/**
527535
* Get Actor input from local store
528536
*/
529-
export const getLocalInput = (cwd: string, inputKey?: string) => {
530-
const defaultLocalStorePath = getLocalKeyValueStorePath();
531-
532-
const storePath = resolve(cwd, defaultLocalStorePath);
537+
export const getLocalInput = (cwd: string, inputKey?: string): LocalInput | undefined => {
538+
const key = inputKey ?? KEY_VALUE_STORE_KEYS.INPUT;
539+
const storePath = resolve(cwd, getLocalKeyValueStorePath());
533540

534541
if (!existsSync(storePath)) return;
535542

543+
// A tracked record: the sidecar names the file the value lives in, so a key like `INPUT`
544+
// resolves to `INPUT.json` without guessing extensions.
545+
const metadata = readKvsRecordMetadata(storePath, key);
546+
547+
if (metadata) {
548+
const fileName = metadata.filename ?? encodeRecordKey(key);
549+
550+
if (existsSync(join(storePath, fileName))) {
551+
return { body: readFileSync(join(storePath, fileName)), contentType: metadata.contentType, fileName };
552+
}
553+
}
554+
536555
const files = readdirSync(storePath);
537-
const inputName = files.find((file) => !!file.match(inputFileRegExp(inputKey ?? 'INPUT')));
556+
const inputName = files.find((file) => !!file.match(inputFileRegExp(key)));
538557

539558
// No input file
540559
if (!inputName) return;
@@ -553,20 +572,29 @@ export const purgeDefaultDataset = async () => {
553572
};
554573

555574
/**
556-
* Deletes every record from the default key-value store, except the files
557-
* matching the given input keys. Defaults to preserving `INPUT.*`.
575+
* Deletes every record from the default key-value store, except the ones
576+
* belonging to the given input keys. Defaults to preserving `INPUT.*`.
558577
*/
559578
export const purgeDefaultKeyValueStore = async (...inputKeys: string[]) => {
560579
const defaultKeyValueStorePath = resolve(process.cwd(), getLocalKeyValueStorePath());
561580
if (!existsSync(defaultKeyValueStorePath)) {
562581
return;
563582
}
564583
const filesToDelete = readdirSync(defaultKeyValueStorePath);
565-
const preserveRegExps = (inputKeys.length > 0 ? inputKeys : ['INPUT']).map(inputFileRegExp);
584+
const keys = inputKeys.length > 0 ? inputKeys : [KEY_VALUE_STORE_KEYS.INPUT];
585+
const preserveRegExps = keys.map(inputFileRegExp);
586+
// The value file may be bound to a name the regexps don't cover, and the sidecar itself
587+
// never matches them, yet dropping it would strip the input of its metadata.
588+
const preserveNames = new Set(
589+
keys.flatMap((key) => [
590+
recordMetadataFileName(key),
591+
readKvsRecordMetadata(defaultKeyValueStorePath, key)?.filename,
592+
]),
593+
);
566594

567595
const deletePromises: Promise<void>[] = [];
568596
filesToDelete.forEach((file) => {
569-
if (!preserveRegExps.some((re) => re.test(file))) {
597+
if (!preserveNames.has(file) && !preserveRegExps.some((re) => re.test(file))) {
570598
deletePromises.push(deleteFile(join(defaultKeyValueStorePath, file)));
571599
}
572600
});
@@ -645,8 +673,8 @@ export const getNpmCmd = (): string => {
645673
};
646674

647675
/**
648-
* Returns true if the local storage holds nothing but the input file, either
649-
* the user's own `<inputKey>.*` or the temporary copy the CLI writes next to it.
676+
* Returns true if the local storage holds nothing but the input record, either
677+
* the user's own `<inputKey>` / `<inputKey>.*` or the temporary copy the CLI writes next to it.
650678
*/
651679
export const checkIfStorageIsEmpty = async (inputKey?: string) => {
652680
const key = inputKey || KEY_VALUE_STORE_KEYS.INPUT;
@@ -655,7 +683,14 @@ export const checkIfStorageIsEmpty = async (inputKey?: string) => {
655683
const keyValueStoreDir = getLocalKeyValueStorePath().replaceAll('\\', '/');
656684

657685
const filesWithoutInput = await glob(
658-
[`${storageDir}/**`, `!${keyValueStoreDir}/${key}.*`, `!${keyValueStoreDir}/${TEMP_INPUT_KEY_PREFIX}${key}.*`],
686+
[
687+
`${storageDir}/**`,
688+
// `<key>.*` also covers the record's `<key>.__metadata__.json` sidecar.
689+
...[key, `${TEMP_INPUT_KEY_PREFIX}${key}`].flatMap((inputName) => [
690+
`!${keyValueStoreDir}/${inputName}`,
691+
`!${keyValueStoreDir}/${inputName}.*`,
692+
]),
693+
],
659694
{ cwd: process.cwd() },
660695
);
661696

test/local/commands/init.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ describe('apify init', () => {
4444
expect(
4545
JSON.parse(readFileSync(joinPath(getLocalKeyValueStorePath(), `${KEY_VALUE_STORE_KEYS.INPUT}.json`), 'utf8')),
4646
).toStrictEqual({});
47+
expect(
48+
JSON.parse(
49+
readFileSync(joinPath(getLocalKeyValueStorePath(), `${KEY_VALUE_STORE_KEYS.INPUT}.__metadata__.json`), 'utf8'),
50+
),
51+
).toStrictEqual({
52+
key: KEY_VALUE_STORE_KEYS.INPUT,
53+
contentType: 'application/json; charset=utf-8',
54+
filename: `${KEY_VALUE_STORE_KEYS.INPUT}.json`,
55+
});
4756
});
4857

4958
it('correctly creates structure with prefilled INPUT.json', async () => {

0 commit comments

Comments
 (0)