Skip to content

Commit 3a839e7

Browse files
authored
feat: cleaner worker thread for File Driver (#13)
1 parent b718b95 commit 3a839e7

File tree

14 files changed

+191
-48
lines changed

14 files changed

+191
-48
lines changed

docs/content/docs/cache_drivers.md

+10-3
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,10 @@ import { fileDriver } from "bentocache/drivers/file";
6565
const bento = new BentoCache({
6666
default: 'file',
6767
stores: {
68-
redis: bentostore().useL2Layer(fileDriver({
69-
directory: './cache'
68+
redis: bentostore().useL2Layer(
69+
fileDriver({
70+
directory: './cache',
71+
pruneInterval: '1h'
7072
}))
7173
}
7274
})
@@ -75,6 +77,11 @@ const bento = new BentoCache({
7577
| Option | Description | Default |
7678
| --- | --- | --- |
7779
| `directory` | The directory where the cache files will be stored. | N/A |
80+
| `pruneInterval` | The interval in milliseconds to prune expired entries. false to disable. | false |
81+
82+
### Prune Interval
83+
84+
Since the filesystem driver does not have a way to automatically prune expired entries, you can set a `pruneInterval` to automatically prune expired entries. By setting this option, the driver will launch a [worker thread](https://nodejs.org/api/worker_threads.html) that will clean up the cache at the specified interval.
7885

7986
## Memory
8087

@@ -165,7 +172,7 @@ All SQL drivers accept the following options:
165172
| `tableName` | The name of the table that will be used to store the cache. | `bentocache` |
166173
| `autoCreateTable` | If the cache table should be automatically created if it does not exist. | `true` |
167174
| `connection` | An instance of `knex` or `Kysely` based on the driver. | N/A |
168-
| `pruneInterval` | The interval in milliseconds to prune expired entries. | `60000` |
175+
| `pruneInterval` | The [Duration](./options.md#ttl-formats) in milliseconds to prune expired entries. | false |
169176

170177
### Knex
171178

packages/bentocache/package.json

+13-2
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,14 @@
1515
],
1616
"exports": {
1717
".": "./build/index.js",
18-
"./drivers/*": "./build/src/drivers/*.js",
18+
"./drivers/redis": "./build/src/drivers/redis.js",
19+
"./drivers/memory": "./build/src/drivers/memory.js",
20+
"./drivers/file": "./build/src/drivers/file/file.js",
21+
"./drivers/dynamodb": "./build/src/drivers/dynamodb.js",
22+
"./drivers/base_driver": "./build/src/drivers/base_driver.js",
23+
"./drivers/database": "./build/src/drivers/database/database.js",
24+
"./drivers/knex": "./build/src/drivers/database/adapters/knex.js",
25+
"./drivers/kysely": "./build/src/drivers/database/adapters/kysely.js",
1926
"./types": "./build/src/types/main.js",
2027
"./plugins/*": "./build/plugins/*.js",
2128
"./test_suite": "./build/src/test_suite.js"
@@ -32,7 +39,7 @@
3239
"quick:test": "cross-env NODE_NO_WARNINGS=1 node --enable-source-maps --loader=ts-node/esm bin/test.ts",
3340
"pretest": "pnpm lint",
3441
"test": "c8 pnpm quick:test",
35-
"build": "tsup-node",
42+
"build": "pnpm clean && tsup-node",
3643
"postbuild": "pnpm copy:templates",
3744
"release": "pnpm build && pnpm release-it",
3845
"version": "pnpm build",
@@ -113,6 +120,10 @@
113120
"./index.ts",
114121
"./src/types/main.ts",
115122
"./src/drivers/*.ts",
123+
"./src/drivers/database/database.ts",
124+
"./src/drivers/database/adapters/*.ts",
125+
"./src/drivers/file/file.ts",
126+
"./src/drivers/file/cleaner_worker.js",
116127
"./plugins/*.ts",
117128
"./src/test_suite.ts"
118129
],

packages/bentocache/src/drivers/knex.ts packages/bentocache/src/drivers/database/adapters/knex.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Knex } from 'knex'
22

3-
import { DatabaseDriver } from './database.js'
4-
import type { CreateDriverResult, DatabaseAdapter, KnexConfig } from '../types/main.js'
3+
import { DatabaseDriver } from '../database.js'
4+
import type { CreateDriverResult, DatabaseAdapter, KnexConfig } from '../../../types/main.js'
55

66
/**
77
* Create a knex driver

packages/bentocache/src/drivers/kysely.ts packages/bentocache/src/drivers/database/adapters/kysely.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { SqliteAdapter, type Kysely, MysqlAdapter } from 'kysely'
22

3-
import { DatabaseDriver } from './database.js'
4-
import type { CreateDriverResult, DatabaseAdapter, KyselyConfig } from '../types/main.js'
3+
import { DatabaseDriver } from '../database.js'
4+
import type { CreateDriverResult, DatabaseAdapter, KyselyConfig } from '../../../types/main.js'
55

66
/**
77
* Create a kysely driver

packages/bentocache/src/drivers/database.ts packages/bentocache/src/drivers/database/database.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { BaseDriver } from './base_driver.js'
2-
import type { DatabaseConfig, CacheDriver, DatabaseAdapter } from '../types/main.js'
1+
import { resolveTtl } from '../../helpers.js'
2+
import { BaseDriver } from '../base_driver.js'
3+
import type { DatabaseConfig, CacheDriver, DatabaseAdapter } from '../../types/main.js'
34

45
/**
56
* A store that use a database to store cache entries
@@ -41,9 +42,8 @@ export class DatabaseDriver extends BaseDriver implements CacheDriver<true> {
4142
this.#initialized = Promise.resolve()
4243
}
4344

44-
if (typeof config.pruneInterval === 'number') {
45-
this.#startPruneInterval(config.pruneInterval)
46-
}
45+
if (config.pruneInterval === false) return
46+
this.#startPruneInterval(resolveTtl(config.pruneInterval)!)
4747
}
4848

4949
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// @ts-check
2+
3+
import { join } from 'node:path'
4+
import { workerData } from 'node:worker_threads'
5+
import { readdir, unlink, readFile } from 'node:fs/promises'
6+
7+
const directory = workerData.directory
8+
const pruneIntervalInMs = workerData.pruneInterval
9+
10+
/**
11+
* Read the file content and delete it if it's expired
12+
*
13+
* @param {string} filePath
14+
*/
15+
async function deleteFileIfExpired(filePath) {
16+
const content = await readFile(filePath, 'utf-8')
17+
const [, expiresAt] = JSON.parse(content)
18+
19+
const expiry = new Date(expiresAt).getTime()
20+
if (+expiry === -1) return
21+
22+
if (expiry < Date.now()) {
23+
await unlink(filePath)
24+
}
25+
}
26+
27+
/**
28+
* Get recursive list of files in the cache directory and delete expired files
29+
*/
30+
async function prune() {
31+
const dirEntries = await readdir(directory, { recursive: true, withFileTypes: true })
32+
33+
for (const dirEntry of dirEntries) {
34+
if (dirEntry.isDirectory()) continue
35+
36+
const filePath = join(dirEntry.path, dirEntry.name)
37+
await deleteFileIfExpired(filePath).catch((error) => {
38+
console.error('[bentocache] file cleaner worker error', error)
39+
})
40+
}
41+
}
42+
43+
setInterval(async () => {
44+
try {
45+
await prune()
46+
} catch (error) {
47+
console.error('[bentocache] file cleaner worker error', error)
48+
}
49+
}, pruneIntervalInMs)

packages/bentocache/src/drivers/file.ts packages/bentocache/src/drivers/file/file.ts

+26-12
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
import { dirname, join } from 'node:path'
2+
import { Worker } from 'node:worker_threads'
23
import { access, mkdir, readFile, writeFile, rm } from 'node:fs/promises'
34

4-
import { BaseDriver } from './base_driver.js'
5-
import type { CacheDriver, CreateDriverResult, FileConfig } from '../types/main.js'
5+
import { resolveTtl } from '../../helpers.js'
6+
import { BaseDriver } from '../base_driver.js'
7+
import type { CacheDriver, CreateDriverResult, FileConfig } from '../../types/main.js'
68

79
/**
810
* Create a new file driver
911
*/
1012
export function fileDriver(options: FileConfig): CreateDriverResult<FileDriver> {
11-
return {
12-
options,
13-
factory: (config: FileConfig) => new FileDriver(config),
14-
}
13+
return { options, factory: (config: FileConfig) => new FileDriver(config) }
1514
}
1615

1716
/**
@@ -30,12 +29,28 @@ export class FileDriver extends BaseDriver implements CacheDriver {
3029
*/
3130
#directory: string
3231

32+
/**
33+
* Worker thread that will clean up the expired files
34+
*/
35+
#cleanerWorker?: Worker
36+
3337
declare config: FileConfig
3438

35-
constructor(config: FileConfig) {
39+
constructor(config: FileConfig, isNamespace: boolean = false) {
3640
super(config)
3741

3842
this.#directory = this.#sanitizePath(join(config.directory, config.prefix || ''))
43+
44+
/**
45+
* If this is a namespaced class, then we should not start the cleaner
46+
* worker multiple times. Only the parent class will take care of it.
47+
*/
48+
if (isNamespace) return
49+
if (config.pruneInterval === false) return
50+
51+
this.#cleanerWorker = new Worker(new URL('./cleaner_worker.js', import.meta.url), {
52+
workerData: { directory: this.#directory, pruneInterval: resolveTtl(config.pruneInterval) },
53+
})
3954
}
4055

4156
/**
@@ -95,10 +110,7 @@ export class FileDriver extends BaseDriver implements CacheDriver {
95110
* Returns a new instance of the driver namespaced
96111
*/
97112
namespace(namespace: string) {
98-
return new FileDriver({
99-
...this.config,
100-
prefix: this.createNamespacePrefix(namespace),
101-
})
113+
return new FileDriver({ ...this.config, prefix: this.createNamespacePrefix(namespace) }, true)
102114
}
103115

104116
/**
@@ -217,5 +229,7 @@ export class FileDriver extends BaseDriver implements CacheDriver {
217229
return true
218230
}
219231

220-
async disconnect() {}
232+
async disconnect() {
233+
await this.#cleanerWorker?.terminate()
234+
}
221235
}

packages/bentocache/src/types/options/drivers_options.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type { Kysely } from 'kysely'
33
import type { DynamoDBClientConfig } from '@aws-sdk/client-dynamodb'
44
import type { Redis as IoRedis, RedisOptions as IoRedisOptions } from 'ioredis'
55

6+
import type { Duration } from '../helpers.js'
7+
68
/**
79
* Options that are common to all drivers
810
*
@@ -87,6 +89,14 @@ export type FileConfig = {
8789
* Directory where the cache files will be stored
8890
*/
8991
directory: string
92+
93+
/**
94+
* The interval between each expired entry pruning
95+
* Can be set to `false` to disable pruning.
96+
*
97+
* @default false
98+
*/
99+
pruneInterval?: Duration | false
90100
} & DriverCommonOptions
91101

92102
/**
@@ -110,7 +120,7 @@ export interface DatabaseConfig extends DriverCommonOptions {
110120
*
111121
* @default false
112122
*/
113-
pruneInterval?: number | false
123+
pruneInterval?: Duration | false
114124
}
115125

116126
/**

packages/bentocache/test_helpers/driver_test_suite.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ export function registerCacheDriverTestSuite(options: {
2020
const { test, group } = options
2121
const sleepTime = options.supportsMilliseconds ? 20 : 1000
2222

23-
const cache = options.createDriver()
23+
let cache: CacheDriver
24+
25+
group.setup(async () => {
26+
cache = options.createDriver()
27+
return () => cache.disconnect()
28+
})
2429

2530
group.tap((t) => t.disableTimeout())
2631
group.each.teardown(async () => {
@@ -29,10 +34,6 @@ export function registerCacheDriverTestSuite(options: {
2934

3035
options.configureGroup?.(group)
3136

32-
group.teardown(async () => {
33-
await cache.disconnect()
34-
})
35-
3637
test('get() returns undefined when key does not exists', async ({ assert }) => {
3738
assert.deepEqual(await cache.get('key'), undefined)
3839
})

packages/bentocache/tests/drivers/database.spec.ts

+12-11
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,18 @@ import knex from 'knex'
22
import { test } from '@japa/runner'
33
import { setTimeout } from 'node:timers/promises'
44

5-
import { KnexAdapter } from '../../src/drivers/knex.js'
6-
import { DatabaseDriver } from '../../src/drivers/database.js'
5+
import { DatabaseDriver } from '../../src/drivers/database/database.js'
6+
import { KnexAdapter } from '../../src/drivers/database/adapters/knex.js'
77

88
test.group('Database', () => {
99
test('should prune expired items every x seconds', async ({ assert, cleanup }) => {
10-
const adapter = new KnexAdapter({
11-
connection: knex({
12-
client: 'better-sqlite3',
13-
connection: { filename: ':memory:' },
14-
useNullAsDefault: true,
15-
}),
10+
const db = knex({
11+
client: 'better-sqlite3',
12+
connection: { filename: ':memory:' },
13+
useNullAsDefault: true,
1614
})
1715

18-
const driver = new DatabaseDriver(adapter, {
16+
const driver = new DatabaseDriver(new KnexAdapter({ connection: db }), {
1917
tableName: 'cache',
2018
autoCreateTable: true,
2119
pruneInterval: 500,
@@ -30,7 +28,10 @@ test.group('Database', () => {
3028

3129
await setTimeout(1000)
3230

33-
assert.isUndefined(await driver.get('foo'))
34-
assert.isUndefined(await driver.get('foo2'))
31+
const hasFoo = await db('cache').where('key', 'foo').first()
32+
const hasFoo2 = await db('cache').where('key', 'foo2').first()
33+
34+
assert.isUndefined(hasFoo)
35+
assert.isUndefined(hasFoo2)
3536
})
3637
})

packages/bentocache/tests/drivers/file.spec.ts

+50-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { test } from '@japa/runner'
22
import { fileURLToPath } from 'node:url'
3+
import { setTimeout } from 'node:timers/promises'
34

4-
import { FileDriver } from '../../src/drivers/file.js'
55
import { BASE_URL } from '../../test_helpers/index.js'
6+
import { FileDriver } from '../../src/drivers/file/file.js'
67
import { registerCacheDriverTestSuite } from '../../test_helpers/driver_test_suite.js'
78

89
test.group('File driver', (group) => {
@@ -14,3 +15,51 @@ test.group('File driver', (group) => {
1415
},
1516
})
1617
})
18+
19+
test.group('File Driver | Prune', () => {
20+
test('should prune expired items every x seconds', async ({ assert, fs, cleanup }) => {
21+
const driver = new FileDriver({
22+
pruneInterval: 500,
23+
directory: fileURLToPath(BASE_URL),
24+
})
25+
26+
cleanup(() => driver.disconnect())
27+
28+
await Promise.all([
29+
driver.set('foo', 'bar', 300),
30+
driver.set('foo2', 'bar', 300),
31+
driver.set('foo3:1', 'bar', 300),
32+
driver.set('foo4', 'bar', undefined),
33+
])
34+
35+
await setTimeout(1000)
36+
37+
assert.isFalse(await fs.exists('foo'))
38+
assert.isFalse(await fs.exists('foo2'))
39+
assert.isFalse(await fs.exists('foo3/1'))
40+
assert.isTrue(await fs.exists('foo4'))
41+
})
42+
43+
test('continue if invalid file is inside the cache directory', async ({
44+
assert,
45+
fs,
46+
cleanup,
47+
}) => {
48+
const driver = new FileDriver({
49+
pruneInterval: 500,
50+
directory: fileURLToPath(BASE_URL),
51+
})
52+
53+
cleanup(() => driver.disconnect())
54+
55+
await fs.create('foo', 'invalid content')
56+
57+
await Promise.all([driver.set('foo2', 'bar', 300), driver.set('foo3:1', 'bar', 300)])
58+
59+
await setTimeout(1000)
60+
61+
assert.isTrue(await fs.exists('foo'))
62+
assert.isFalse(await fs.exists('foo2'))
63+
assert.isFalse(await fs.exists('foo3/1'))
64+
})
65+
})

0 commit comments

Comments
 (0)