fhirsmith 0.11.1 → 0.12.0

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.
@@ -26,6 +26,30 @@ function debugLog(error, message) {
26
26
  }
27
27
  }
28
28
 
29
+ /**
30
+ * Render a duration for a human reading an error message: coarse enough to read
31
+ * at a glance, precise enough to compare against a configured timeout.
32
+ * @param {number} ms - duration in milliseconds
33
+ * @returns {string} e.g. "45 seconds", "31 minutes", "2 hours 5 minutes"
34
+ */
35
+ function formatDuration(ms) {
36
+ if (ms === null || ms === undefined || !isFinite(ms) || ms < 0) {
37
+ return 'an unknown time';
38
+ }
39
+ const seconds = Math.round(ms / 1000);
40
+ if (seconds < 90) {
41
+ return `${seconds} second${seconds === 1 ? '' : 's'}`;
42
+ }
43
+ const minutes = Math.round(seconds / 60);
44
+ if (minutes < 90) {
45
+ return `${minutes} minute${minutes === 1 ? '' : 's'}`;
46
+ }
47
+ const hours = Math.floor(minutes / 60);
48
+ const rem = minutes % 60;
49
+ const h = `${hours} hour${hours === 1 ? '' : 's'}`;
50
+ return rem === 0 ? h : `${h} ${rem} minute${rem === 1 ? '' : 's'}`;
51
+ }
52
+
29
53
 
