Skip to content

Commit 8999388

Browse files
IsakTronag
authored andcommitted
feat(cache): reuse prepared sqlite statements. Also make :memory: cache the default.
1 parent 64e044f commit 8999388

File tree

3 files changed

+54
-92
lines changed

3 files changed

+54
-92
lines changed

lib/interceptor/cache.js

+48-87
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import assert from 'node:assert'
22
import { DecoratorHandler, parseHeaders, parseCacheControl } from '../utils.js'
33
import { DatabaseSync } from 'node:sqlite' // --experimental-sqlite
4+
import * as BJSON from 'buffer-json'
45

56
class CacheHandler extends DecoratorHandler {
67
#handler
78
#store
89
#key
910
#opts
10-
#value = null
11+
#value
1112

1213
constructor({ key, handler, store, opts = [] }) {
1314
super(handler)
@@ -25,13 +26,11 @@ class CacheHandler extends DecoratorHandler {
2526
}
2627

2728
onHeaders(statusCode, rawHeaders, resume, statusMessage, headers = parseHeaders(rawHeaders)) {
28-
if (statusCode !== 307) {
29+
if (statusCode !== 307 || statusCode !== 200) {
2930
return this.#handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers)
3031
}
3132

32-
// TODO (fix): Support vary header.
3333
const cacheControl = parseCacheControl(headers['cache-control'])
34-
3534
const contentLength = headers['content-length'] ? Number(headers['content-length']) : Infinity
3635
const maxEntrySize = this.#store.maxEntrySize ?? Infinity
3736

@@ -66,7 +65,7 @@ class CacheHandler extends DecoratorHandler {
6665
(rawHeaders?.reduce((xs, x) => xs + x.length, 0) ?? 0) +
6766
(statusMessage?.length ?? 0) +
6867
64,
69-
expires: Date.now() + ttl, // in ms!
68+
expires: Date.now() + ttl,
7069
}
7170
}
7271
}
@@ -89,20 +88,14 @@ class CacheHandler extends DecoratorHandler {
8988

9089
onComplete(rawTrailers) {
9190
if (this.#value) {
91+
const reqHeaders = this.#opts
9292
const resHeaders = parseHeaders(this.#value.data.rawHeaders)
9393

9494
// Early return if Vary = *, uncacheable.
9595
if (resHeaders.vary === '*') {
9696
return this.#handler.onComplete(rawTrailers)
9797
}
9898

99-
const reqHeaders = this.#opts
100-
101-
// If Range header present, assume that the response varies based on Range.
102-
if (reqHeaders.headers?.range) {
103-
resHeaders.vary += ', Range'
104-
}
105-
10699
this.#value.data.rawTrailers = rawTrailers
107100
this.#value.size = this.#value.size
108101
? this.#value.size + rawTrailers?.reduce((xs, x) => xs + x.length, 0)
@@ -112,6 +105,7 @@ class CacheHandler extends DecoratorHandler {
112105

113106
this.#store.set(this.#key, this.#value)
114107
}
108+
115109
return this.#handler.onComplete(rawTrailers)
116110
}
117111
}
@@ -120,20 +114,27 @@ function formatVaryData(resHeaders, reqHeaders) {
120114
return resHeaders.vary
121115
?.split(',')
122116
.map((key) => key.trim().toLowerCase())
123-
.map((key) => [key, reqHeaders[key] ?? reqHeaders.headers[key]])
124-
.filter(([_key, val]) => val)
117+
.map((key) => [key, reqHeaders[key] ?? ''])
118+
.filter(([, val]) => val)
125119
}
126120

127121
export class CacheStore {
128-
constructor() {
129-
this.database = null
130-
this.init()
131-
}
122+
#database
123+
124+
#insertquery
125+
#getQuery
126+
#purgeQuery
127+
128+
#size = 0
129+
#maxSize = 128e9
130+
131+
constructor(location = ':memory:', opts) {
132+
// TODO (fix): Validate args...
132133

133-
init() {
134-
this.database = new DatabaseSync('file:memdb1?mode=memory&cache=shared')
134+
this.#maxSize = opts.maxSize ?? this.#maxSize
135+
this.#database = new DatabaseSync(location)
135136

136-
this.database.exec(`
137+
this.#database.exec(`
137138
CREATE TABLE IF NOT EXISTS cacheInterceptor(
138139
key TEXT,
139140
data TEXT,
@@ -142,91 +143,51 @@ export class CacheStore {
142143
expires INTEGER
143144
) STRICT
144145
`)
145-
}
146-
147-
set(key, entry) {
148-
if (!this.database) {
149-
throw new Error('Database not initialized')
150-
}
151146

152-
entry.data = JSON.stringify(entry.data)
153-
entry.vary = JSON.stringify(entry.vary)
154-
155-
const insert = this.database.prepare(
147+
this.#insertquery = this.#database.prepare(
156148
'INSERT INTO cacheInterceptor (key, data, vary, size, expires) VALUES (?, ?, ?, ?, ?)',
157149
)
158150

159-
insert.run(key, entry.data, entry.vary, entry.size, entry.expires)
151+
this.#getQuery = this.#database.prepare(
152+
'SELECT * FROM cacheInterceptor WHERE key = ? AND expires > ? ',
153+
)
154+
155+
this.#purgeQuery = this.#database.prepare('DELETE FROM cacheInterceptor WHERE expires < ?')
160156

161-
this.purge()
157+
this.#maybePurge()
162158
}
163159

164-
get(key) {
165-
if (!this.database) {
166-
throw new Error('Database not initialized')
167-
}
168-
this.purge()
169-
const query = this.database.prepare(
170-
'SELECT * FROM cacheInterceptor WHERE key = ? AND expires > ? ',
171-
)
172-
const rows = query.all(key, Date.now())
173-
rows.map((i) => {
174-
i.data = JSON.parse(i.data)
175-
i.vary = JSON.parse(i.vary)
176-
i.data = {
177-
...i.data,
178-
// JSON.parse doesn't convert a Buffer object back to a Buffer object once it has been stringified.
179-
body: this.#convertToBuffer(i.data.body),
180-
rawHeaders: this.#convertToBuffer(i.data.rawHeaders),
181-
rawTrailers: this.#convertToBuffer(i.data.rawTrailers),
182-
}
183-
return i
184-
})
160+
set(key, { data, vary, size, expires }) {
161+
this.#insertquery.run(key, JSON.stringify(data), BJSON.stringify(vary), size, expires)
185162

186-
return rows
163+
this.#size += size
164+
this.#maybePurge()
187165
}
188166

189-
purge() {
190-
const query = this.database.prepare('DELETE FROM cacheInterceptor WHERE expires < ?')
191-
query.run(Date.now())
167+
get(key) {
168+
return this.#getQuery.all(key, Date.now()).map(({ data, vary, size, expires }) => ({
169+
data: BJSON.parse(data),
170+
vary: JSON.parse(vary),
171+
size: parseInt(size), // TODO (fix): Is parseInt necessary?
172+
expores: parseInt(expires), // TODO (fix): Is parseInt necessary?
173+
}))
192174
}
193175

194-
deleteAll() {
195-
const query = this.database.prepare('DELETE FROM cacheInterceptor')
196-
query.run()
176+
close() {
177+
this.#database.close()
197178
}
198179

199-
#convertToBuffer(bufferArray) {
200-
if (Array.isArray(bufferArray) && bufferArray.length > 0) {
201-
return bufferArray.map((ba) => {
202-
return typeof ba === 'object' ? Buffer.from(ba.data) : ba
203-
})
180+
#maybePurge() {
181+
if (this.#size == null || this.#size > this.#maxSize) {
182+
this.#purgeQuery.run(Date.now())
183+
this.#size = this.#database.exec('SELECT SUM(size) FROM cacheInterceptor')[0].values[0][0]
204184
}
205-
return []
206185
}
207186
}
208187

209-
/*
210-
Sort entries by number of vary headers in descending order, because
211-
we need to compare the most complex response to the request first.
212-
A cached response with an empty ´vary´ field will otherwise win every time.
213-
*/
214-
function sortEntriesByVary(entries) {
215-
entries.sort((a, b) => {
216-
const lengthA = a.vary ? a.vary.length : 0
217-
const lengthB = b.vary ? b.vary.length : 0
218-
return lengthB - lengthA
219-
})
220-
}
221-
222188
function findEntryByHeaders(entries, reqHeaders) {
223-
sortEntriesByVary(entries)
224-
225189
return entries?.find(
226-
(entry) =>
227-
entry.vary?.every(([key, val]) => {
228-
return reqHeaders?.headers[key] === val
229-
}) ?? true,
190+
(entry) => entry.vary?.every(([key, val]) => reqHeaders?.headers[key] === val) ?? true,
230191
)
231192
}
232193

@@ -276,7 +237,7 @@ export default (opts) => (dispatch) => (opts, handler) => {
276237

277238
const key = `${opts.method}:${opts.path}`
278239

279-
const entries = (store.get(key) ?? opts.method === 'HEAD') ? store.get(`GET:${opts.path}`) : null
240+
const entries = store.get(key) ?? (opts.method === 'HEAD' ? store.get(`GET:${opts.path}`) : null)
280241

281242
const entry = findEntryByHeaders(entries, opts)
282243

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"lib/*"
1010
],
1111
"dependencies": {
12+
"buffer-json": "^2.0.0",
1213
"cache-control-parser": "^2.0.6",
1314
"cacheable-lookup": "^7.0.0",
1415
"http-errors": "^2.0.0",

test/cache.js

+5-5
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ test('If no matching entry found, store the response in cache. Else return a mat
133133
// response not found in cache, response should be added to cache.
134134
const response = await undici.request(`http://0.0.0.0:${serverPort}`, {
135135
dispatcher: new undici.Agent().compose(interceptors.cache()),
136-
cache: true,
136+
cache,
137137
})
138138
let str = ''
139139
for await (const chunk of response.body) {
@@ -154,7 +154,7 @@ test('If no matching entry found, store the response in cache. Else return a mat
154154
'User-Agent': 'Chrome',
155155
origin2: 'www.google.com/images',
156156
},
157-
cache: true,
157+
cache,
158158
})
159159
let str2 = ''
160160
for await (const chunk of response2.body) {
@@ -200,7 +200,7 @@ test('Responses with header Vary: * should not be cached', async (t) => {
200200
// But the server returns Vary: *, and thus shouldn't be cached.
201201
const response = await undici.request(`http://0.0.0.0:${serverPort}`, {
202202
dispatcher: new undici.Agent().compose(interceptors.cache()),
203-
cache: true,
203+
cache,
204204
headers: {
205205
Accept: 'application/txt',
206206
'User-Agent': 'Chrome',
@@ -247,7 +247,7 @@ test('Store 307-status-responses that happen to be dependent on the Range header
247247

248248
const request1 = undici.request(`http://0.0.0.0:${serverPort}`, {
249249
dispatcher: new undici.Agent().compose(interceptors.cache()),
250-
cache: true,
250+
cache,
251251
headers: {
252252
range: 'bytes=0-999',
253253
},
@@ -269,7 +269,7 @@ test('Store 307-status-responses that happen to be dependent on the Range header
269269

270270
const request2 = undici.request(`http://0.0.0.0:${serverPort}`, {
271271
dispatcher: new undici.Agent().compose(interceptors.cache()),
272-
cache: true,
272+
cache,
273273
headers: {
274274
range: 'bytes=0-999',
275275
},

0 commit comments

Comments
 (0)