@procedures/client 0.49.1 → 0.50.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.
@@ -40,6 +40,16 @@ export const DEFAULT_LIVE_KEYS = 1024;
40
40
  export const DEFAULT_LIVE_VALUE_BYTES = 16384;
41
41
  /** The default limit of the topics of one live state. */
42
42
  export const DEFAULT_LIVE_TOPICS = 4096;
43
+ /**
44
+ * The default limit of the bytes of the values of one topic, as JSON. This limit bounds a snapshot and a window event.
45
+ * In the bench `live-limits` (run 42152574477), a full topic at this limit kept 1,075,659 bytes of heap.
46
+ */
47
+ export const DEFAULT_LIVE_TOPIC_BYTES = 1_048_576;
48
+ /**
49
+ * The default limit of the bytes of all values of one live state, as JSON. In the bench `live-limits` (run 42152574477),
50
+ * a full state kept 68,842,177 bytes: 1.53 percent of a heap limit of 4,496,293,888 bytes.
51
+ */
52
+ export const DEFAULT_LIVE_BYTES = 67_108_864;
43
53
 
44
54
  /** The prefix of each live topic. */
45
55
  const PREFIX = "live.";
@@ -169,6 +179,18 @@ export interface LiveStateOptions {
169
179
  * @default DEFAULT_LIVE_TOPICS
170
180
  */
171
181
  maxTopics?: number | undefined;
182
+ /**
183
+ * The limit of the bytes of the values of one topic, as JSON. A set that takes a topic above the limit gets
184
+ * `FORBIDDEN`. A set that makes a value smaller always goes. This limit bounds a snapshot and a window event.
185
+ * @default DEFAULT_LIVE_TOPIC_BYTES
186
+ */
187
+ maxTopicBytes?: number | undefined;
188
+ /**
189
+ * The limit of the bytes of all values of this live state, as JSON. A set that takes the state above the limit gets
190
+ * `FORBIDDEN`. This limit bounds the memory of the state, because the product of the other limits does not.
191
+ * @default DEFAULT_LIVE_BYTES
192
+ */
193
+ maxBytes?: number | undefined;
172
194
  /**
173
195
  * The clock of the deadlines and of the window. A test gives the `fakeClock` of
174
196
  * `@mark1russell7/service/testing`.
@@ -233,6 +255,8 @@ interface Slot {
233
255
  readonly state: TopicState;
234
256
  readonly key: string;
235
257
  entry: LiveEntry;
258
+ /** The bytes of the value, as JSON. */
259
+ bytes: number;
236
260
  deadline: number;
237
261
  owner: string | undefined;
238
262
  }
