Skip to content

Commit 4f88083

Browse files
committed
use S3 as backend instead of local files!
1 parent 39538b3 commit 4f88083

7 files changed

Lines changed: 435 additions & 52 deletions

File tree

README.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -145,11 +145,16 @@ This node is not yet verified for n8n Cloud. Please use a self-hosted n8n instan
145145

146146
| Parameter | Type | Default | Description |
147147
|-----------|------|---------|-------------|
148+
| **S3 Bucket** | String | "" | Bucket where cache objects are stored |
149+
| **Path Prefix** | String | `$smartcache` | Prefix used for keys inside the bucket |
148150
| **Batch Mode** | Boolean | false | Whether to process all input items as a single unit |
149151
| **Force Miss** | Boolean | false | Whether to force cache miss and regenerate data |
150152
| **Cache Key Fields** | String | "" | Comma-separated fields for cache key (empty = all fields) |
151153
| **TTL (Hours)** | Number | 24 | Cache expiration time (0 = infinite) |
152-
| **Cache Directory** | String | `/tmp/n8n-smartcache` | Directory to store cache files |
154+
155+
Note on persistence:
156+
- SmartCache requires the built-in "S3" credential and persists to your S3-compatible bucket using the provided Path Prefix.
157+
- For self-hosted or S3-compatible services with self-signed certificates, enable "Ignore SSL" in your S3 credential, or use an HTTP endpoint if appropriate.
153158

154159
### Input/Output Design
155160

@@ -281,27 +286,27 @@ SmartCache generates unique cache keys using:
281286

282287
**Cache not working:**
283288

284-
- Verify cache directory exists and is writable
289+
- When using S3, ensure the bucket exists and your IAM user has `s3:HeadObject`, `s3:GetObject`, and `s3:PutObject` permissions for the chosen prefix
285290
- Check that input data structure is consistent
286291
- Ensure node ID remains stable across executions
287292

288293
**High memory usage:**
289294

290295
- Consider using `cacheKeyFields` to limit cache key data
291296
- Implement cache cleanup for old files
292-
- Monitor cache directory size
297+
- For persistent S3 caches, consider enabling TTL to limit object growth
293298

294299
**Permission errors:**
295300

296-
- Ensure n8n process has read/write access to cache directory
297-
- Use absolute paths for cache directory
301+
- Ensure S3 credentials are valid and region/endpoint are correct
302+
- Use a distinct prefix per environment or workflow to avoid collisions
298303

299304
## 📈 Monitoring & Analytics
300305

301306
SmartCache provides detailed logging:
302307