30
54
  class TimeTracker {
31
55
  constructor() {
@@ -56,7 +80,9 @@ class TimeTracker {
56
80
  * Stores resources by cache-id for reuse across requests
57
81
  */
58
82
  class ResourceCache {
59
- constructor(stats) {
83
+ logCacheOps = false;
84
+
85
+ constructor(stats, maxTombstones = 1000) {
60
86
  this.stats = stats;
61
87
  this.cache = new Map();
62
88
  this.locks = new Map(); // For thread-safety with async operations
@@ -70,6 +96,156 @@ class ResourceCache {
70
96
  // this cache has held at once, for capacity reporting.
71
97
  this.maxConcepts = 0;
72
98
  this.maxSizeValue = 0;
99
+ // Tombstones: why a cache-id that this server *did* issue is no longer here.
100
+ // Without these, an unknown cache-id is indistinguishable from one that was
101
+ // never issued, and the client can't tell "your build released it early" from
102
+ // "it sat idle too long" from "you're talking to the wrong server/instance" -
103
+ // which is exactly the ambiguity that makes cache-id-unknown reports (see
104
+ // issue #279) unfalsifiable. Bounded and insertion-ordered: oldest evicted
105
+ // first, so this can never grow without limit on a long-running server.
106
+ this.tombstones = new Map(); // cacheId -> { reason, at, createdAt, lastUsed, idleMs, maxAgeMs }
107
+ this.maxTombstones = maxTombstones;
108
+ // The idle timeout this cache is pruned against, in ms. The cache doesn't
109
+ // enforce it (prune() is told the maxAge by its caller), it just reports it,
110
+ // so a client polling $cache-control?mode=check can work out how often it needs
111
+ // to poll instead of guessing. Null = not advertised.
112
+ this.idleTimeoutMs = null;
113
+ }
114
+
115
+ /**
116
+ * Tell the cache what idle timeout it is being pruned against, so it can report
117
+ * it to clients. Purely informational - pruning is driven by prune()'s argument.
118
+ * @param {number} ms
119
+ */
120
+ setIdleTimeout(ms) {
121
+ this.idleTimeoutMs = ms;
122
+ }
123
+
124
+ /**
125
+ * Record why a cache-id stopped existing, so a later request carrying that id
126
+ * can be told what actually happened to it rather than a list of maybes.
127
+ *
128
+ * @param {string} cacheId - The cache identifier
129
+ * @param {Object} entry - The entry being removed (for its timings)
130
+ * @param {'closed'|'expired'|'cleared'} reason - How it went away:
131
+ * `closed` = the client released it with $cache-control?mode=end;
132
+ * `expired` = it went unused for longer than the idle timeout;
133
+ * `cleared` = the server dropped every cache at once.
134
+ * @param {Object} [extra] - Reason-specific detail (e.g. idleMs, maxAgeMs)
135
+ */
136
+ _recordTombstone(cacheId, entry, reason, extra = {}) {
137
+ // Re-inserting moves it to the end of the insertion order, so an id that
138
+ // died twice (created, expired, re-issued, closed) reports its latest fate.
139
+ this.tombstones.delete(cacheId);
140
+ this.tombstones.set(cacheId, {
141
+ reason,
142
+ at: Date.now(),
143
+ createdAt: entry ? entry.createdAt : null,
144
+ lastUsed: entry ? entry.lastUsed : null,
145
+ ...extra
146
+ });
147
+ while (this.tombstones.size > this.maxTombstones) {
148
+ // Map iterates in insertion order, so the first key is the oldest record.
149
+ this.tombstones.delete(this.tombstones.keys().next().value);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * What happened to a cache-id that isn't in the cache. Null means this server
155
+ * has no record of ever issuing it: either it never did, or the id was issued
156
+ * by a different server/instance/endpoint, or this process has restarted since.
157
+ *
158
+ * @param {string} cacheId - The cache identifier
159
+ * @returns {Object|null} the tombstone record, or null
160
+ */
161
+ tombstone(cacheId) {
162
+ return this.tombstones.get(cacheId) || null;
163
+ }
164
+
165
+ /**
166
+ * The message to report for a cache-id that isn't here: which of the three
167
+ * fates it met, with the numbers that make it checkable. Both throw sites
168
+ * (worker.setupAdditionalResources and batchValidate.frontLoadBatch) use this
169
+ * so they can never drift apart.
170
+ *
171
+ * @param {string} cacheId - The cache identifier
172
+ * @returns {{messageId: string, params: Array}} message id and its parameters,
173
+ * which always start with the cache-id itself.
174
+ */
175
+ describeMissing(cacheId) {
176
+ const t = this.tombstone(cacheId);
177
+ if (!t) {
178
+ return { messageId: 'CACHE_ID_UNKNOWN', params: [cacheId] };
179
+ }
180
+ const ago = formatDuration(Date.now() - t.at);
181
+ switch (t.reason) {
182
+ case 'closed':
183
+ return { messageId: 'CACHE_ID_CLOSED', params: [cacheId, ago, formatDuration(t.at - t.createdAt)] };
184
+ case 'expired':
185
+ return {
186
+ messageId: 'CACHE_ID_EXPIRED',
187
+ params: [cacheId, formatDuration(t.idleMs), formatDuration(t.maxAgeMs), ago]
188
+ };
189
+ default:
190
+ return { messageId: 'CACHE_ID_CLEARED', params: [cacheId, ago] };
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Number of dead cache-ids this cache still remembers the fate of.
196
+ * @returns {number}
197
+ */
198
+ tombstoneCount() {
199
+ return this.tombstones.size;
200
+ }
201
+
202
+ /**
203
+ * Report on a cache-id without touching it. Deliberately read-only, and
204
+ * deliberately separate from touch(): a client asking "is my cache still alive?"
205
+ * wants to know how idle it had become, and reading after a refresh would always
206
+ * answer zero. Callers that are keeping the cache alive read the status first,
207
+ * then touch.
208
+ *
209
+ * @param {string} cacheId - The cache identifier
210
+ * @returns {Object} `{exists: false}` for a cache-id that isn't here, otherwise
211
+ * `{exists: true, sealed, resources, concepts, idleMs, ageMs, timeoutMs}`.
212
+ */
213
+ status(cacheId) {
214
+ const entry = this.cache.get(cacheId);
215
+ if (!entry) {
216
+ return { exists: false };
217
+ }
218
+ const now = Date.now();
219
+ return {
220
+ exists: true,
221
+ sealed: !!entry.sealed,
222
+ resources: entry.resources.length,
223
+ concepts: entry.concepts || 0,
224
+ idleMs: now - entry.lastUsed,
225
+ ageMs: entry.createdAt ? now - entry.createdAt : null,
226
+ timeoutMs: this.idleTimeoutMs
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Reset a cache's idle clock without reading or changing its contents - the
232
+ * keepalive behind $cache-control?mode=check. A client whose traffic is absorbed
233
+ * by its own local cache can otherwise let a cache it is still relying on go idle
234
+ * past the timeout, and only find out when it finally needs the server again.
235
+ *
236
+ * @param {string} cacheId - The cache identifier
237
+ * @returns {boolean} true if the cache existed and was refreshed
238
+ */
239
+ touch(cacheId) {
240
+ const entry = this.cache.get(cacheId);
241
+ if (!entry) {
242
+ return false;
243
+ }
244
+ entry.lastUsed = Date.now();
245
+ if (this.logCacheOps) {
246
+ this.log.info(`cache-id '${cacheId}': touched (idle clock reset)`);
247
+ }
248
+ return true;
73
249
  }
74
250
 
75
251
  /**
@@ -102,10 +278,14 @@ class ResourceCache {
102
278
  const entry = this.cache.get(cacheId);
103
279
  if (entry) {
104
280
  entry.lastUsed = Date.now();
105
- this.log.info(`cache-id '${cacheId}': hit, returning ${entry.resources.length} resource(s): ${entry.resources.map(r => this._resourceKey(r)).join(', ')}`);
281
+ if (this.logCacheOps) {
282
+ this.log.info(`cache-id '${cacheId}': hit, returning ${entry.resources.length} resource(s): ${entry.resources.map(r => this._resourceKey(r)).join(', ')}`);
283
+ }
106
284
  return [...entry.resources]; // Return a copy
107
285
  }
108
- this.log.info(`cache-id '${cacheId}': miss (no entry)`);
286
+ if (this.logCacheOps) {
287
+ this.log.info(`cache-id '${cacheId}': miss (no entry)`);
288
+ }
109
289
  return [];
110
290
  }
111
291
 
@@ -151,7 +331,7 @@ class ResourceCache {
151
331
  }
152
332
  }
153
333
 
154
- const entry = this.cache.get(cacheId) || { resources: [], lastUsed: Date.now(), concepts: 0 };
334
+ const entry = this.cache.get(cacheId) || { resources: [], createdAt: Date.now(), lastUsed: Date.now(), concepts: 0 };
155
335
 
156
336
  // Merge resources, avoiding duplicates by url+version. Keep the entry's concept
157
337
  // subtotal and the cache-wide total in step with each insertion/replacement.
@@ -165,12 +345,16 @@ class ResourceCache {
165
345
  entry.resources[existingIndex] = resource;
166
346
  entry.concepts += delta;
167
347
  this.totalConcepts += delta;
168
- this.log.info(`cache-id '${cacheId}': replaced ${key}`);
348
+ if (this.logCacheOps) {
349
+ this.log.info(`cache-id '${cacheId}': replaced ${key}`);
350
+ }
169
351
  } else {
170
352
  entry.resources.push(resource);
171
353
  entry.concepts += newConcepts;
172
354
  this.totalConcepts += newConcepts;
173
- this.log.info(`cache-id '${cacheId}': added ${key}`);
355
+ if (this.logCacheOps) {
356
+ this.log.info(`cache-id '${cacheId}': added ${key}`);
357
+ }
174
358
  }
175
359
  }
176
360
 
@@ -187,7 +371,9 @@ class ResourceCache {
187
371
  * resources and will not grow when further resources are seen later.
188
372
  */
189
373
  set(cacheId, resources, sealed = false) {
190
- this.log.info(`cache-id '${cacheId}': set (replace all, sealed=${!!sealed}) with ${resources.length} resource(s): ${resources.map(r => this._resourceKey(r)).join(', ')}`);
374
+ if (this.logCacheOps) {
375
+ this.log.info(`cache-id '${cacheId}': set (replace all, sealed=${!!sealed}) with ${resources.length} resource(s): ${resources.map(r => this._resourceKey(r)).join(', ')}`);
376
+ }
191
377
  // Retained under a cache-id - mark cached so providers may memoise resolved
192
378
  // filter analysis on their filter elements (see filter()/fc).
193
379
  for (const resource of resources) {
@@ -209,21 +395,32 @@ class ResourceCache {
209
395
  this.totalConcepts += concepts;
210
396
  this.cache.set(cacheId, {
211
397
  resources: [...resources],
398
+ createdAt: existing ? existing.createdAt : Date.now(),
212
399
  lastUsed: Date.now(),
213
400
  concepts,
214
401
  sealed: !!sealed
215
402
  });
403
+ // This id is alive again, so any record of its previous death is now wrong.
404
+ this.tombstones.delete(cacheId);
216
405
  this._trackMax();
217
406
  }
218
407
 
219
408
  /**
220
- * Clear a specific cache-id
409
+ * Clear a specific cache-id. This is the client-initiated release path
410
+ * ($cache-control?mode=end), so a cache that existed is tombstoned as `closed`:
411
+ * a later request still carrying the id gets told the client let it go, rather
412
+ * than being sent looking for an expiry that never happened.
413
+ *
221
414
  * @param {string} cacheId - The cache identifier
222
415
  */
223
416
  clear(cacheId) {
224
417
  const entry = this.cache.get(cacheId);
225
418
  if (entry) {
226
419
  this.totalConcepts -= entry.concepts || 0;
420
+ this._recordTombstone(cacheId, entry, 'closed');
421
+ if (this.logCacheOps) {
422
+ this.log.info(`cache-id '${cacheId}': closed by client after ${formatDuration(Date.now() - entry.createdAt)} (held ${entry.resources.length} resource(s))`);
423
+ }
227
424
  }
228
425
  this.cache.delete(cacheId);
229
426
  }
@@ -232,6 +429,9 @@ class ResourceCache {
232
429
  * Clear all cached entries
233
430
  */
234
431
  clearAll() {
432
+ for (const [cacheId, entry] of this.cache.entries()) {
433
+ this._recordTombstone(cacheId, entry, 'cleared');
434
+ }
235
435
  this.cache.clear();
236
436
  this.totalConcepts = 0;
237
437
  }
@@ -247,9 +447,18 @@ class ResourceCache {
247
447
  let i = 0;
248
448
  const now = Date.now();
249
449
  for (const [cacheId, entry] of this.cache.entries()) {
250
- if (now - entry.lastUsed > maxAge) {
450
+ const idleMs = now - entry.lastUsed;
451
+ if (idleMs > maxAge) {
251
452
  i++;
252
453
  this.totalConcepts -= entry.concepts || 0;
454
+ // Record the *measured* idle time, not the configured timeout: the sweep
455
+ // runs periodically, so a cache can sit dead for up to one sweep interval
456
+ // past the timeout, and only the measured figure lets a client line the
457
+ // expiry up against its own request log.
458
+ this._recordTombstone(cacheId, entry, 'expired', { idleMs, maxAgeMs: maxAge });
459
+ if (this.logCacheOps) {
460
+ this.log.info(`cache-id '${cacheId}': expired after ${formatDuration(idleMs)} idle (timeout ${formatDuration(maxAge)})`);
461
+ }
253
462
  this.cache.delete(cacheId);
254
463
  }
255
464
  }
@@ -906,5 +1115,6 @@ module.exports = {
906
1115
  ResourceCache,
907
1116
  ExpansionCache,
908
1117
  isDebugging,
909
- debugLog
1118
+ debugLog,
1119
+ formatDuration
910
1120
  };