@crawlee/fs-storage 4.0.0-beta.99 → 4.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/file-system-storage.d.ts +35 -27
- package/file-system-storage.js +194 -99
- package/index.d.ts +1 -0
- package/package.json +7 -6
- package/resource-clients/dataset.d.ts +1 -2
- package/resource-clients/dataset.js +13 -19
- package/resource-clients/key-value-store.d.ts +6 -29
- package/resource-clients/key-value-store.js +42 -149
- package/resource-clients/request-queue.d.ts +6 -1
- package/resource-clients/request-queue.js +32 -37
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ Crawlee is available as the [`crawlee`](https://www.npmjs.com/package/crawlee) N
|
|
|
34
34
|
|
|
35
35
|
We recommend visiting the [Introduction tutorial](https://crawlee.dev/js/docs/introduction) in Crawlee documentation for more information.
|
|
36
36
|
|
|
37
|
-
> Crawlee requires **Node.js
|
|
37
|
+
> Crawlee requires **Node.js 22.13 or higher**.
|
|
38
38
|
|
|
39
39
|
### With Crawlee CLI
|
|
40
40
|
|
package/file-system-storage.d.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import type * as storage from '@crawlee/types';
|
|
2
2
|
import type { CrawleeLogger } from '@crawlee/types';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import type { AdoptionCandidate } from '@crawlee/fs-storage-native';
|
|
4
|
+
/** What the key-value store hooks of {@link FileSystemStorageBackend} learn about the store at hand. */
|
|
5
|
+
export interface KeyValueStoreHookOptions {
|
|
6
|
+
/** Whether the store is the run's default key-value store. */
|
|
7
|
+
isDefaultStore: boolean;
|
|
8
|
+
}
|
|
9
|
+
/** A file-system key-value store backend as seen by {@link FileSystemStorageBackend.purgeKeyValueStore}. */
|
|
10
|
+
export interface PurgeableKeyValueStoreBackend extends storage.KeyValueStoreBackend {
|
|
11
|
+
/** Remove every record from the store except the given keys. */
|
|
12
|
+
purgeExcept(keys: string[]): Promise<void>;
|
|
13
|
+
}
|
|
6
14
|
export interface FileSystemStorageOptions {
|
|
7
15
|
/**
|
|
8
16
|
* Path to directory where the data will be saved.
|
|
@@ -10,6 +18,8 @@ export interface FileSystemStorageOptions {
|
|
|
10
18
|
localDataDirectory: string;
|
|
11
19
|
/**
|
|
12
20
|
* Optional logger for FileSystemStorageBackend warnings.
|
|
21
|
+
*
|
|
22
|
+
* @internal
|
|
13
23
|
*/
|
|
14
24
|
logger?: CrawleeLogger;
|
|
15
25
|
/**
|
|
@@ -38,15 +48,7 @@ export interface FileSystemStorageOptions {
|
|
|
38
48
|
* `teardown` can operate over them), and exposing them through the `@crawlee/types` interfaces.
|
|
39
49
|
*/
|
|
40
50
|
export declare class FileSystemStorageBackend implements storage.StorageBackend {
|
|
41
|
-
|
|
42
|
-
readonly datasetsDirectory: string;
|
|
43
|
-
readonly keyValueStoresDirectory: string;
|
|
44
|
-
readonly requestQueuesDirectory: string;
|
|
45
|
-
readonly logger?: CrawleeLogger;
|
|
46
|
-
readonly requestQueueAccess: 'single' | 'shared';
|
|
47
|
-
readonly keyValueStoreBackendCache: KeyValueStoreBackend[];
|
|
48
|
-
readonly datasetBackendCache: DatasetBackend[];
|
|
49
|
-
readonly requestQueueBackendCache: RequestQueueBackend[];
|
|
51
|
+
#private;
|
|
50
52
|
constructor(options: FileSystemStorageOptions);
|
|
51
53
|
/**
|
|
52
54
|
* Return a cache key that includes the resolved storage directory, so that two
|
|
@@ -54,27 +56,33 @@ export declare class FileSystemStorageBackend implements storage.StorageBackend
|
|
|
54
56
|
* partitions, by including the storage directory in the cache key.
|
|
55
57
|
*/
|
|
56
58
|
getStorageBackendCacheKey(): string;
|
|
57
|
-
private static resolveStorageKey;
|
|
58
59
|
createDatasetBackend(options?: storage.StorageIdentifier): Promise<storage.DatasetBackend>;
|
|
59
60
|
createKeyValueStoreBackend(options?: storage.StorageIdentifier): Promise<storage.KeyValueStoreBackend>;
|
|
60
61
|
createRequestQueueBackend(options?: storage.StorageIdentifier): Promise<storage.RequestQueueBackend>;
|
|
61
|
-
storageExists(id: string, type: 'Dataset' | 'KeyValueStore' | 'RequestQueue'): Promise<boolean>;
|
|
62
62
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
63
|
+
* What the native `open` may turn into records: value files sitting in the store directory with no
|
|
64
|
+
* metadata sidecar, written out-of-band by a CLI, a project template, a v3 Crawlee or a text editor.
|
|
65
|
+
* Each becomes a record keyed by its filename, with the content type derived from the extension alone.
|
|
66
|
+
*
|
|
67
|
+
* Subclasses may claim specific files under a key of their own by prepending candidates — the Apify
|
|
68
|
+
* SDK adopts a bare `INPUT` or `INPUT.json` in the default store as the record `INPUT` this way.
|
|
69
|
+
*
|
|
70
|
+
* Once adopted, the file is an ordinary record: readable, listed, deletable, and — in a run-scoped
|
|
71
|
+
* store — purged on start unless {@link purgeKeyValueStore} spares it. Files adoption skips (dotfiles)
|
|
72
|
+
* are purged all the same: the purge sweeps the directory and keeps only the store metadata and the
|
|
73
|
+
* spared keys.
|
|
74
|
+
*
|
|
75
|
+
*/
|
|
76
|
+
protected keyValueStoreAdoptionCandidates(_options: KeyValueStoreHookOptions): AdoptionCandidate[];
|
|
77
|
+
/**
|
|
78
|
+
* Empties one run-scoped key-value store during {@link purge}. Subclasses may spare some keys of the
|
|
79
|
+
* default store with {@link PurgeableKeyValueStoreBackend.purgeExcept} — the Apify SDK keeps its run input.
|
|
69
80
|
*/
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
private static readMetadataId;
|
|
81
|
+
protected purgeKeyValueStore(store: PurgeableKeyValueStoreBackend, _options: KeyValueStoreHookOptions): Promise<void>;
|
|
82
|
+
storageExists(id: string, type: 'Dataset' | 'KeyValueStore' | 'RequestQueue'): Promise<boolean>;
|
|
73
83
|
/**
|
|
74
|
-
* Cleans up the
|
|
75
|
-
*
|
|
76
|
-
* - all records from the default key-value store, except for the "INPUT" key;
|
|
77
|
-
* - the default request queue.
|
|
84
|
+
* Cleans up the run-scoped storages before the run starts, sweeping the storage directories so that
|
|
85
|
+
* leftovers from a previous process are caught too.
|
|
78
86
|
*/
|
|
79
87
|
purge(): Promise<void>;
|
|
80
88
|
/**
|
package/file-system-storage.js
CHANGED
|
@@ -1,10 +1,35 @@
|
|
|
1
1
|
import { opendir, readFile } from 'node:fs/promises';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { parseArgument, schemas } from '@crawlee/utils/internal';
|
|
4
|
+
import { z } from 'zod';
|
|
5
5
|
import { DatasetBackend } from './resource-clients/dataset.js';
|
|
6
6
|
import { KeyValueStoreBackend } from './resource-clients/key-value-store.js';
|
|
7
7
|
import { RequestQueueBackend } from './resource-clients/request-queue.js';
|
|
8
|
+
const fileSystemStorageOptionsSchema = z.object({
|
|
9
|
+
localDataDirectory: z.string(),
|
|
10
|
+
requestQueueAccess: z.enum(['single', 'shared']).default('single'),
|
|
11
|
+
logger: schemas.logger.optional(),
|
|
12
|
+
});
|
|
13
|
+
// The native package throws at load time on platforms without a published binary (e.g.
|
|
14
|
+
// linux musl), and `@crawlee/core` imports this module eagerly via its service locator.
|
|
15
|
+
// Load it lazily so merely importing `@crawlee/fs-storage` stays safe everywhere and the
|
|
16
|
+
// native binding is only loaded when a file-system storage is actually used.
|
|
17
|
+
let nativeModule;
|
|
18
|
+
async function importNativeModule() {
|
|
19
|
+
nativeModule ??= import('@crawlee/fs-storage-native');
|
|
20
|
+
return nativeModule;
|
|
21
|
+
}
|
|
22
|
+
/** The alias `@crawlee/core` opens the default (unnamed) storage under. */
|
|
23
|
+
const DEFAULT_STORAGE_ALIAS = '__default__';
|
|
24
|
+
/** The directory the default storage lives in, one level below `datasets` / `key_value_stores` / etc. */
|
|
25
|
+
const DEFAULT_STORAGE_DIRECTORY = 'default';
|
|
26
|
+
/**
|
|
27
|
+
* Content types declared for adopted files. The native client infers nothing from extensions, and
|
|
28
|
+
* neither do we beyond this: a `.json` file is JSON, anything else is bytes for the
|
|
29
|
+
* {@link KeyValueStore} frontend to make sense of.
|
|
30
|
+
*/
|
|
31
|
+
const ADOPTED_JSON_CONTENT_TYPE = 'application/json; charset=utf-8';
|
|
32
|
+
const ADOPTED_BINARY_CONTENT_TYPE = 'application/octet-stream';
|
|
8
33
|
/**
|
|
9
34
|
* A file-system storage backend backed by the native `@crawlee/fs-storage-native` Rust extension.
|
|
10
35
|
*
|
|
@@ -14,26 +39,23 @@ import { RequestQueueBackend } from './resource-clients/request-queue.js';
|
|
|
14
39
|
* `teardown` can operate over them), and exposing them through the `@crawlee/types` interfaces.
|
|
15
40
|
*/
|
|
16
41
|
export class FileSystemStorageBackend {
|
|
17
|
-
localDataDirectory;
|
|
18
|
-
datasetsDirectory;
|
|
19
|
-
keyValueStoresDirectory;
|
|
20
|
-
requestQueuesDirectory;
|
|
21
|
-
logger;
|
|
22
|
-
requestQueueAccess;
|
|
23
|
-
keyValueStoreBackendCache = [];
|
|
24
|
-
datasetBackendCache = [];
|
|
25
|
-
requestQueueBackendCache = [];
|
|
42
|
+
#localDataDirectory;
|
|
43
|
+
#datasetsDirectory;
|
|
44
|
+
#keyValueStoresDirectory;
|
|
45
|
+
#requestQueuesDirectory;
|
|
46
|
+
#logger;
|
|
47
|
+
#requestQueueAccess;
|
|
48
|
+
#keyValueStoreBackendCache = [];
|
|
49
|
+
#datasetBackendCache = [];
|
|
50
|
+
#requestQueueBackendCache = [];
|
|
26
51
|
constructor(options) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
this
|
|
32
|
-
this
|
|
33
|
-
this
|
|
34
|
-
this.datasetsDirectory = resolve(this.localDataDirectory, 'datasets');
|
|
35
|
-
this.keyValueStoresDirectory = resolve(this.localDataDirectory, 'key_value_stores');
|
|
36
|
-
this.requestQueuesDirectory = resolve(this.localDataDirectory, 'request_queues');
|
|
52
|
+
const { logger, requestQueueAccess, localDataDirectory } = parseArgument(options, fileSystemStorageOptionsSchema);
|
|
53
|
+
this.#logger = logger;
|
|
54
|
+
this.#requestQueueAccess = requestQueueAccess;
|
|
55
|
+
this.#localDataDirectory = localDataDirectory;
|
|
56
|
+
this.#datasetsDirectory = resolve(this.#localDataDirectory, 'datasets');
|
|
57
|
+
this.#keyValueStoresDirectory = resolve(this.#localDataDirectory, 'key_value_stores');
|
|
58
|
+
this.#requestQueuesDirectory = resolve(this.#localDataDirectory, 'request_queues');
|
|
37
59
|
}
|
|
38
60
|
/**
|
|
39
61
|
* Return a cache key that includes the resolved storage directory, so that two
|
|
@@ -41,92 +63,125 @@ export class FileSystemStorageBackend {
|
|
|
41
63
|
* partitions, by including the storage directory in the cache key.
|
|
42
64
|
*/
|
|
43
65
|
getStorageBackendCacheKey() {
|
|
44
|
-
return `FileSystemStorageBackend:${resolve(this
|
|
66
|
+
return `FileSystemStorageBackend:${resolve(this.#localDataDirectory)}`;
|
|
45
67
|
}
|
|
46
|
-
static resolveStorageKey(options) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
const
|
|
51
|
-
|
|
68
|
+
static #resolveStorageKey(options) {
|
|
69
|
+
// No identifier at all means the default storage, which is opened under the reserved alias —
|
|
70
|
+
// same rule as `resolveStorageIdentifier` in @crawlee/core, so that a backend used directly
|
|
71
|
+
// lands on the very storage the frontends would have opened.
|
|
72
|
+
const requestedAlias = options.alias || (!options.id && !options.name ? DEFAULT_STORAGE_ALIAS : undefined);
|
|
73
|
+
// `__default__` is an internal sentinel and must not escape onto disk: the default storage lives
|
|
74
|
+
// in `default`, which is what the docs, the project templates and every pre-existing local
|
|
75
|
+
// `storage/` directory expect. Normalizing here keeps the cache key and the directory in step.
|
|
76
|
+
const alias = requestedAlias === DEFAULT_STORAGE_ALIAS ? DEFAULT_STORAGE_DIRECTORY : requestedAlias;
|
|
77
|
+
// `alias` covers the identifier-less case, so one of the three is always set.
|
|
78
|
+
const cacheKey = alias ?? options.name ?? options.id;
|
|
79
|
+
return { id: options.id, name: options.name, alias, cacheKey };
|
|
52
80
|
}
|
|
53
81
|
async createDatasetBackend(options = {}) {
|
|
54
|
-
const { id, name, alias, cacheKey } = FileSystemStorageBackend
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return found;
|
|
61
|
-
}
|
|
82
|
+
const { id, name, alias, cacheKey } = FileSystemStorageBackend.#resolveStorageKey(options);
|
|
83
|
+
const found = this.#datasetBackendCache.find((store) => store.id === cacheKey ||
|
|
84
|
+
store.name?.toLowerCase() === cacheKey.toLowerCase() ||
|
|
85
|
+
store.cacheKey.toLowerCase() === cacheKey.toLowerCase());
|
|
86
|
+
if (found) {
|
|
87
|
+
return found;
|
|
62
88
|
}
|
|
63
|
-
const nativeBackend = await
|
|
89
|
+
const nativeBackend = await (await importNativeModule()).FileSystemDatasetClient.open(id, name, alias, this.#localDataDirectory);
|
|
64
90
|
const newStore = await DatasetBackend.create({
|
|
65
91
|
name: alias ? undefined : (name ?? cacheKey),
|
|
66
|
-
cacheKey
|
|
92
|
+
cacheKey,
|
|
67
93
|
nativeBackend,
|
|
68
|
-
logger: this
|
|
94
|
+
logger: this.#logger,
|
|
69
95
|
});
|
|
70
|
-
this
|
|
96
|
+
this.#datasetBackendCache.push(newStore);
|
|
71
97
|
return newStore;
|
|
72
98
|
}
|
|
73
99
|
async createKeyValueStoreBackend(options = {}) {
|
|
74
|
-
const { id, name, alias, cacheKey } = FileSystemStorageBackend
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return found;
|
|
81
|
-
}
|
|
100
|
+
const { id, name, alias, cacheKey } = FileSystemStorageBackend.#resolveStorageKey(options);
|
|
101
|
+
const found = this.#keyValueStoreBackendCache.find((store) => store.id === cacheKey ||
|
|
102
|
+
store.name?.toLowerCase() === cacheKey.toLowerCase() ||
|
|
103
|
+
store.cacheKey.toLowerCase() === cacheKey.toLowerCase());
|
|
104
|
+
if (found) {
|
|
105
|
+
return found;
|
|
82
106
|
}
|
|
83
|
-
const nativeBackend = await
|
|
107
|
+
const nativeBackend = await (await importNativeModule()).FileSystemKeyValueStoreClient.open(id, name, alias, this.#localDataDirectory,
|
|
108
|
+
// useTestClock — always real wall-clock outside of native tests.
|
|
109
|
+
undefined, this.keyValueStoreAdoptionCandidates({ isDefaultStore: cacheKey === DEFAULT_STORAGE_DIRECTORY }));
|
|
84
110
|
const newStore = await KeyValueStoreBackend.create({
|
|
85
111
|
name: alias ? undefined : (name ?? cacheKey),
|
|
86
|
-
cacheKey
|
|
112
|
+
cacheKey,
|
|
87
113
|
nativeBackend,
|
|
88
|
-
logger: this
|
|
114
|
+
logger: this.#logger,
|
|
89
115
|
});
|
|
90
|
-
this
|
|
116
|
+
this.#keyValueStoreBackendCache.push(newStore);
|
|
91
117
|
return newStore;
|
|
92
118
|
}
|
|
93
119
|
async createRequestQueueBackend(options = {}) {
|
|
94
|
-
const { id, name, alias, cacheKey } = FileSystemStorageBackend
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return found;
|
|
101
|
-
}
|
|
120
|
+
const { id, name, alias, cacheKey } = FileSystemStorageBackend.#resolveStorageKey(options);
|
|
121
|
+
const found = this.#requestQueueBackendCache.find((queue) => queue.id === cacheKey ||
|
|
122
|
+
queue.name?.toLowerCase() === cacheKey.toLowerCase() ||
|
|
123
|
+
queue.cacheKey.toLowerCase() === cacheKey.toLowerCase());
|
|
124
|
+
if (found) {
|
|
125
|
+
return found;
|
|
102
126
|
}
|
|
103
|
-
const nativeBackend = await
|
|
127
|
+
const nativeBackend = await (await importNativeModule()).FileSystemRequestQueueClient.open(id, name, alias, this.#localDataDirectory,
|
|
104
128
|
// useTestClock — always real wall-clock outside of native tests.
|
|
105
|
-
undefined, this
|
|
129
|
+
undefined, this.#requestQueueAccess);
|
|
106
130
|
const newStore = await RequestQueueBackend.create({
|
|
107
131
|
name: alias ? undefined : (name ?? cacheKey),
|
|
108
|
-
cacheKey
|
|
132
|
+
cacheKey,
|
|
109
133
|
nativeBackend,
|
|
110
|
-
logger: this
|
|
134
|
+
logger: this.#logger,
|
|
111
135
|
});
|
|
112
|
-
this
|
|
136
|
+
this.#requestQueueBackendCache.push(newStore);
|
|
113
137
|
return newStore;
|
|
114
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* What the native `open` may turn into records: value files sitting in the store directory with no
|
|
141
|
+
* metadata sidecar, written out-of-band by a CLI, a project template, a v3 Crawlee or a text editor.
|
|
142
|
+
* Each becomes a record keyed by its filename, with the content type derived from the extension alone.
|
|
143
|
+
*
|
|
144
|
+
* Subclasses may claim specific files under a key of their own by prepending candidates — the Apify
|
|
145
|
+
* SDK adopts a bare `INPUT` or `INPUT.json` in the default store as the record `INPUT` this way.
|
|
146
|
+
*
|
|
147
|
+
* Once adopted, the file is an ordinary record: readable, listed, deletable, and — in a run-scoped
|
|
148
|
+
* store — purged on start unless {@link purgeKeyValueStore} spares it. Files adoption skips (dotfiles)
|
|
149
|
+
* are purged all the same: the purge sweeps the directory and keeps only the store metadata and the
|
|
150
|
+
* spared keys.
|
|
151
|
+
*
|
|
152
|
+
*/
|
|
153
|
+
keyValueStoreAdoptionCandidates(_options) {
|
|
154
|
+
return [
|
|
155
|
+
{
|
|
156
|
+
files: [
|
|
157
|
+
{ filename: '*.json', contentType: ADOPTED_JSON_CONTENT_TYPE },
|
|
158
|
+
{ filename: '*', contentType: ADOPTED_BINARY_CONTENT_TYPE },
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Empties one run-scoped key-value store during {@link purge}. Subclasses may spare some keys of the
|
|
165
|
+
* default store with {@link PurgeableKeyValueStoreBackend.purgeExcept} — the Apify SDK keeps its run input.
|
|
166
|
+
*/
|
|
167
|
+
async purgeKeyValueStore(store, _options) {
|
|
168
|
+
await store.purge();
|
|
169
|
+
}
|
|
115
170
|
async storageExists(id, type) {
|
|
116
171
|
let backends;
|
|
117
172
|
let baseDir;
|
|
118
173
|
switch (type) {
|
|
119
174
|
case 'Dataset':
|
|
120
|
-
backends = this
|
|
121
|
-
baseDir = this
|
|
175
|
+
backends = this.#datasetBackendCache;
|
|
176
|
+
baseDir = this.#datasetsDirectory;
|
|
122
177
|
break;
|
|
123
178
|
case 'KeyValueStore':
|
|
124
|
-
backends = this
|
|
125
|
-
baseDir = this
|
|
179
|
+
backends = this.#keyValueStoreBackendCache;
|
|
180
|
+
baseDir = this.#keyValueStoresDirectory;
|
|
126
181
|
break;
|
|
127
182
|
case 'RequestQueue':
|
|
128
|
-
backends = this
|
|
129
|
-
baseDir = this
|
|
183
|
+
backends = this.#requestQueueBackendCache;
|
|
184
|
+
baseDir = this.#requestQueuesDirectory;
|
|
130
185
|
break;
|
|
131
186
|
default:
|
|
132
187
|
return false;
|
|
@@ -144,7 +199,7 @@ export class FileSystemStorageBackend {
|
|
|
144
199
|
// has a matching directory. We therefore read the real id from the metadata and only report
|
|
145
200
|
// existence when it equals the queried string. This matches upstream PR #3800/#3808 and
|
|
146
201
|
// prevents a named storage from being re-resolved as `{ id: name }` on a subsequent run.
|
|
147
|
-
const resolvedId = await FileSystemStorageBackend
|
|
202
|
+
const resolvedId = await FileSystemStorageBackend.#resolveStorageIdOnDisk(baseDir, id);
|
|
148
203
|
return resolvedId === id;
|
|
149
204
|
}
|
|
150
205
|
/**
|
|
@@ -155,10 +210,10 @@ export class FileSystemStorageBackend {
|
|
|
155
210
|
* back to scanning sibling directories for one whose metadata id equals `entryNameOrId` (the case
|
|
156
211
|
* of a storage opened by name and later looked up by its auto-assigned id).
|
|
157
212
|
*/
|
|
158
|
-
static async resolveStorageIdOnDisk(baseDirectory, entryNameOrId) {
|
|
213
|
+
static async #resolveStorageIdOnDisk(baseDirectory, entryNameOrId) {
|
|
159
214
|
// Directory named exactly after the string: return its real (metadata) id, which may differ
|
|
160
215
|
// from the string when the string is a name rather than an id.
|
|
161
|
-
const directId = await FileSystemStorageBackend
|
|
216
|
+
const directId = (await FileSystemStorageBackend.#readMetadata(resolve(baseDirectory, entryNameOrId)))?.id;
|
|
162
217
|
if (directId !== undefined) {
|
|
163
218
|
return directId;
|
|
164
219
|
}
|
|
@@ -174,49 +229,89 @@ export class FileSystemStorageBackend {
|
|
|
174
229
|
if (!directory.isDirectory()) {
|
|
175
230
|
continue;
|
|
176
231
|
}
|
|
177
|
-
const metadataId = await FileSystemStorageBackend
|
|
232
|
+
const metadataId = (await FileSystemStorageBackend.#readMetadata(resolve(baseDirectory, directory.name)))
|
|
233
|
+
?.id;
|
|
178
234
|
if (metadataId === entryNameOrId) {
|
|
179
235
|
return metadataId;
|
|
180
236
|
}
|
|
181
237
|
}
|
|
182
238
|
return undefined;
|
|
183
239
|
}
|
|
184
|
-
/** Read
|
|
185
|
-
static async
|
|
240
|
+
/** Read a storage directory's `__metadata__.json`, or `undefined` if there is none to read. */
|
|
241
|
+
static async #readMetadata(storageDirectory) {
|
|
186
242
|
try {
|
|
187
243
|
const fileContent = await readFile(resolve(storageDirectory, '__metadata__.json'), 'utf8');
|
|
188
|
-
return JSON.parse(fileContent)
|
|
244
|
+
return JSON.parse(fileContent);
|
|
189
245
|
}
|
|
190
246
|
catch {
|
|
191
|
-
// Directory missing, or no/unreadable metadata file —
|
|
247
|
+
// Directory missing, or no/unreadable metadata file — nothing to report.
|
|
192
248
|
return undefined;
|
|
193
249
|
}
|
|
194
250
|
}
|
|
195
251
|
/**
|
|
196
|
-
* Cleans up the
|
|
197
|
-
*
|
|
198
|
-
* - all records from the default key-value store, except for the "INPUT" key;
|
|
199
|
-
* - the default request queue.
|
|
252
|
+
* Cleans up the run-scoped storages before the run starts, sweeping the storage directories so that
|
|
253
|
+
* leftovers from a previous process are caught too.
|
|
200
254
|
*/
|
|
201
255
|
async purge() {
|
|
202
|
-
// Resolve the default stores up front so leftover on-disk records are purged even when the
|
|
203
|
-
// store has not been opened in this process yet (e.g. a fresh run over a pre-existing
|
|
204
|
-
// directory). Opening caches the backend, so the subsequent purge operates on a real backend.
|
|
205
|
-
// The default store is opened via the internal `__default__` alias (see resolveStorageIdentifier
|
|
206
|
-
// in @crawlee/core), which resolves to the `default` cache key — match that here so we purge the
|
|
207
|
-
// very backend the default open would return rather than creating a divergent one.
|
|
208
|
-
const [defaultKeyValueStore, defaultDataset, defaultRequestQueue] = await Promise.all([
|
|
209
|
-
this.createKeyValueStoreBackend({ alias: '__default__' }),
|
|
210
|
-
this.createDatasetBackend({ alias: '__default__' }),
|
|
211
|
-
this.createRequestQueueBackend({ alias: '__default__' }),
|
|
212
|
-
]);
|
|
213
256
|
await Promise.all([
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
defaultRequestQueue.purge(),
|
|
257
|
+
this.#purgeRunScopedStorages(this.#keyValueStoresDirectory, async (alias) => this.createKeyValueStoreBackend({ alias }), async (store, isDefault) => this.purgeKeyValueStore(store, { isDefaultStore: isDefault })),
|
|
258
|
+
this.#purgeRunScopedStorages(this.#datasetsDirectory, async (alias) => this.createDatasetBackend({ alias }), async (store) => store.purge()),
|
|
259
|
+
this.#purgeRunScopedStorages(this.#requestQueuesDirectory, async (alias) => this.createRequestQueueBackend({ alias }), async (store) => store.purge()),
|
|
218
260
|
]);
|
|
219
261
|
}
|
|
262
|
+
/**
|
|
263
|
+
* Purge every run-scoped storage under `storagesDirectory`, whether or not it has been opened in this
|
|
264
|
+
* process yet. Storages are opened rather than emptied on disk directly, so that one already open
|
|
265
|
+
* under the same name or alias is purged through the backend the run is using, not a second one.
|
|
266
|
+
*/
|
|
267
|
+
async #purgeRunScopedStorages(storagesDirectory, open, purgeStorage) {
|
|
268
|
+
// The default storage is listed unconditionally, so that a run over an empty directory still ends
|
|
269
|
+
// up with it opened (and cached) exactly as it was before. Deduplicating by cache key then keeps
|
|
270
|
+
// it to a single open: every run after the first also finds its `default` directory on disk, and
|
|
271
|
+
// opening the same storage twice concurrently would race two backends onto one directory.
|
|
272
|
+
const aliasesByCacheKey = new Map();
|
|
273
|
+
for (const alias of [
|
|
274
|
+
DEFAULT_STORAGE_ALIAS,
|
|
275
|
+
...(await FileSystemStorageBackend.#listUnnamedStorages(storagesDirectory)),
|
|
276
|
+
]) {
|
|
277
|
+
const { cacheKey } = FileSystemStorageBackend.#resolveStorageKey({ alias });
|
|
278
|
+
if (!aliasesByCacheKey.has(cacheKey)) {
|
|
279
|
+
aliasesByCacheKey.set(cacheKey, alias);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
await Promise.all(Array.from(aliasesByCacheKey, async ([cacheKey, alias]) => {
|
|
283
|
+
await purgeStorage(await open(alias), cacheKey === DEFAULT_STORAGE_DIRECTORY);
|
|
284
|
+
}));
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* The directory names of the on-disk storages under `storagesDirectory` that Crawlee created without
|
|
288
|
+
* a name — the default storage and every alias-keyed one. Since the directory is named after the
|
|
289
|
+
* storage's `name ?? alias`, the name is read from the metadata rather than guessed.
|
|
290
|
+
*
|
|
291
|
+
* Two kinds of directory are left out, as purging them would destroy data this process never wrote:
|
|
292
|
+
* one without a readable `__metadata__.json` (not written by Crawlee — a hand-placed input directory,
|
|
293
|
+
* say), and one named after its own id, which is reachable only by `{ id }` and so is not run-scoped.
|
|
294
|
+
*/
|
|
295
|
+
static async #listUnnamedStorages(storagesDirectory) {
|
|
296
|
+
let directories;
|
|
297
|
+
try {
|
|
298
|
+
directories = await opendir(storagesDirectory);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
return [];
|
|
302
|
+
}
|
|
303
|
+
const unnamed = [];
|
|
304
|
+
for await (const directory of directories) {
|
|
305
|
+
if (!directory.isDirectory()) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const metadata = await FileSystemStorageBackend.#readMetadata(resolve(storagesDirectory, directory.name));
|
|
309
|
+
if (metadata !== undefined && typeof metadata.name !== 'string' && metadata.id !== directory.name) {
|
|
310
|
+
unnamed.push(directory.name);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return unnamed;
|
|
314
|
+
}
|
|
220
315
|
/**
|
|
221
316
|
* This method should be called at the end of the process, to ensure all data is saved.
|
|
222
317
|
*
|
|
@@ -224,6 +319,6 @@ export class FileSystemStorageBackend {
|
|
|
224
319
|
* are not stuck (until their lock expires) for the next consumer of the same on-disk queue.
|
|
225
320
|
*/
|
|
226
321
|
async teardown() {
|
|
227
|
-
await Promise.all(this
|
|
322
|
+
await Promise.all(this.#requestQueueBackendCache.map(async (queue) => queue.persistState()));
|
|
228
323
|
}
|
|
229
324
|
}
|
package/index.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crawlee/fs-storage",
|
|
3
|
-
"version": "4.0.0-
|
|
3
|
+
"version": "4.0.0-rc.1",
|
|
4
4
|
"description": "A file-system storage implementation of the Apify API",
|
|
5
5
|
"engines": {
|
|
6
|
-
"node": ">=22.
|
|
6
|
+
"node": ">=22.13.0"
|
|
7
7
|
},
|
|
8
8
|
"type": "module",
|
|
9
9
|
"exports": {
|
|
@@ -42,9 +42,10 @@
|
|
|
42
42
|
"access": "public"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@crawlee/fs-storage-native": "0.
|
|
46
|
-
"@crawlee/types": "4.0.0-
|
|
47
|
-
"@
|
|
45
|
+
"@crawlee/fs-storage-native": ">=0.2.2 <0.3",
|
|
46
|
+
"@crawlee/types": "4.0.0-rc.1",
|
|
47
|
+
"@crawlee/utils": "4.0.0-rc.1",
|
|
48
|
+
"zod": "^4.5.4"
|
|
48
49
|
},
|
|
49
50
|
"lerna": {
|
|
50
51
|
"command": {
|
|
@@ -53,5 +54,5 @@
|
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
},
|
|
56
|
-
"gitHead": "
|
|
57
|
+
"gitHead": "f354ca5e943bed1a5c657a1fce1974c053f9fff0"
|
|
57
58
|
}
|
|
@@ -21,10 +21,9 @@ export interface DatasetBackendOptions {
|
|
|
21
21
|
* the `@crawlee/types` interfaces.
|
|
22
22
|
*/
|
|
23
23
|
export declare class DatasetBackend<Data extends Dictionary = Dictionary> extends CachedIdClient implements storage.DatasetBackend<Data> {
|
|
24
|
+
#private;
|
|
24
25
|
readonly name?: string;
|
|
25
26
|
readonly cacheKey: string;
|
|
26
|
-
private readonly nativeBackend;
|
|
27
|
-
private readonly logger?;
|
|
28
27
|
constructor(options: DatasetBackendOptions);
|
|
29
28
|
get datasetDirectory(): string;
|
|
30
29
|
static create<Data extends Dictionary = Dictionary>(options: DatasetBackendOptions): Promise<DatasetBackend<Data>>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseArgument, schemas } from '@crawlee/utils/internal';
|
|
2
2
|
import { CachedIdClient } from './cached-id-client.js';
|
|
3
3
|
/**
|
|
4
4
|
* `getData` options accepted by the high-level `Dataset` frontend but not supported by the native
|
|
@@ -19,17 +19,17 @@ const UNSUPPORTED_GET_DATA_OPTIONS = ['clean', 'fields', 'omit', 'skipHidden', '
|
|
|
19
19
|
export class DatasetBackend extends CachedIdClient {
|
|
20
20
|
name;
|
|
21
21
|
cacheKey;
|
|
22
|
-
nativeBackend;
|
|
23
|
-
logger;
|
|
22
|
+
#nativeBackend;
|
|
23
|
+
#logger;
|
|
24
24
|
constructor(options) {
|
|
25
25
|
super();
|
|
26
26
|
this.name = options.name;
|
|
27
27
|
this.cacheKey = options.cacheKey;
|
|
28
|
-
this
|
|
29
|
-
this
|
|
28
|
+
this.#nativeBackend = options.nativeBackend;
|
|
29
|
+
this.#logger = options.logger;
|
|
30
30
|
}
|
|
31
31
|
get datasetDirectory() {
|
|
32
|
-
return this
|
|
32
|
+
return this.#nativeBackend.pathToDataset;
|
|
33
33
|
}
|
|
34
34
|
static async create(options) {
|
|
35
35
|
const backend = new DatasetBackend(options);
|
|
@@ -37,32 +37,26 @@ export class DatasetBackend extends CachedIdClient {
|
|
|
37
37
|
return backend;
|
|
38
38
|
}
|
|
39
39
|
async getMetadata() {
|
|
40
|
-
return this
|
|
40
|
+
return this.#nativeBackend.getMetadata();
|
|
41
41
|
}
|
|
42
42
|
async drop() {
|
|
43
|
-
await this
|
|
43
|
+
await this.#nativeBackend.dropStorage();
|
|
44
44
|
}
|
|
45
45
|
async purge() {
|
|
46
|
-
await this
|
|
46
|
+
await this.#nativeBackend.purge();
|
|
47
47
|
}
|
|
48
48
|
async pushData(items) {
|
|
49
|
-
await this
|
|
49
|
+
await this.#nativeBackend.pushData(items);
|
|
50
50
|
}
|
|
51
51
|
async getData(options = {}) {
|
|
52
52
|
const passedOptions = options;
|
|
53
53
|
const ignored = UNSUPPORTED_GET_DATA_OPTIONS.filter((key) => passedOptions[key] !== undefined);
|
|
54
54
|
if (ignored.length > 0) {
|
|
55
|
-
this
|
|
55
|
+
this.#logger?.warning?.(`getData() options [${ignored.join(', ')}] are not supported by the file-system dataset ` +
|
|
56
56
|
`and were ignored. Only "offset", "limit" and "desc" are honored.`);
|
|
57
57
|
}
|
|
58
|
-
const { desc, limit, offset } =
|
|
59
|
-
|
|
60
|
-
desc: s.boolean().optional(),
|
|
61
|
-
limit: s.number().int().optional(),
|
|
62
|
-
offset: s.number().int().optional(),
|
|
63
|
-
})
|
|
64
|
-
.parse(options);
|
|
65
|
-
const page = await this.nativeBackend.getData(offset ?? 0, limit, desc ?? false, false);
|
|
58
|
+
const { desc, limit, offset } = parseArgument(options, schemas.datasetListItemsOptions);
|
|
59
|
+
const page = await this.#nativeBackend.getData(offset ?? 0, limit, desc ?? false, false);
|
|
66
60
|
return {
|
|
67
61
|
count: page.count,
|
|
68
62
|
desc: page.desc,
|
|
@@ -22,9 +22,9 @@ export interface KeyValueStoreBackendOptions {
|
|
|
22
22
|
* is the {@link KeyValueStore} frontend codec's job, not this backend's.
|
|
23
23
|
*/
|
|
24
24
|
export declare class KeyValueStoreBackend extends CachedIdClient implements storage.KeyValueStoreBackend {
|
|
25
|
+
#private;
|
|
25
26
|
readonly name?: string;
|
|
26
27
|
readonly cacheKey: string;
|
|
27
|
-
private readonly nativeBackend;
|
|
28
28
|
constructor(options: KeyValueStoreBackendOptions);
|
|
29
29
|
get keyValueStoreDirectory(): string;
|
|
30
30
|
static create(options: KeyValueStoreBackendOptions): Promise<KeyValueStoreBackend>;
|
|
@@ -32,19 +32,16 @@ export declare class KeyValueStoreBackend extends CachedIdClient implements stor
|
|
|
32
32
|
drop(): Promise<void>;
|
|
33
33
|
purge(): Promise<void>;
|
|
34
34
|
/**
|
|
35
|
-
* Remove every record from the store except the
|
|
36
|
-
*
|
|
37
|
-
* while preserving the run's input, matching the historical file-system storage behavior.
|
|
38
|
-
*
|
|
39
|
-
* The native `purge` keep-list matches by exact key with no extension globbing, so we pass every
|
|
40
|
-
* filename the input might live under (`INPUT`, `INPUT.json`, `INPUT.txt`, `INPUT.bin`).
|
|
35
|
+
* Remove every record from the store except the given keys. For {@link FileSystemStorageBackend}
|
|
36
|
+
* subclasses that keep some of the default store's records across the purge on start.
|
|
41
37
|
*/
|
|
42
|
-
|
|
38
|
+
purgeExcept(keys: string[]): Promise<void>;
|
|
43
39
|
listKeys(options?: storage.KeyValueStoreListKeysOptions): Promise<storage.KeyValueStoreListKeysResult>;
|
|
44
40
|
/**
|
|
45
41
|
* Generates a public `file://` URL for accessing a specific record in the key-value store.
|
|
46
42
|
*
|
|
47
|
-
*
|
|
43
|
+
* Existence-agnostic per the {@link KeyValueStoreBackend} contract: the URL points at the file
|
|
44
|
+
* the key is bound to if there is a record, and at the file it would be written to otherwise.
|
|
48
45
|
* @param key The key of the record to generate the public URL for.
|
|
49
46
|
*/
|
|
50
47
|
getPublicUrl(key: string): Promise<string | undefined>;
|
|
@@ -58,24 +55,4 @@ export declare class KeyValueStoreBackend extends CachedIdClient implements stor
|
|
|
58
55
|
getValue(key: string): Promise<storage.KeyValueStoreRecord | undefined>;
|
|
59
56
|
setValue(record: storage.KeyValueStoreInputRecord): Promise<void>;
|
|
60
57
|
deleteValue(key: string): Promise<void>;
|
|
61
|
-
/**
|
|
62
|
-
* Resolve `key` to the on-disk key that actually exists, or `undefined` if nothing does. Every
|
|
63
|
-
* key is checked against its tracked record; the run-input keys additionally fall back to
|
|
64
|
-
* out-of-band bare files, in which case the matched on-disk key is returned so callers like
|
|
65
|
-
* `getPublicUrl` point at the file that exists. Two run-input shapes are handled (see
|
|
66
|
-
* {@link bareFallbacksFor}): the logical `INPUT`, which probes the conventional extensions, and a
|
|
67
|
-
* literal bare filename such as `INPUT.json` as listed by `listKeys`, which resolves itself.
|
|
68
|
-
*/
|
|
69
|
-
private resolveExistingKey;
|
|
70
|
-
/**
|
|
71
|
-
* The native `resolveValue`/`resolveExistingKey` bare-file fallbacks to use for `key`, or
|
|
72
|
-
* `undefined` if `key` is a plain tracked-record lookup with no bare-file probing.
|
|
73
|
-
*
|
|
74
|
-
* - The logical run-input key (`INPUT`) probes the full extension ladder (`INPUT`, `INPUT.json`,
|
|
75
|
-
* `INPUT.txt`, `INPUT.bin`), matching how Crawlee reads run input.
|
|
76
|
-
* - A literal bare filename as surfaced by `listKeys` (`INPUT.json`/`.txt`/`.bin`) resolves itself:
|
|
77
|
-
* the tracked record first, then the bare file at that exact name (a single empty-extension
|
|
78
|
-
* fallback), so a listed key round-trips through `getValue`/`recordExists`.
|
|
79
|
-
*/
|
|
80
|
-
private bareFallbacksFor;
|
|
81
58
|
}
|
|
@@ -1,40 +1,22 @@
|
|
|
1
1
|
import { Readable } from 'node:stream';
|
|
2
|
-
import {
|
|
2
|
+
import { parseArgument, schemas } from '@crawlee/utils/internal';
|
|
3
|
+
import { z } from 'zod';
|
|
3
4
|
import { isStream } from '../utils.js';
|
|
4
5
|
import { CachedIdClient } from './cached-id-client.js';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
];
|
|
20
|
-
const ALLOWED_BARE_FILES = ['INPUT'];
|
|
21
|
-
/**
|
|
22
|
-
* The out-of-band ("bare") files to surface from the native `listKeys`, derived from
|
|
23
|
-
* {@link ALLOWED_BARE_FILES} × {@link BARE_FILE_FALLBACKS}. Each native {@link ListBareFallback}
|
|
24
|
-
* `name` is the literal on-disk filename to probe (e.g. `INPUT.json`), and the native lists a match
|
|
25
|
-
* under that same `name` — which is exactly the key we return, so a listed bare file round-trips
|
|
26
|
-
* through `getValue`/`recordExists` (see {@link BARE_FILE_CONTENT_TYPES}).
|
|
27
|
-
*/
|
|
28
|
-
const LIST_BARE_FALLBACKS = ALLOWED_BARE_FILES.flatMap((key) => BARE_FILE_FALLBACKS.map(({ extension, contentType }) => ({ name: `${key}${extension}`, contentType })));
|
|
29
|
-
/**
|
|
30
|
-
* Lookup from a bare file's literal on-disk name (e.g. `INPUT.json`) to the content type to report
|
|
31
|
-
* for it, used to read a listed bare key back directly (`getValue('INPUT.json')`). The empty-extension
|
|
32
|
-
* entry (`INPUT`) is intentionally excluded: an extensionless `INPUT` lookup goes through the
|
|
33
|
-
* `resolveValue` fallback probing instead, which already covers the extensionless file.
|
|
34
|
-
*/
|
|
35
|
-
const BARE_FILE_CONTENT_TYPES = new Map(ALLOWED_BARE_FILES.flatMap((key) => BARE_FILE_FALLBACKS.filter(({ extension }) => extension !== '').map(({ extension, contentType }) => [`${key}${extension}`, contentType])));
|
|
36
|
-
/** Maps a bare file's on-disk name (e.g. `INPUT.json`) to its logical key (e.g. `INPUT`), for dedup. */
|
|
37
|
-
const BARE_FILE_LOGICAL_KEYS = new Map(ALLOWED_BARE_FILES.flatMap((key) => BARE_FILE_FALLBACKS.map(({ extension }) => [`${key}${extension}`, key])));
|
|
6
|
+
const keySchema = z.string();
|
|
7
|
+
const inputRecordShape = z.object({
|
|
8
|
+
key: z.string().min(1),
|
|
9
|
+
value: z.union([
|
|
10
|
+
z.string(),
|
|
11
|
+
z.instanceof(Buffer),
|
|
12
|
+
z.instanceof(ArrayBuffer),
|
|
13
|
+
schemas.typedArray,
|
|
14
|
+
// A stream is an object; this only checks it is a non-null, non-array object
|
|
15
|
+
// (the stream guard in `setValue` does the real check).
|
|
16
|
+
schemas.plainObject,
|
|
17
|
+
]),
|
|
18
|
+
contentType: z.string().min(1).optional(),
|
|
19
|
+
});
|
|
38
20
|
/**
|
|
39
21
|
* A file-system key-value store backend backed by the native `@crawlee/fs-storage-native` Rust
|
|
40
22
|
* extension.
|
|
@@ -46,15 +28,15 @@ const BARE_FILE_LOGICAL_KEYS = new Map(ALLOWED_BARE_FILES.flatMap((key) => BARE_
|
|
|
46
28
|
export class KeyValueStoreBackend extends CachedIdClient {
|
|
47
29
|
name;
|
|
48
30
|
cacheKey;
|
|
49
|
-
nativeBackend;
|
|
31
|
+
#nativeBackend;
|
|
50
32
|
constructor(options) {
|
|
51
33
|
super();
|
|
52
34
|
this.name = options.name;
|
|
53
35
|
this.cacheKey = options.cacheKey;
|
|
54
|
-
this
|
|
36
|
+
this.#nativeBackend = options.nativeBackend;
|
|
55
37
|
}
|
|
56
38
|
get keyValueStoreDirectory() {
|
|
57
|
-
return this
|
|
39
|
+
return this.#nativeBackend.pathToKvs;
|
|
58
40
|
}
|
|
59
41
|
static async create(options) {
|
|
60
42
|
const backend = new KeyValueStoreBackend(options);
|
|
@@ -62,75 +44,37 @@ export class KeyValueStoreBackend extends CachedIdClient {
|
|
|
62
44
|
return backend;
|
|
63
45
|
}
|
|
64
46
|
async getMetadata() {
|
|
65
|
-
return this
|
|
47
|
+
return this.#nativeBackend.getMetadata();
|
|
66
48
|
}
|
|
67
49
|
async drop() {
|
|
68
|
-
await this
|
|
50
|
+
await this.#nativeBackend.dropStorage();
|
|
69
51
|
}
|
|
70
52
|
async purge() {
|
|
71
|
-
await this
|
|
53
|
+
await this.#nativeBackend.purge();
|
|
72
54
|
}
|
|
73
55
|
/**
|
|
74
|
-
* Remove every record from the store except the
|
|
75
|
-
*
|
|
76
|
-
* while preserving the run's input, matching the historical file-system storage behavior.
|
|
77
|
-
*
|
|
78
|
-
* The native `purge` keep-list matches by exact key with no extension globbing, so we pass every
|
|
79
|
-
* filename the input might live under (`INPUT`, `INPUT.json`, `INPUT.txt`, `INPUT.bin`).
|
|
56
|
+
* Remove every record from the store except the given keys. For {@link FileSystemStorageBackend}
|
|
57
|
+
* subclasses that keep some of the default store's records across the purge on start.
|
|
80
58
|
*/
|
|
81
|
-
async
|
|
82
|
-
await this
|
|
59
|
+
async purgeExcept(keys) {
|
|
60
|
+
await this.#nativeBackend.purge(keys);
|
|
83
61
|
}
|
|
84
62
|
async listKeys(options = {}) {
|
|
85
|
-
const { prefix, exclusiveStartKey, limit } =
|
|
86
|
-
.object({
|
|
87
|
-
prefix: s.string().optional(),
|
|
88
|
-
exclusiveStartKey: s.string().optional(),
|
|
89
|
-
limit: s.number().int().greaterThan(0).optional(),
|
|
90
|
-
})
|
|
91
|
-
.parse(options);
|
|
92
|
-
// Pass the bare-file fallbacks so out-of-band value files (e.g. a hand-placed `INPUT.json`)
|
|
93
|
-
// are enumerated alongside tracked records, under their actual on-disk name. The native reads
|
|
94
|
-
// everything it needs off the filesystem index — no per-file reads — so this stays cheap.
|
|
63
|
+
const { prefix, exclusiveStartKey, limit } = parseArgument(options, schemas.keyValueStoreListKeysOptions);
|
|
95
64
|
// The native `listKeys` already returns a self-describing page (items + pagination cursors)
|
|
96
|
-
// matching the `KeyValueStoreListKeysResult` contract
|
|
97
|
-
|
|
98
|
-
const presentKeys = new Set(page.items.map((record) => record.key));
|
|
99
|
-
// A bare value file is listed under its actual name (`INPUT.json`), which already round-trips
|
|
100
|
-
// through `getValue`/`recordExists`. The only collision is a tracked record occupying the
|
|
101
|
-
// logical key itself (`INPUT`): it shadows the extension-bearing bare variants (`INPUT.json`
|
|
102
|
-
// etc.) for the same logical key, so drop those. The extensionless bare file *is* the logical
|
|
103
|
-
// key, so it is never a separate duplicate.
|
|
104
|
-
const items = page.items.filter((record) => {
|
|
105
|
-
const logicalKey = BARE_FILE_LOGICAL_KEYS.get(record.key);
|
|
106
|
-
const isExtensionBearingBareFile = logicalKey !== undefined && logicalKey !== record.key;
|
|
107
|
-
return !(isExtensionBearingBareFile && presentKeys.has(logicalKey));
|
|
108
|
-
});
|
|
109
|
-
return {
|
|
110
|
-
items,
|
|
111
|
-
count: items.length,
|
|
112
|
-
limit: page.limit,
|
|
113
|
-
exclusiveStartKey: page.exclusiveStartKey,
|
|
114
|
-
isTruncated: page.isTruncated,
|
|
115
|
-
nextExclusiveStartKey: page.nextExclusiveStartKey,
|
|
116
|
-
};
|
|
65
|
+
// matching the `KeyValueStoreListKeysResult` contract.
|
|
66
|
+
return this.#nativeBackend.listKeys(exclusiveStartKey, limit, prefix);
|
|
117
67
|
}
|
|
118
68
|
/**
|
|
119
69
|
* Generates a public `file://` URL for accessing a specific record in the key-value store.
|
|
120
70
|
*
|
|
121
|
-
*
|
|
71
|
+
* Existence-agnostic per the {@link KeyValueStoreBackend} contract: the URL points at the file
|
|
72
|
+
* the key is bound to if there is a record, and at the file it would be written to otherwise.
|
|
122
73
|
* @param key The key of the record to generate the public URL for.
|
|
123
74
|
*/
|
|
124
75
|
async getPublicUrl(key) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
// so we resolve the on-disk key first (handling e.g. `INPUT` -> `INPUT.json`) and normalize
|
|
128
|
-
// the native `null`-for-missing result to the historical `undefined` contract.
|
|
129
|
-
const resolvedKey = await this.resolveExistingKey(key);
|
|
130
|
-
if (resolvedKey === undefined) {
|
|
131
|
-
return undefined;
|
|
132
|
-
}
|
|
133
|
-
return (await this.nativeBackend.getPublicUrl(resolvedKey)) ?? undefined;
|
|
76
|
+
parseArgument(key, keySchema);
|
|
77
|
+
return this.#nativeBackend.getPublicUrl(key);
|
|
134
78
|
}
|
|
135
79
|
/**
|
|
136
80
|
* Tests whether a record with the given key exists without retrieving its value.
|
|
@@ -139,15 +83,12 @@ export class KeyValueStoreBackend extends CachedIdClient {
|
|
|
139
83
|
* @returns `true` if the record exists, `false` otherwise.
|
|
140
84
|
*/
|
|
141
85
|
async recordExists(key) {
|
|
142
|
-
|
|
143
|
-
return
|
|
86
|
+
parseArgument(key, keySchema);
|
|
87
|
+
return this.#nativeBackend.recordExists(key);
|
|
144
88
|
}
|
|
145
89
|
async getValue(key) {
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
const record = fallbacks
|
|
149
|
-
? await this.nativeBackend.resolveValue(key, fallbacks)
|
|
150
|
-
: await this.nativeBackend.getValue(key);
|
|
90
|
+
parseArgument(key, keySchema);
|
|
91
|
+
const record = await this.#nativeBackend.getValue(key);
|
|
151
92
|
if (record) {
|
|
152
93
|
return {
|
|
153
94
|
key: record.key,
|
|
@@ -162,19 +103,7 @@ export class KeyValueStoreBackend extends CachedIdClient {
|
|
|
162
103
|
// serialized it: non-bytes become a `string`, everything else is a `Buffer`/typed array or a
|
|
163
104
|
// stream. So we only accept those shapes here — there is no JSON inference or `String(value)`
|
|
164
105
|
// coercion left to do.
|
|
165
|
-
|
|
166
|
-
key: s.string().lengthGreaterThan(0),
|
|
167
|
-
value: s.union([
|
|
168
|
-
s.string(),
|
|
169
|
-
s.instance(Buffer),
|
|
170
|
-
s.instance(ArrayBuffer),
|
|
171
|
-
s.typedArray(),
|
|
172
|
-
// A stream is an object; disabling validation makes shapeshift only check it is a
|
|
173
|
-
// non-null, non-array object (the stream guard below does the real check).
|
|
174
|
-
s.object({}).setValidationEnabled(false),
|
|
175
|
-
]),
|
|
176
|
-
contentType: s.string().lengthGreaterThan(0).optional(),
|
|
177
|
-
}).parse(record);
|
|
106
|
+
parseArgument(record, inputRecordShape);
|
|
178
107
|
const { key, value } = record;
|
|
179
108
|
// The frontend resolves the content type before it reaches the backend; this backend is a plain
|
|
180
109
|
// byte transport and does not infer content types.
|
|
@@ -183,7 +112,7 @@ export class KeyValueStoreBackend extends CachedIdClient {
|
|
|
183
112
|
// consumes a Web `ReadableStream`, so convert the Node `Readable` we get from the frontend.
|
|
184
113
|
if (isStream(value)) {
|
|
185
114
|
const webStream = Readable.toWeb(value);
|
|
186
|
-
await this
|
|
115
|
+
await this.#nativeBackend.setValueStream(key, webStream, contentType);
|
|
187
116
|
return;
|
|
188
117
|
}
|
|
189
118
|
// Normalize the remaining (already-serialized) value into a Buffer for the native client.
|
|
@@ -194,46 +123,10 @@ export class KeyValueStoreBackend extends CachedIdClient {
|
|
|
194
123
|
: ArrayBuffer.isView(value)
|
|
195
124
|
? Buffer.from(value.buffer, value.byteOffset, value.byteLength)
|
|
196
125
|
: Buffer.from(value);
|
|
197
|
-
await this
|
|
126
|
+
await this.#nativeBackend.setValue(key, buffer, contentType);
|
|
198
127
|
}
|
|
199
128
|
async deleteValue(key) {
|
|
200
|
-
|
|
201
|
-
await this
|
|
202
|
-
}
|
|
203
|
-
/**
|
|
204
|
-
* Resolve `key` to the on-disk key that actually exists, or `undefined` if nothing does. Every
|
|
205
|
-
* key is checked against its tracked record; the run-input keys additionally fall back to
|
|
206
|
-
* out-of-band bare files, in which case the matched on-disk key is returned so callers like
|
|
207
|
-
* `getPublicUrl` point at the file that exists. Two run-input shapes are handled (see
|
|
208
|
-
* {@link bareFallbacksFor}): the logical `INPUT`, which probes the conventional extensions, and a
|
|
209
|
-
* literal bare filename such as `INPUT.json` as listed by `listKeys`, which resolves itself.
|
|
210
|
-
*/
|
|
211
|
-
async resolveExistingKey(key) {
|
|
212
|
-
const fallbacks = this.bareFallbacksFor(key);
|
|
213
|
-
if (fallbacks) {
|
|
214
|
-
return ((await this.nativeBackend.resolveExistingKey(key, fallbacks.map(({ extension }) => extension))) ?? undefined);
|
|
215
|
-
}
|
|
216
|
-
return (await this.nativeBackend.recordExists(key)) ? key : undefined;
|
|
217
|
-
}
|
|
218
|
-
/**
|
|
219
|
-
* The native `resolveValue`/`resolveExistingKey` bare-file fallbacks to use for `key`, or
|
|
220
|
-
* `undefined` if `key` is a plain tracked-record lookup with no bare-file probing.
|
|
221
|
-
*
|
|
222
|
-
* - The logical run-input key (`INPUT`) probes the full extension ladder (`INPUT`, `INPUT.json`,
|
|
223
|
-
* `INPUT.txt`, `INPUT.bin`), matching how Crawlee reads run input.
|
|
224
|
-
* - A literal bare filename as surfaced by `listKeys` (`INPUT.json`/`.txt`/`.bin`) resolves itself:
|
|
225
|
-
* the tracked record first, then the bare file at that exact name (a single empty-extension
|
|
226
|
-
* fallback), so a listed key round-trips through `getValue`/`recordExists`.
|
|
227
|
-
*/
|
|
228
|
-
// eslint-disable-next-line class-methods-use-this
|
|
229
|
-
bareFallbacksFor(key) {
|
|
230
|
-
if (ALLOWED_BARE_FILES.includes(key)) {
|
|
231
|
-
return BARE_FILE_FALLBACKS;
|
|
232
|
-
}
|
|
233
|
-
const contentType = BARE_FILE_CONTENT_TYPES.get(key);
|
|
234
|
-
if (contentType !== undefined) {
|
|
235
|
-
return [{ extension: '', contentType }];
|
|
236
|
-
}
|
|
237
|
-
return undefined;
|
|
129
|
+
parseArgument(key, keySchema);
|
|
130
|
+
await this.#nativeBackend.deleteValue(key);
|
|
238
131
|
}
|
|
239
132
|
}
|
|
@@ -21,9 +21,9 @@ export interface RequestQueueBackendOptions {
|
|
|
21
21
|
* This adapter forwards each operation and converts result shapes to the `@crawlee/types` interfaces.
|
|
22
22
|
*/
|
|
23
23
|
export declare class RequestQueueBackend extends CachedIdClient implements storage.RequestQueueBackend {
|
|
24
|
+
#private;
|
|
24
25
|
readonly name?: string;
|
|
25
26
|
readonly cacheKey: string;
|
|
26
|
-
private readonly nativeBackend;
|
|
27
27
|
constructor(options: RequestQueueBackendOptions);
|
|
28
28
|
get requestQueueDirectory(): string;
|
|
29
29
|
static create(options: RequestQueueBackendOptions): Promise<RequestQueueBackend>;
|
|
@@ -32,6 +32,11 @@ export declare class RequestQueueBackend extends CachedIdClient implements stora
|
|
|
32
32
|
* available again.
|
|
33
33
|
*/
|
|
34
34
|
setExpectedRequestProcessingTimeSecs(secs: number): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* @inheritdoc
|
|
37
|
+
* The lock is owned and extended by the native client; `false` means it no longer holds the request.
|
|
38
|
+
*/
|
|
39
|
+
extendRequestProcessingTimeSecs(requestId: string, secs: number): Promise<boolean>;
|
|
35
40
|
getMetadata(): Promise<storage.RequestQueueInfo>;
|
|
36
41
|
drop(): Promise<void>;
|
|
37
42
|
purge(): Promise<void>;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseArgument, schemas } from '@crawlee/utils/internal';
|
|
2
|
+
import { z } from 'zod';
|
|
2
3
|
import { CachedIdClient } from './cached-id-client.js';
|
|
4
|
+
const uniqueKeySchema = z.string();
|
|
3
5
|
/**
|
|
4
6
|
* Convert a request (either a Crawlee `Request` instance or a plain schema object) into a plain object
|
|
5
7
|
* whose properties are all enumerable.
|
|
@@ -15,21 +17,6 @@ import { CachedIdClient } from './cached-id-client.js';
|
|
|
15
17
|
function plainifyRequest(request) {
|
|
16
18
|
return JSON.parse(JSON.stringify(request));
|
|
17
19
|
}
|
|
18
|
-
const requestShape = s
|
|
19
|
-
.object({
|
|
20
|
-
id: s.string(),
|
|
21
|
-
url: s.string().url({ allowedProtocols: ['http:', 'https:'] }),
|
|
22
|
-
uniqueKey: s.string(),
|
|
23
|
-
method: s.string().optional(),
|
|
24
|
-
retryCount: s.number().int().optional(),
|
|
25
|
-
handledAt: s.union([s.string(), s.date().valid()]).optional(),
|
|
26
|
-
})
|
|
27
|
-
.passthrough();
|
|
28
|
-
const requestShapeWithoutId = requestShape.omit(['id']);
|
|
29
|
-
const batchRequestShapeWithoutId = requestShapeWithoutId.array();
|
|
30
|
-
const requestOptionsShape = s.object({
|
|
31
|
-
forefront: s.boolean().optional(),
|
|
32
|
-
});
|
|
33
20
|
/**
|
|
34
21
|
* A file-system request queue backend backed by the native `@crawlee/fs-storage-native` Rust
|
|
35
22
|
* extension.
|
|
@@ -40,15 +27,15 @@ const requestOptionsShape = s.object({
|
|
|
40
27
|
export class RequestQueueBackend extends CachedIdClient {
|
|
41
28
|
name;
|
|
42
29
|
cacheKey;
|
|
43
|
-
nativeBackend;
|
|
30
|
+
#nativeBackend;
|
|
44
31
|
constructor(options) {
|
|
45
32
|
super();
|
|
46
33
|
this.name = options.name;
|
|
47
34
|
this.cacheKey = options.cacheKey;
|
|
48
|
-
this
|
|
35
|
+
this.#nativeBackend = options.nativeBackend;
|
|
49
36
|
}
|
|
50
37
|
get requestQueueDirectory() {
|
|
51
|
-
return this
|
|
38
|
+
return this.#nativeBackend.pathToRq;
|
|
52
39
|
}
|
|
53
40
|
static async create(options) {
|
|
54
41
|
const backend = new RequestQueueBackend(options);
|
|
@@ -60,21 +47,28 @@ export class RequestQueueBackend extends CachedIdClient {
|
|
|
60
47
|
* available again.
|
|
61
48
|
*/
|
|
62
49
|
async setExpectedRequestProcessingTimeSecs(secs) {
|
|
63
|
-
await this
|
|
50
|
+
await this.#nativeBackend.setExpectedRequestProcessingTime(secs);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* @inheritdoc
|
|
54
|
+
* The lock is owned and extended by the native client; `false` means it no longer holds the request.
|
|
55
|
+
*/
|
|
56
|
+
async extendRequestProcessingTimeSecs(requestId, secs) {
|
|
57
|
+
return this.#nativeBackend.prolongRequestLock(requestId, secs);
|
|
64
58
|
}
|
|
65
59
|
async getMetadata() {
|
|
66
|
-
return this
|
|
60
|
+
return this.#nativeBackend.getMetadata();
|
|
67
61
|
}
|
|
68
62
|
async drop() {
|
|
69
|
-
await this
|
|
63
|
+
await this.#nativeBackend.dropStorage();
|
|
70
64
|
}
|
|
71
65
|
async purge() {
|
|
72
|
-
await this
|
|
66
|
+
await this.#nativeBackend.purge();
|
|
73
67
|
}
|
|
74
68
|
async addBatchOfRequests(requests, options = {}) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const response = await this
|
|
69
|
+
parseArgument(requests, schemas.storageRequestBatch);
|
|
70
|
+
parseArgument(options, schemas.requestQueueOperationOptions);
|
|
71
|
+
const response = await this.#nativeBackend.addBatchOfRequests(requests.map((request) => plainifyRequest(request)), options.forefront ?? false);
|
|
78
72
|
// `processedRequests` is structurally identical between the native and `storage` types, so it
|
|
79
73
|
// passes through unchanged. `unprocessedRequests` only differs in that the native `method` is
|
|
80
74
|
// a plain `string`, hence the cast to the narrower `AllowedHttpMethods` union.
|
|
@@ -84,29 +78,30 @@ export class RequestQueueBackend extends CachedIdClient {
|
|
|
84
78
|
};
|
|
85
79
|
}
|
|
86
80
|
async getRequest(uniqueKey) {
|
|
87
|
-
|
|
81
|
+
parseArgument(uniqueKey, uniqueKeySchema);
|
|
88
82
|
// The native client tags requests with an internal `orderNo`; it's harmless to leak, so we
|
|
89
83
|
// hand the request back as-is rather than copying it just to drop one undeclared property.
|
|
90
84
|
// The native client already returns `undefined` for a missing request, matching this contract.
|
|
91
|
-
return (await this
|
|
85
|
+
return (await this.#nativeBackend.getRequest(uniqueKey));
|
|
92
86
|
}
|
|
93
87
|
async fetchNextRequest() {
|
|
94
|
-
return (await this
|
|
88
|
+
return (await this.#nativeBackend.fetchNextRequest());
|
|
95
89
|
}
|
|
96
90
|
async markRequestAsHandled(request) {
|
|
97
|
-
|
|
98
|
-
return (await this
|
|
91
|
+
parseArgument(request, schemas.storageRequest);
|
|
92
|
+
return (await this.#nativeBackend.markRequestAsHandled(plainifyRequest(request))) ?? undefined;
|
|
99
93
|
}
|
|
100
94
|
async reclaimRequest(request, options = {}) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return ((await this
|
|
95
|
+
parseArgument(request, schemas.storageRequest);
|
|
96
|
+
parseArgument(options, schemas.requestQueueOperationOptions);
|
|
97
|
+
return ((await this.#nativeBackend.reclaimRequest(plainifyRequest(request), options.forefront ?? false)) ??
|
|
98
|
+
undefined);
|
|
104
99
|
}
|
|
105
100
|
async isEmpty() {
|
|
106
|
-
return this
|
|
101
|
+
return this.#nativeBackend.isEmpty();
|
|
107
102
|
}
|
|
108
103
|
async isFinished() {
|
|
109
|
-
return this
|
|
104
|
+
return this.#nativeBackend.isFinished();
|
|
110
105
|
}
|
|
111
106
|
/**
|
|
112
107
|
* Persist the native client's in-memory state to disk. Called by
|
|
@@ -114,6 +109,6 @@ export class RequestQueueBackend extends CachedIdClient {
|
|
|
114
109
|
* for the next consumer of the same on-disk queue.
|
|
115
110
|
*/
|
|
116
111
|
async persistState() {
|
|
117
|
-
await this
|
|
112
|
+
await this.#nativeBackend.persistState();
|
|
118
113
|
}
|
|
119
114
|
}
|