@isaacs/ttlcache 1.0.1 → 1.0.4

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.
Files changed (3) hide show
  1. package/README.md +39 -1
  2. package/index.js +67 -31
  3. package/package.json +28 -3
package/README.md CHANGED
@@ -61,11 +61,22 @@ Create a new `TTLCache` object.
61
61
  retrieved? Defaults to `false`. Overridable on the `get()` method.
62
62
  * `noUpdateTTL` Should setting a new value for an existing key leave the
63
63
  TTL unchanged? Defaults to `false`. Overridable on the `set()` method.
64
+ (Note that TTL is _always_ updated if the item is expired, since that is
65
+ treated as a new `set()` and the old item is no longer relevant.)
64
66
  * `dispose` Method called with `(value, key, reason)` when an item is
65
67
  removed from the cache. Called once item is fully removed from cache.
66
68
  It is safe to re-add at this point, but note that adding when `reason` is
67
69
  `'set'` can result in infinite recursion if `noDisponseOnSet` is not
68
70
  specified.
71
+
72
+ Disposal reasons:
73
+
74
+ * `'stale'` TTL expired.
75
+ * `'set'` Overwritten with a new different value.
76
+ * `'evict'` Removed from the cache to stay within capacity limit.
77
+ * `'delete'` Explicitly deleted with `cache.delete()` or
78
+ `cache.clear()`
79
+
69
80
  * `noDisposeOnSet` Do not call `dispose()` method when overwriting a key
70
81
  with a new value. Defaults to `false`. Overridable on `set()` method.
71
82
 
@@ -113,7 +124,8 @@ Delete all items from the cache.
113
124
  ### `cache.entries()`
114
125
 
115
126
  Return an iterator that walks through each `[key, value]` from soonest
116
- expiring to latest expiring.
127
+ expiring to latest expiring. (Items expiring at the same time are walked
128
+ in insertion order.)
117
129
 
118
130
  Default iteration method for the cache object.
119
131
 
@@ -150,3 +162,29 @@ automatically.
150
162
 
151
163
  Called when an item is removed from the cache and should be disposed. Set
152
164
  this on the constructor options.
165
+
166
+ ## Algorithm
167
+
168
+ The cache uses two `Map` objects. The first maps item keys to their
169
+ expiration time, and the second maps item keys to their values. Then, a
170
+ null-prototype object uses the expiration time as keys, with the value
171
+ being an array of all the keys expiring at that time.
172
+
173
+ This leverages a few important features of modern JavaScript engines for
174
+ fairly good performance:
175
+
176
+ - `Map` objects are highly optimized for referring to arbitrary values by
177
+ arbitrary keys.
178
+ - Objects with solely integer-numeric keys are iterated in sorted numeric
179
+ order rather than insertion order, and insertions in the middle of the
180
+ key ordering are still very fast. This is true of all modern JS engines
181
+ tested at the time of this module's creation, but most particularly V8
182
+ (the engine in Node.js).
183
+
184
+ When it is time to prune, we can always walk the null-prototype object in
185
+ iteration order, deleting items until we come to the first key greater than
186
+ the current time.
187
+
188
+ Thus, the `start` time doesn't need to be tracked, only the expiration
189
+ time. When an item age is updated (either explicitly on `get()`, or by
190
+ setting to a new value), it is deleted and re-inserted.
package/index.js CHANGED
@@ -3,18 +3,24 @@
3
3
  // Relies on the fact that integer Object keys are kept sorted,
4
4
  // and managed very efficiently by V8.
5
5
 