303308
```
304-
[SmartCache] Generated cache metadata: {cacheKey: "abc123", cachePath: "/tmp/cache/abc123.cache"}
309+
[SmartCache] Generated cache metadata: {cacheKey: "abc123", cachePath: "$smartcache/abc123.cache"}
305310
[SmartCache] Cache hit: {cacheKey: "abc123", cacheAge: 2.5}
306311
[SmartCache] Cache miss: {cacheKey: "def456", reason: "File not found"}
307312
[SmartCache] Finished processing: {totalItems: 10, cacheHits: 8, cacheMisses: 2}

nodes/SmartCache/SmartCache.node.ts

Lines changed: 125 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77
*/
88

99
import { createHash } from 'node:crypto'
10-
import { promises as fs } from 'node:fs'
11-
import * as path from 'node:path'
1210
import { strict as assert } from 'node:assert'
1311
import {
1412
IContextObject,
@@ -17,8 +15,11 @@ import {
1715
INodeType,
1816
INodeTypeDescription,
1917
NodeConnectionType,
18+
NodeOperationError,
2019
} from 'n8n-workflow'
2120

21+
import { CacheBackend, S3Backend, joinPrefix } from './storage'
22+
2223
const getItemIndex = (pairedItem: INodeExecutionData['pairedItem']): number => {
2324
if (Array.isArray(pairedItem)) {
2425
return pairedItem[0]?.item ?? 0
@@ -54,7 +55,7 @@ const processItemData = (item: INodeExecutionData, cacheKeyFields: string) =>
5455
const generateCacheMetadata = (
5556
items: INodeExecutionData | INodeExecutionData[],
5657
cacheKeyFields: string,
57-
cacheDir: string,
58+
prefix: string,
5859
nodeId: string,
5960
) => {
6061
const dataToHash = Array.isArray(items)
@@ -88,51 +89,58 @@ const generateCacheMetadata = (
8889
const hash = createHash('sha256')
8990
.update(JSON.stringify({ nodeId, data: sortedData }))
9091
.digest('hex')
91-
const cachePath = path.join(cacheDir, `${hash}.cache`)
92+
const objectKey = joinPrefix(prefix, `${hash}.cache`)
9293

9394
return {
9495
cacheKey: hash,
95-
cachePath,
96+
cachePath: objectKey,
9697
}
9798
}
9899

99-
const writeToCacheFile = async (
100+
const writeToCache = async (
100101
items: INodeExecutionData | INodeExecutionData[],
101102
context: IContextObject,
102-
batchMode = false,
103+
backend?: CacheBackend,
103104
) => {
104105
// If array, any item serves as they all share the same $smartcache object
105106
const firstItem = Array.isArray(items) ? items[0] : items
106107
assert(firstItem, 'Items cannot be empty')
107108
const cachePath = getCachePathFromItem(firstItem, context)
108-
await fs.mkdir(path.dirname(cachePath), { recursive: true })
109-
await fs.writeFile(cachePath, JSON.stringify(items))
110-
console.log(`Wrote ${batchMode ? 'batch' : 'item'} to cache file: ${cachePath}`)
109+
assert(backend, 'Cache backend not available')
110+
await backend.put(cachePath, items)
111+
console.debug(`[SmartCache] Wrote to cache at ${cachePath}`)
111112
}
112113

113-
const handleCacheHit = async (cachePath: string, ttl: number) => {
114-
const stats = await fs.stat(cachePath)
115-
const cacheAge = (Date.now() - stats.mtime.getTime()) / (1000 * 60 * 60)
116-
117-
if (ttl > 0 && cacheAge >= ttl) {
118-
return { status: 'expired', cacheAge }
114+
const handleCacheHit = async (
115+
cachePath: string,
116+
ttl: number,
117+
backend: CacheBackend,
118+
) => {
119+
const head = await backend.head(cachePath)
120+
if (!head) return { status: 'miss' as const }
121+
if (ttl > 0) {
122+
const cacheAge = (Date.now() - head.lastModified.getTime()) / (1000 * 60 * 60)
123+
if (cacheAge >= ttl) {
124+
return { status: 'expired' as const, cacheAge }
125+
}
119126
}
120-
121-
const cachedContent = JSON.parse(await fs.readFile(cachePath, 'utf-8'))
122-
return { status: 'hit', content: cachedContent }
127+
const content = await backend.get(cachePath)
128+
if (content == null) return { status: 'miss' as const }
129+
return { status: 'hit' as const, content }
123130
}
124131

125132
const processBatch = async (
126133
items: INodeExecutionData[],
127134
context: IContextObject,
128135
cacheKeyFields: string,
129-
cacheDir: string,
136+
prefix: string,
130137
force: boolean,
131138
ttl: number,
132139
logger: IExecuteFunctions['logger'],
133140
nodeId: string,
141+
backend: CacheBackend,
134142
) => {
135-
const $smartCache = generateCacheMetadata(items, cacheKeyFields, cacheDir, nodeId)
143+
const $smartCache = generateCacheMetadata(items, cacheKeyFields, prefix, nodeId)
136144

137145
// Store cache metadata for each item
138146
items.forEach((item) => {
@@ -152,7 +160,7 @@ const processBatch = async (
152160
}
153161

154162
try {
155-
const result = await handleCacheHit($smartCache.cachePath, ttl)
163+
const result = await handleCacheHit($smartCache.cachePath, ttl, backend)
156164
if (result.status === 'hit') {
157165
return { hits: Array.isArray(result.content) ? result.content : [result.content], misses: [] }
158166
}
@@ -170,13 +178,14 @@ const processSingleItem = async (
170178
item: INodeExecutionData,
171179
context: IContextObject,
172180
cacheKeyFields: string,
173-
cacheDir: string,
181+
prefix: string,
174182
force: boolean,
175183
ttl: number,
176184
logger: IExecuteFunctions['logger'],
177185
nodeId: string,
186+
backend: CacheBackend,
178187
) => {
179-
const $smartCache = generateCacheMetadata(item, cacheKeyFields, cacheDir, nodeId)
188+
const $smartCache = generateCacheMetadata(item, cacheKeyFields, prefix, nodeId)
180189
const itemIndex = getItemIndex(item.pairedItem)
181190
context[itemIndex] = $smartCache
182191

@@ -192,7 +201,7 @@ const processSingleItem = async (
192201
}
193202

194203
try {
195-
const result = await handleCacheHit($smartCache.cachePath, ttl)
204+
const result = await handleCacheHit($smartCache.cachePath, ttl, backend)
196205
if (result.status === 'hit') {
197206
return { hit: result.content, miss: null }
198207
}
@@ -213,7 +222,8 @@ export class SmartCache implements INodeType {
213222
icon: 'file:smartCache.svg',
214223
group: ['transform'],
215224
version: 1,
216-
description: 'Intelligent caching node with automatic hash generation and TTL support',
225+
description:
226+
'Intelligent caching node with automatic hash generation and TTL support. Persists cache objects to S3 (or S3-compatible) storage.',
217227
subtitle:
218228
'={{ ($parameter["batchMode"] ? "Batch" : "Individual") + ($parameter["force"] ? " • ⚠️ Force Miss" : "") }}',
219229
documentationUrl: 'https://github.com/skadaai/n8n-nodes-smartcache#readme',
@@ -243,7 +253,31 @@ export class SmartCache implements INodeType {
243253
required: true,
244254
},
245255
],
256+
/* eslint-disable n8n-nodes-base/node-class-description-credentials-name-unsuffixed */
257+
credentials: [
258+
{
259+
name: 's3',
260+
required: true,
261+
},
262+
],
263+
/* eslint-enable n8n-nodes-base/node-class-description-credentials-name-unsuffixed */
246264
properties: [
265+
{
266+
displayName: 'S3 Bucket',
267+
name: 'bucket',
268+
type: 'string',
269+
default: '',
270+
description: 'Bucket where cache objects are stored',
271+
noDataExpression: true,
272+
},
273+
{
274+
displayName: 'Path Prefix',
275+
name: 'cacheDir',
276+
type: 'string',
277+
default: 'smartcache',
278+
description: 'Prefix for object keys inside the S3 bucket (e.g., $smartcache)',
279+
noDataExpression: true,
280+
},
247281
{
248282
displayName: 'Batch Mode',
249283
name: 'batchMode',
@@ -276,15 +310,6 @@ export class SmartCache implements INodeType {
276310
default: 24,
277311
description: 'Time-to-live for cache entries in hours. Use 0 for infinite.',
278312
},
279-
{
280-
displayName: 'Cache Directory',
281-
name: 'cacheDir',
282-
type: 'string',
283-
default: '/tmp/n8n-smartcache',
284-
description:
285-
'Directory where cache files will be stored. Must be writable by the n8n process.',
286-
noDataExpression: true,
287-
},
288313
],
289314
}
290315

@@ -295,15 +320,61 @@ export class SmartCache implements INodeType {
295320
const cacheKeyFields = (this.getNodeParameter('cacheKeyFields', 0) as string).trim()
296321
const batchMode = this.getNodeParameter('batchMode', 0) as boolean
297322
const context = this.getContext('node')
323+
if (force) {
324+
this.logger.warn('[SmartCache] Force Miss is enabled: cache reads will be bypassed and new objects will be written')
325+
}
326+
// Create S3 backend (credentials are required by node definition)
327+
const creds = (await this.getCredentials('s3')) as unknown as {
328+
accessKeyId: string
329+
secretAccessKey: string
330+
region: string
331+
endpoint?: string
332+
forcePathStyle?: boolean
333+
ignoreSSL?: boolean
334+
}
335+
if (!creds) {
336+
throw new NodeOperationError(this.getNode(), 'S3 credentials are required')
337+
}
338+
const bucket = (this.getNodeParameter('bucket', 0) as string).trim()
339+
if (!bucket) {
340+
throw new NodeOperationError(this.getNode(), 'S3 bucket is required')
341+
}
342+
const backend: CacheBackend = new S3Backend(
343+
{
344+
accessKeyId: String(creds.accessKeyId),
345+
secretAccessKey: String(creds.secretAccessKey),
346+
sessionToken: (creds as { sessionToken?: string }).sessionToken,
347+
region: String(creds.region),
348+
bucket,
349+
endpoint: creds.endpoint ? String(creds.endpoint) : undefined,
350+
forcePathStyle: creds.forcePathStyle,
351+
ignoreSSL: creds.ignoreSSL,
352+
},
353+
this.logger,
354+
this,
355+
)
356+
357+
// Validate bucket exists early with a lightweight HEAD
358+
try {
359+
if (backend instanceof S3Backend) {
360+
await backend.ensureBucketAccessible()
361+
}
362+
} catch (err) {
363+
throw new NodeOperationError(
364+
this.getNode(),
365+
err instanceof Error ? err.message : String(err),
366+
)
367+
}
298368

299369
const mainInput = this.getInputData(0) // Input 1
300370
const cacheInput = this.getInputData(1) // Input 2 (write)
301371

302372
this.logger.debug('[SmartCache] SmartCache initialized with parameters:', {
303373
force,
304374
ttl,
305-
cacheDir,
375+
cachePrefix: cacheDir,
306376
cacheKeyFields,
377+
backend: backend.constructor.name,
307378
})
308379

309380
// Early return if both inputs are empty
@@ -325,6 +396,7 @@ export class SmartCache implements INodeType {
325396
ttl,
326397
this.logger,
327398
nodeId,
399+
backend,
328400
)
329401
: await Promise.all(
330402
mainInput.map((item) =>
@@ -337,6 +409,7 @@ export class SmartCache implements INodeType {
337409
ttl,
338410
this.logger,
339411
nodeId,
412+
backend,
340413
),
341414
),
342415
).then((results) => ({
@@ -362,7 +435,14 @@ export class SmartCache implements INodeType {
362435
getItemIndex(firstItem.pairedItem) !== undefined,
363436
'Write input items must come from cache miss output',
364437
)
365-
await writeToCacheFile(cacheInput, context, true)
438+
try {
439+
await writeToCache(cacheInput, context, backend)
440+
} catch (err) {
441+
throw new NodeOperationError(
442+
this.getNode(),
443+
`Failed to persist cache to S3: ${String(err instanceof Error ? err.message : err)}`,
444+
)
445+
}
366446
return [cacheInput, []]
367447
}
368448

@@ -372,7 +452,14 @@ export class SmartCache implements INodeType {
372452
getItemIndex(item.pairedItem) !== undefined,
373453
'Write input items must come from cache miss output',
374454
)
375-
await writeToCacheFile(item, context)
455+
try {
456+
await writeToCache(item, context, backend)
457+
} catch (err) {
458+
throw new NodeOperationError(
459+
this.getNode(),
460+
`Failed to persist cache to S3: ${String(err instanceof Error ? err.message : err)}`,
461+
)
462+
}
376463
results.push(item)
377464
}
378465

0 commit comments

Comments
 (0)