@@ -249,6 +273,8 @@ interface TopicState {
249
273
  readonly keys: Map<string, Slot>;
250
274
  readonly stop: Unsubscribe;
251
275
  readonly windowMs: number;
276
+ /** The bytes of the values of the keys, as JSON. */
277
+ bytes: number;
252
278
  waiting: Waiting | undefined;
253
279
  /** This field holds the timer of the open window. It is undefined when the topic is idle. */
254
280
  window: Cancel | undefined;
@@ -261,6 +287,17 @@ function isLiveEvent(value: unknown): value is LiveEvent {
261
287
  return typeof event.origin === "string" && Array.isArray(event.set) && Array.isArray(event.gone);
262
288
  }
263
289
 
290
+ /** True when a text has a character outside ASCII. */
291
+ const NOT_ASCII = /[^\u0000-\u007f]/;
292
+ let encoder: InstanceType<typeof TextEncoder> | undefined;
293
+
294
+ /** This function gives the UTF-8 bytes of a text. An ASCII text has one byte for each character, and needs no encode. */
295
+ function utf8Bytes(text: string): number {
296
+ if (!NOT_ASCII.test(text)) return text.length;
297
+ encoder ??= new TextEncoder();
298
+ return encoder.encode(text).length;
299
+ }
300
+
264
301
  /** This function gives a refusal of a set. */
265
302
  function refused(message: string, reason: "write-not-allowed" | "partition-not-allowed", path: readonly string[] = LIVE_SET_PATH): InvocationError {
266
303
  return new InvocationError("FORBIDDEN", message, [...path], reason);
@@ -298,6 +335,10 @@ export class LiveState {
298
335
  private readonly maxKeys: number;
299
336
  private readonly maxValueBytes: number;
300
337
  private readonly maxTopics: number;
338
+ private readonly maxTopicBytes: number;
339
+ private readonly maxBytes: number;
340
+ /** The bytes of all values, as JSON. */
341
+ private totalBytes = 0;
301
342
  private readonly origin = newOrigin();
302
343
  private readonly topics = new Map<string, TopicState>();
303
344
  /** Each key in the order of its last set. All keys have the same time, so this is the order of the deadlines. */
@@ -321,6 +362,8 @@ export class LiveState {
321
362
  this.maxKeys = options.maxKeysPerTopic ?? DEFAULT_LIVE_KEYS;
322
363
  this.maxValueBytes = options.maxValueBytes ?? DEFAULT_LIVE_VALUE_BYTES;
323
364
  this.maxTopics = options.maxTopics ?? DEFAULT_LIVE_TOPICS;
365
+ this.maxTopicBytes = options.maxTopicBytes ?? DEFAULT_LIVE_TOPIC_BYTES;
366
+ this.maxBytes = options.maxBytes ?? DEFAULT_LIVE_BYTES;
324
367
  this.procedures = [this.setProcedure(host.topicSchema), this.snapshotProcedure(host.topicSchema), this.leaveProcedure(host.topicSchema)];
325
368
  }
326
369
 
@@ -329,6 +372,11 @@ export class LiveState {
329
372
  return this.deadlines.size;
330
373
  }
331
374
 
375
+ /** The bytes of the values of this state, as JSON. */
376
+ get bytes(): number {
377
+ return this.totalBytes;
378
+ }
379
+
332
380
  /**
333
381
  * The topic of the bus for a live topic of a call, or undefined for another topic. The publisher
334
382
  * calls it for each subscription and each re-check, so a change of the partition ends a
@@ -355,19 +403,23 @@ export class LiveState {
355
403
  */
356
404
  set(topic: string, key: string, value: unknown, options: LiveSetOptions = {}): number {
357
405
  if (this.closed) throw refused("The live state is closed", "write-not-allowed");
358
- this.checkValue(value);
406
+ const bytes = this.checkValue(value);
359
407
  const state = this.stateOf(topic);
360
408
  if (!state) throw refused(`The live state has its limit of ${this.maxTopics} topics`, "write-not-allowed");
409
+ let slot = state.keys.get(key);
410
+ if (!slot && state.keys.size >= this.maxKeys) throw refused(`The live topic "${topic}" has its limit of ${this.maxKeys} keys`, "write-not-allowed");
411
+ const more = bytes - (slot?.bytes ?? 0);
412
+ if (more > 0 && state.bytes + more > this.maxTopicBytes) throw refused(`The live topic "${topic}" has its limit of ${this.maxTopicBytes} bytes`, "write-not-allowed");
413
+ if (more > 0 && this.totalBytes + more > this.maxBytes) throw refused(`The live state has its limit of ${this.maxBytes} bytes`, "write-not-allowed");
361
414
  const at = this.clock.now();
362
415
  const entry: LiveEntry = options.by === undefined ? { key, value, at } : { key, value, by: options.by, at };
363
- let slot = state.keys.get(key);
364
416
  if (slot) {
365
417
  slot.entry = entry;
366
418
  } else {
367
- if (state.keys.size >= this.maxKeys) throw refused(`The live topic "${topic}" has its limit of ${this.maxKeys} keys`, "write-not-allowed");
368
- slot = { state, key, entry, deadline: 0, owner: undefined };
419
+ slot = { state, key, entry, bytes: 0, deadline: 0, owner: undefined };
369
420
  state.keys.set(key, slot);
370
421
  }
422
+ this.resize(slot, bytes);
371
423
  this.renew(slot, at);
372
424
  this.own(slot, options.owner);
373
425
  this.queue(state, (waiting) => {
@@ -421,15 +473,26 @@ export class LiveState {
421
473
  this.topics.clear();
422
474
  this.deadlines.clear();
423
475
  this.owned.clear();
476
+ this.totalBytes = 0;
424
477
  }
425
478
 
426
- /** This method refuses a value above the limit. A text has at most 3 bytes for each UTF-16 unit. */
427
- private checkValue(value: unknown): void {
479
+ /**
480
+ * This method refuses a value above the limit, and gives the bytes of a value that fits. A text has at least one
481
+ * byte for each UTF-16 unit, so a longer text needs no encode.
482
+ */
483
+ private checkValue(value: unknown): number {
428
484
  const text = JSON.stringify(value);
429
485
  if (text === undefined) throw refused("A live value must have a JSON form", "write-not-allowed");
430
- const fits = text.length * 3 <= this.maxValueBytes
431
- || (text.length <= this.maxValueBytes && new TextEncoder().encode(text).length <= this.maxValueBytes);
432
- if (!fits) throw refused(`A live value has more than ${this.maxValueBytes} bytes`, "write-not-allowed");
486
+ const bytes = text.length > this.maxValueBytes ? Infinity : utf8Bytes(text);
487
+ if (bytes > this.maxValueBytes) throw refused(`A live value has more than ${this.maxValueBytes} bytes`, "write-not-allowed");
488
+ return bytes;
489
+ }
490
+
491
+ /** This method gives a key the bytes of its new value, and changes the sums of its topic and of the state. */
492
+ private resize(slot: Slot, bytes: number): void {
493
+ slot.state.bytes += bytes - slot.bytes;
494
+ this.totalBytes += bytes - slot.bytes;
495
+ slot.bytes = bytes;
433
496
  }
434
497
 
435
498
  /**
@@ -449,7 +512,7 @@ export class LiveState {
449
512
  if (this.topics.size >= this.maxTopics) return undefined;
450
513
  }
451
514
  const stop = this.bus.on<unknown>(topic, (event) => this.heard(topic, event));
452
- const state: TopicState = { topic, keys: new Map(), stop, windowMs: toMs(this.windowOf(topic), "window"), waiting: undefined, window: undefined };
515
+ const state: TopicState = { topic, keys: new Map(), stop, windowMs: toMs(this.windowOf(topic), "window"), bytes: 0, waiting: undefined, window: undefined };
453
516
  this.topics.set(topic, state);
454
517
  return state;
455
518
  }
@@ -462,13 +525,21 @@ export class LiveState {
462
525
  const now = this.clock.now();
463
526
  for (const entry of event.set) {
464
527
  let slot = state.keys.get(entry.key);
528
+ const bytes = this.bytesOf(entry.value);
529
+ const more = bytes - (slot?.bytes ?? 0);
530
+ if (bytes > this.maxValueBytes || (more > 0 && (state.bytes + more > this.maxTopicBytes || this.totalBytes + more > this.maxBytes))) {
531
+ // A value that does not fit here leaves no old value of the key: the subscribers got the new one from the bus
532
+ if (slot) this.end(slot, "expired", false);
533
+ continue;
534
+ }
465
535
  if (slot) {
466
536
  slot.entry = entry;
467
537
  } else {
468
538
  if (state.keys.size >= this.maxKeys) continue;
469
- slot = { state, key: entry.key, entry, deadline: 0, owner: undefined };
539
+ slot = { state, key: entry.key, entry, bytes: 0, deadline: 0, owner: undefined };
470
540
  state.keys.set(entry.key, slot);
471
541
  }
542
+ this.resize(slot, bytes);
472
543
  // The connection of this key is on the other host: its deadline ends the key if that host dies
473
544
  this.own(slot, undefined);
474
545
  this.renew(slot, now);
@@ -479,6 +550,12 @@ export class LiveState {
479
550
  }
480
551
  }
481
552
 
553
+ /** The bytes of a value of another host, as JSON. A value with no JSON form has no place. */
554
+ private bytesOf(value: unknown): number {
555
+ const text = JSON.stringify(value);
556
+ return text === undefined ? Infinity : utf8Bytes(text);
557
+ }
558
+
482
559
  /** This method moves a key to the end of the deadlines. */
483
560
  private renew(slot: Slot, now: number): void {
484
561
  slot.deadline = now + this.expiresMs;
@@ -507,6 +584,7 @@ export class LiveState {
507
584
  this.own(slot, undefined);
508
585
  this.deadlines.delete(slot);
509
586
  slot.state.keys.delete(slot.key);
587
+ this.resize(slot, 0);
510
588
  if (!publish) return;
511
589
  this.queue(slot.state, (waiting) => {
512
590
  waiting.set.delete(slot.key);