6
- const maybeReqPerfHooks = (fallback) => {
6
+ const maybeReqPerfHooks = fallback => {
7
7
  try {
8
8
  return require('perf_hooks').performance
9
9
  } catch (e) {
10
10
  return fallback
11
11
  }
12
12
  }
13
- const {now} = maybeReqPerfHooks(Date)
13
+ const { now } = maybeReqPerfHooks(Date)
14
14
  const isPosInt = n => n && n === Math.floor(n) && n > 0 && isFinite(n)
15
15
 
16
16
  class TTLCache {
17
- constructor ({ max = Infinity, ttl, updateAgeOnGet = false, noUpdateTTL = false, dispose }) {
17
+ constructor({
18
+ max = Infinity,
19
+ ttl,
20
+ updateAgeOnGet = false,
21
+ noUpdateTTL = false,
22
+ dispose,
23
+ }) {
18
24
  // {[expirationTime]: [keys]}
19
25
  this.expirations = Object.create(null)
20
26
  // {key=>val}
@@ -29,6 +35,8 @@ class TTLCache {
29
35
  }
30
36
  this.ttl = ttl
31
37
  this.max = max
38
+ this.updateAgeOnGet = updateAgeOnGet
39
+ this.noUpdateTTL = noUpdateTTL
32
40
  if (dispose !== undefined) {
33
41
  if (typeof dispose !== 'function') {
34
42
  throw new TypeError('dispose must be function if set')
@@ -37,8 +45,9 @@ class TTLCache {
37
45
  }
38
46
  }
39
47
 
40
- clear () {
41
- const entries = this.dispose !== TTLCache.prototype.dispose ? [...this] : []
48
+ clear() {
49
+ const entries =
50
+ this.dispose !== TTLCache.prototype.dispose ? [...this] : []
42
51
  this.data.clear()
43
52
  this.expirationMap.clear()
44
53
  this.expirations = Object.create(null)
@@ -47,14 +56,25 @@ class TTLCache {
47
56
  }
48
57
  }
49
58
 
50
- set (key, val, { ttl = this.ttl, noUpdateTTL = this.noUpdateTTL, noDisposeOnSet = this.noDisposeOnSet } = {}) {
59
+ set(
60
+ key,
61
+ val,
62
+ {
63
+ ttl = this.ttl,
64
+ noUpdateTTL = this.noUpdateTTL,
65
+ noDisposeOnSet = this.noDisposeOnSet,
66
+ } = {}
67
+ ) {
51
68
  if (!isPosInt(ttl)) {
52
69
  throw new TypeError('ttl must be positive integer')
53
70
  }
54
71
  const current = this.expirationMap.get(key)
72
+ const time = now()
73
+ const oldValue =
74
+ current === undefined ? undefined : this.data.get(key)
55
75
  if (current !== undefined) {
56
- const oldValue = this.data.get(key)
57
- if (noUpdateTTL) {
76
+ // we aren't updating the ttl, so just set the data
77
+ if (noUpdateTTL && current > time) {
58
78
  if (oldValue !== val) {
59
79
  this.data.set(key, val)
60
80
  if (!noDisposeOnSet) {
@@ -63,13 +83,17 @@ class TTLCache {
63
83
  }
64
84
  return this
65
85
  } else {
66
- this.delete(key, {
67
- reason: 'set',
68
- noDispose: noDisposeOnSet || oldValue === val,
69
- })
86
+ // just delete from expirations list, since we're about to
87
+ // add to data and expirationsMap anyway
88
+ const exp = this.expirations[current]
89
+ if (!exp || exp.length <= 1) {
90
+ delete this.expirations[current]
91
+ } else {
92
+ this.expirations[current] = exp.filter(k => k !== key)
93
+ }
70
94
  }
71
95
  }
72
- const expiration = Math.ceil(now() + ttl)
96
+ const expiration = Math.ceil(time + ttl)
73
97
  this.expirationMap.set(key, expiration)
74
98
  this.data.set(key, val)
75
99
  if (!this.expirations[expiration]) {
@@ -79,52 +103,64 @@ class TTLCache {
79
103
  this.expirations[expiration] = []
80
104
  }
81
105
  this.expirations[expiration].push(key)
106
+
82
107
  while (this.size > this.max) {
83
108
  this.purgeToCapacity()
84
109
  }
110
+
111
+ if (!noDisposeOnSet && current && oldValue !== val) {
112
+ this.dispose(oldValue, key, 'set')
113
+ }
85
114
  return this
86
115
  }
87
116
 
88
- has (key) {
117
+ has(key) {
89
118
  return this.data.has(key)
90
119
  }
91
120
 
92
- getRemainingTTL (key) {
121
+ getRemainingTTL(key) {
93
122
  const expiration = this.expirationMap.get(key)
94
- return expiration !== undefined ? Math.max(0, expiration - now()) : 0
123
+ return expiration !== undefined
124
+ ? Math.max(0, expiration - now())
125
+ : 0
95
126
  }
96
127
 
97
- get (key, { updateAgeOnGet = this.updateAgeOnGet, ttl = this.ttl } = {}) {
128
+ get(
129
+ key,
130
+ { updateAgeOnGet = this.updateAgeOnGet, ttl = this.ttl } = {}
131
+ ) {
98
132
  const val = this.data.get(key)
99
133
  if (updateAgeOnGet) {
100
- this.set(key, val, { noUpdateTTL: false, noDisposeOnSet: true, ttl })
134
+ this.set(key, val, {
135
+ noUpdateTTL: false,
136
+ noDisposeOnSet: true,
137
+ ttl,
138
+ })
101
139
  }
102
140
  return val
103
141
  }
104
142
 
105
- dispose (value, key) {}
143
+ dispose(_, __) {}
106
144
 
107
- delete (key, { reason = 'delete', noDispose = false } = {}) {
145
+ delete(key) {
108
146
  const current = this.expirationMap.get(key)
109
147
  if (current !== undefined) {
110
148
  const value = this.data.get(key)
111
149
  this.data.delete(key)
112
150
  this.expirationMap.delete(key)
113
151
  const exp = this.expirations[current]
114
- if (exp.length === 1) {
152
+ if (exp && exp.length <= 1) {
115
153
  delete this.expirations[current]
116
154
  } else {
117
155
  this.expirations[current] = exp.filter(k => k !== key)
118
156
  }
119
- if (!noDispose) {
120
- this.dispose(value, key, reason)
121
- }
157
+ this.dispose(value, key, 'delete')
122
158
  return true
123
159
  }
124
160
  return false
125
161
  }
126
162
 
127
- purgeToCapacity () {
163
+ purgeToCapacity() {
128
164
  for (const exp in this.expirations) {
129
165
  const keys = this.expirations[exp]
130
166
  if (this.size - keys.length >= this.max) {
@@ -148,11 +184,11 @@ class TTLCache {
148
184
  }
149
185
  }
150
186
 
151
- get size () {
187
+ get size() {
152
188
  return this.data.size
153
189
  }
154
190
 
155
- purgeStale () {
191
+ purgeStale() {
156
192
  const n = now()
157
193
  for (const exp in this.expirations) {
158
194
  if (exp > n) {
@@ -168,28 +204,28 @@ class TTLCache {
168
204
  }
169
205
  }
170
206
 
171
- *entries () {
207
+ *entries() {
172
208
  for (const exp in this.expirations) {
173
209
  for (const key of this.expirations[exp]) {
174
210
  yield [key, this.data.get(key)]
175
211
  }
176
212
  }
177
213
  }
178
- *keys () {
214
+ *keys() {
179
215
  for (const exp in this.expirations) {
180
216
  for (const key of this.expirations[exp]) {
181
217
  yield key
182
218
  }
183
219
  }
184
220
  }
185
- *values () {
221
+ *values() {
186
222
  for (const exp in this.expirations) {
187
223
  for (const key of this.expirations[exp]) {
188
224
  yield this.data.get(key)
189
225
  }
190
226
  }
191
227
  }
192
- [Symbol.iterator] () {
228
+ [Symbol.iterator]() {
193
229
  return this.entries()
194
230
  }
195
231
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isaacs/ttlcache",
3
- "version": "1.0.1",
3
+ "version": "1.0.4",
4
4
  "files": [
5
5
  "index.js"
6
6
  ],
@@ -23,10 +23,35 @@
23
23
  "prepublishOnly": "git push origin --follow-tags"
24
24
  },
25
25
  "devDependencies": {
26
- "clock-mock": "^1.0.4",
27
- "tap": "^16.0.1"
26
+ "@types/node": "^17.0.42",
27
+ "@types/tap": "^15.0.7",
28
+ "clock-mock": "^1.0.6",
29
+ "prettier": "^2.7.0",
30
+ "tap": "^16.0.1",
31
+ "ts-node": "^10.8.1"
28
32
  },
29
33
  "engines": {
30
34
  "node": ">=12"
35
+ },
36
+ "tap": {
37
+ "nyc-arg": [
38
+ "--include=index.js"
39
+ ],
40
+ "node-arg": [
41
+ "--require",
42
+ "ts-node/register"
43
+ ],
44
+ "ts": false
45
+ },
46
+ "prettier": {
47
+ "semi": false,
48
+ "printWidth": 70,
49
+ "tabWidth": 2,
50
+ "useTabs": false,
51
+ "singleQuote": true,
52
+ "jsxSingleQuote": false,
53
+ "bracketSameLine": true,
54
+ "arrowParens": "avoid",
55
+ "endOfLine": "lf"
31
56
  }
32
57
  }