@atolis-hq/wake 0.3.82 → 0.3.83
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.
- package/dist/src/bootstrap/index.js +3 -3
- package/dist/src/bootstrap/integration-runtime.js +1 -1
- package/dist/src/bootstrap/persistence-composition.js +1 -0
- package/dist/src/bootstrap/surface-cli-applications.js +12 -8
- package/dist/src/bootstrap/version.js +1 -1
- package/dist/src/control-plane/application/control-plane-service.js +23 -9
- package/dist/src/control-plane/contracts/config.js +6 -3
- package/dist/src/integrations/delivery/application/delivery-outcome-reactor.js +52 -10
- package/dist/src/kernel/contracts/journal-change-signal.js +1 -0
- package/dist/src/kernel/index.js +2 -0
- package/dist/src/kernel/infrastructure/journal-change-signal.js +30 -0
- package/dist/src/persistence/filesystem/file-event-journal.js +6 -1
- package/dist/src/persistence/memory/in-memory-event-journal.js +9 -1
- package/package.json +1 -1
|
@@ -4,8 +4,8 @@ import { createRuntimeProjectionRunner } from './projection-runtime.js';
|
|
|
4
4
|
export function composeDeliveryService(dependencies) {
|
|
5
5
|
return new DeliveryService(dependencies);
|
|
6
6
|
}
|
|
7
|
-
export function composeDeliveryOutcomeReactor(journal, checkpoints, orchestration) {
|
|
8
|
-
return new DeliveryOutcomeReactor(journal, checkpoints, orchestration);
|
|
7
|
+
export function composeDeliveryOutcomeReactor(journal, checkpoints, orchestration, projections) {
|
|
8
|
+
return new DeliveryOutcomeReactor(journal, checkpoints, orchestration, projections);
|
|
9
9
|
}
|
|
10
10
|
export function composeDeliveryRuntime(dependencies) {
|
|
11
11
|
const projectionRunner = createRuntimeProjectionRunner(dependencies.journal, dependencies.projections, dependencies.checkpoints);
|
|
@@ -16,7 +16,7 @@ export function composeDeliveryRuntime(dependencies) {
|
|
|
16
16
|
adapter: dependencies.adapter,
|
|
17
17
|
now: dependencies.now,
|
|
18
18
|
});
|
|
19
|
-
const reactor = composeDeliveryOutcomeReactor(dependencies.journal, dependencies.checkpoints, dependencies.orchestration);
|
|
19
|
+
const reactor = composeDeliveryOutcomeReactor(dependencies.journal, dependencies.checkpoints, dependencies.orchestration, dependencies.projections);
|
|
20
20
|
return {
|
|
21
21
|
async runOnce(signal) {
|
|
22
22
|
await projectionRunner.runRegisteredOnce();
|
|
@@ -120,7 +120,7 @@ export async function composeIntegrationRuntime(input) {
|
|
|
120
120
|
await resourceTransitions.drain();
|
|
121
121
|
return operation();
|
|
122
122
|
});
|
|
123
|
-
const outcomes = new DeliveryOutcomeReactor(input.journal, input.checkpoints, input.orchestration);
|
|
123
|
+
const outcomes = new DeliveryOutcomeReactor(input.journal, input.checkpoints, input.orchestration, input.projections);
|
|
124
124
|
const catchUpProjections = async () => {
|
|
125
125
|
await projectionRunner.runRegisteredOnce();
|
|
126
126
|
};
|
|
@@ -13,6 +13,7 @@ function serializeJournalAppends(journal) {
|
|
|
13
13
|
readStream: (stream) => journal.readStream(stream),
|
|
14
14
|
readAll: (afterGlobalPosition, limit) => journal.readAll(afterGlobalPosition, limit),
|
|
15
15
|
latestGlobalPosition: () => journal.latestGlobalPosition(),
|
|
16
|
+
changeSignal: journal.changeSignal,
|
|
16
17
|
...(journal.readLatest === undefined
|
|
17
18
|
? {}
|
|
18
19
|
: {
|
|
@@ -39,12 +39,13 @@ export function createSurfaceCliApplications(root, api, now) {
|
|
|
39
39
|
// hammered every cycle instead of given a chance to recover.
|
|
40
40
|
const runnerResident = new ResidentHost(runnerTick, (signal, { consecutiveIdleTicks, consecutiveErrorTicks }) => {
|
|
41
41
|
if (consecutiveErrorTicks > 0)
|
|
42
|
-
return sleepUntilAbort(signal,
|
|
42
|
+
return sleepUntilAbort(signal, nextPollBackoffMs(root.config.controlPlane.resident, consecutiveErrorTicks));
|
|
43
|
+
// Genuine idle (no errors): wait for the journal to actually change.
|
|
43
44
|
return consecutiveIdleTicks === 0
|
|
44
45
|
? Promise.resolve()
|
|
45
|
-
:
|
|
46
|
+
: root.journal.changeSignal.waitForChange(signal, JOURNAL_WAIT_FALLBACK_MS);
|
|
46
47
|
}, reportResidentError('runner'));
|
|
47
|
-
const intakeResident = new ResidentHost(intakeHost, (signal, { consecutiveIdleTicks }) => sleepUntilAbort(signal,
|
|
48
|
+
const intakeResident = new ResidentHost(intakeHost, (signal, { consecutiveIdleTicks }) => sleepUntilAbort(signal, nextPollBackoffMs(root.config.controlPlane.resident, consecutiveIdleTicks)), reportResidentError('intake'));
|
|
48
49
|
const servers = new Set();
|
|
49
50
|
const startHttp = createHttpStarter(root, api, servers);
|
|
50
51
|
return {
|
|
@@ -134,8 +135,11 @@ export function createSurfaceCliApplications(root, api, now) {
|
|
|
134
135
|
operational: createOperationalApplications(root),
|
|
135
136
|
};
|
|
136
137
|
}
|
|
138
|
+
// Safety net for a missed in-process notify() or a cross-process writer the
|
|
139
|
+
// EventEmitter can't see; a real append wakes waiters within milliseconds
|
|
140
|
+
// regardless, so there's no operational reason to make this configurable.
|
|
141
|
+
const JOURNAL_WAIT_FALLBACK_MS = 30_000;
|
|
137
142
|
export async function runProjectionPump(root, signal) {
|
|
138
|
-
const intervalMs = 1000;
|
|
139
143
|
while (!signal.aborted) {
|
|
140
144
|
try {
|
|
141
145
|
await root.projectionRunner.runRegisteredOnce();
|
|
@@ -143,12 +147,12 @@ export async function runProjectionPump(root, signal) {
|
|
|
143
147
|
catch (error) {
|
|
144
148
|
process.stderr.write(`Wake projection pump failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
145
149
|
}
|
|
146
|
-
await
|
|
150
|
+
await root.journal.changeSignal.waitForChange(signal, JOURNAL_WAIT_FALLBACK_MS);
|
|
147
151
|
}
|
|
148
152
|
}
|
|
149
|
-
function
|
|
150
|
-
const baseMs = resident?.
|
|
151
|
-
const maxMs = resident?.
|
|
153
|
+
function nextPollBackoffMs(resident, consecutiveIdleTicks) {
|
|
154
|
+
const baseMs = resident?.pollBackoffMs ?? 1000;
|
|
155
|
+
const maxMs = resident?.maxPollBackoffMs ?? baseMs * 16;
|
|
152
156
|
return Math.min(baseMs * 2 ** Math.min(consecutiveIdleTicks, 20), maxMs);
|
|
153
157
|
}
|
|
154
158
|
function sleepUntilAbort(signal, milliseconds) {
|
|
@@ -2,22 +2,36 @@ import { EventActorKind, correlationId, } from '../../kernel/index.js';
|
|
|
2
2
|
import { ControlEventType, createControlEventDraft, selectControlEvent, } from '../contracts/events.js';
|
|
3
3
|
import { controlPlaneStream } from '../contracts/streams.js';
|
|
4
4
|
export function createControlPlaneService(input) {
|
|
5
|
+
// isPaused() is checked many times per pipeline run, so memoize by
|
|
6
|
+
// journal position to skip the read entirely when nothing has moved.
|
|
7
|
+
let cached;
|
|
5
8
|
return {
|
|
6
9
|
pause: (key) => change(input, key, 'pause'),
|
|
7
10
|
resume: (key) => change(input, key, 'resume'),
|
|
8
11
|
async isPaused() {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
if (event?.eventType === ControlEventType.DispatchResumed)
|
|
15
|
-
paused = false;
|
|
16
|
-
}
|
|
12
|
+
const position = await input.journal.latestGlobalPosition();
|
|
13
|
+
if (cached !== undefined && cached.position === position)
|
|
14
|
+
return cached.paused;
|
|
15
|
+
const paused = await currentIsPaused(input.journal);
|
|
16
|
+
cached = { position, paused };
|
|
17
17
|
return paused;
|
|
18
18
|
},
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
|
+
async function currentIsPaused(journal) {
|
|
22
|
+
return isPausedIn(await journal.readStream(controlPlaneStream()));
|
|
23
|
+
}
|
|
24
|
+
function isPausedIn(events) {
|
|
25
|
+
let paused = false;
|
|
26
|
+
for (const envelope of events) {
|
|
27
|
+
const event = selectControlEvent(envelope);
|
|
28
|
+
if (event?.eventType === ControlEventType.DispatchPaused)
|
|
29
|
+
paused = true;
|
|
30
|
+
if (event?.eventType === ControlEventType.DispatchResumed)
|
|
31
|
+
paused = false;
|
|
32
|
+
}
|
|
33
|
+
return paused;
|
|
34
|
+
}
|
|
21
35
|
async function change(input, idempotencyKey, operation) {
|
|
22
36
|
const stream = controlPlaneStream();
|
|
23
37
|
const events = await input.journal.readStream(stream);
|
|
@@ -25,7 +39,7 @@ async function change(input, idempotencyKey, operation) {
|
|
|
25
39
|
const correlation = correlationId(`control:${operation}:${idempotencyKey}`);
|
|
26
40
|
if (events.some((event) => event.eventType === eventType && event.correlationId === correlation))
|
|
27
41
|
return;
|
|
28
|
-
const currentlyPaused =
|
|
42
|
+
const currentlyPaused = isPausedIn(events);
|
|
29
43
|
if ((operation === 'pause' && currentlyPaused) || (operation === 'resume' && !currentlyPaused))
|
|
30
44
|
return;
|
|
31
45
|
const occurredAt = input.clock.now().toISOString();
|
|
@@ -14,10 +14,13 @@ export const controlPlaneConfigSchema = z
|
|
|
14
14
|
schedules: z.array(scheduleSchema).default([]),
|
|
15
15
|
resident: z
|
|
16
16
|
.object({
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// Backoff for the resident loop's own retry cadence when idle or
|
|
18
|
+
// erroring, not a per-adapter rate limit (e.g.
|
|
19
|
+
// integrations.github.polling.intervalMs gates the actual call).
|
|
20
|
+
pollBackoffMs: z.number().int().positive().default(1000),
|
|
21
|
+
maxPollBackoffMs: z.number().int().positive().optional(),
|
|
19
22
|
})
|
|
20
23
|
.strict()
|
|
21
|
-
.default({
|
|
24
|
+
.default({ pollBackoffMs: 1000 }),
|
|
22
25
|
})
|
|
23
26
|
.strict();
|
|
@@ -1,34 +1,63 @@
|
|
|
1
1
|
import { ActivityOutcomeKind, activationId } from '../../../activities/index.js';
|
|
2
|
-
import { EventActorKind } from '../../../kernel/index.js';
|
|
2
|
+
import { EventActorKind, } from '../../../kernel/index.js';
|
|
3
3
|
import { ActivityActivationStatus, workflowInstanceId, } from '../../../orchestration/index.js';
|
|
4
4
|
import { DeliveryEventType, selectDeliveryEvent } from '../contracts/events.js';
|
|
5
5
|
import { DeliveryResultKind } from '../contracts/vocabulary.js';
|
|
6
6
|
const deliveryResultSignalKind = 'delivery-result';
|
|
7
|
+
const pendingNamespace = 'reactor:delivery-outcomes:pending';
|
|
8
|
+
const pendingKey = 'pending-confirmations';
|
|
7
9
|
export class DeliveryOutcomeReactor {
|
|
8
10
|
journal;
|
|
9
11
|
checkpoints;
|
|
10
12
|
orchestration;
|
|
11
|
-
|
|
13
|
+
projections;
|
|
14
|
+
constructor(journal, checkpoints, orchestration, projections) {
|
|
12
15
|
this.journal = journal;
|
|
13
16
|
this.checkpoints = checkpoints;
|
|
14
17
|
this.orchestration = orchestration;
|
|
18
|
+
this.projections = projections;
|
|
15
19
|
}
|
|
16
20
|
async runOnce() {
|
|
17
21
|
const consumer = 'reactor:delivery-outcomes';
|
|
18
22
|
const events = await this.journal.readAll(await this.checkpoints.load(consumer));
|
|
19
|
-
const
|
|
23
|
+
const resolved = new Set();
|
|
24
|
+
const pending = new Map((await this.loadPending()).map((event) => [event.eventId, event]));
|
|
20
25
|
for (const event of events) {
|
|
21
|
-
await this.reconcile(event,
|
|
26
|
+
const matched = await this.reconcile(event, resolved);
|
|
27
|
+
if (matched === false) {
|
|
28
|
+
// reconcile() only ever returns false for a delivery-terminal event
|
|
29
|
+
// (selectDeliveryEvent(event) !== null), so this lookup can't miss.
|
|
30
|
+
const delivery = selectDeliveryEvent(event);
|
|
31
|
+
pending.set(delivery.eventId, event);
|
|
32
|
+
}
|
|
22
33
|
await this.checkpoints.save(consumer, event.globalPosition);
|
|
23
34
|
}
|
|
24
|
-
|
|
25
|
-
|
|
35
|
+
// Catches a confirmation checkpointed before its workflow reached
|
|
36
|
+
// "waiting for delivery" — re-checked here on every call rather than
|
|
37
|
+
// relying on the incremental pass above, which never revisits a
|
|
38
|
+
// position once checkpointed.
|
|
39
|
+
for (const [id, pendingEvent] of pending) {
|
|
40
|
+
if (resolved.has(id)) {
|
|
41
|
+
pending.delete(id);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const matched = await this.reconcile(pendingEvent, resolved);
|
|
45
|
+
if (matched === true)
|
|
46
|
+
pending.delete(id);
|
|
47
|
+
}
|
|
48
|
+
await this.savePending([...pending.values()]);
|
|
26
49
|
return events.length;
|
|
27
50
|
}
|
|
51
|
+
// true: resolved (accepted this call, or already accepted earlier this
|
|
52
|
+
// same call). false: a delivery-terminal event whose workflow isn't
|
|
53
|
+
// waiting on it yet — belongs in the pending set for a later retry. null:
|
|
54
|
+
// not a delivery-terminal event at all.
|
|
28
55
|
async reconcile(event, seen) {
|
|
29
56
|
const delivery = selectDeliveryEvent(event);
|
|
30
|
-
if (delivery === null
|
|
31
|
-
return;
|
|
57
|
+
if (delivery === null)
|
|
58
|
+
return null;
|
|
59
|
+
if (seen.has(delivery.eventId))
|
|
60
|
+
return true;
|
|
32
61
|
const outcome = delivery.eventType === DeliveryEventType.Confirmed ||
|
|
33
62
|
(delivery.eventType === DeliveryEventType.Reconciled &&
|
|
34
63
|
delivery.payload.result === DeliveryResultKind.Confirmed)
|
|
@@ -37,13 +66,13 @@ export class DeliveryOutcomeReactor {
|
|
|
37
66
|
? { kind: ActivityOutcomeKind.Failed, data: { reason: delivery.payload.code } }
|
|
38
67
|
: null;
|
|
39
68
|
if (outcome === null)
|
|
40
|
-
return;
|
|
69
|
+
return null;
|
|
41
70
|
const command = {
|
|
42
71
|
workflowInstanceId: workflowInstanceId(delivery.payload.workflowInstanceId),
|
|
43
72
|
activationId: activationId(delivery.payload.activationId),
|
|
44
73
|
};
|
|
45
74
|
if (!(await this.isAwaitingThisDelivery(command, delivery.payload.intentEventId)))
|
|
46
|
-
return;
|
|
75
|
+
return false;
|
|
47
76
|
seen.add(delivery.eventId);
|
|
48
77
|
await this.orchestration.acceptOutcome({ ...command, outcome }, {
|
|
49
78
|
commandId: delivery.eventId,
|
|
@@ -51,6 +80,7 @@ export class DeliveryOutcomeReactor {
|
|
|
51
80
|
actor: { kind: EventActorKind.System, id: 'delivery-outcome-reactor' },
|
|
52
81
|
occurredAt: event.recordedAt,
|
|
53
82
|
});
|
|
83
|
+
return true;
|
|
54
84
|
}
|
|
55
85
|
/**
|
|
56
86
|
* A delivery's own completion may only resolve the activation that
|
|
@@ -66,4 +96,16 @@ export class DeliveryOutcomeReactor {
|
|
|
66
96
|
view.waitingFor?.signalKind === deliveryResultSignalKind &&
|
|
67
97
|
view.waitingFor.intentEventId === intentEventId);
|
|
68
98
|
}
|
|
99
|
+
async loadPending() {
|
|
100
|
+
const stored = await this.projections.read(pendingNamespace, pendingKey);
|
|
101
|
+
return stored?.value.events ?? [];
|
|
102
|
+
}
|
|
103
|
+
async savePending(events) {
|
|
104
|
+
await this.projections.write({
|
|
105
|
+
namespace: pendingNamespace,
|
|
106
|
+
key: pendingKey,
|
|
107
|
+
lastGlobalPosition: 0,
|
|
108
|
+
value: { events },
|
|
109
|
+
});
|
|
110
|
+
}
|
|
69
111
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/src/kernel/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export * from './contracts/event-journal.js';
|
|
|
5
5
|
export * from './contracts/event-schema.js';
|
|
6
6
|
export * from './contracts/events.js';
|
|
7
7
|
export * from './contracts/id-generator.js';
|
|
8
|
+
export * from './contracts/journal-change-signal.js';
|
|
8
9
|
export { causationId, correlationId, eventId } from './contracts/identifiers.js';
|
|
9
10
|
export * from './contracts/projection-store.js';
|
|
10
11
|
export * from './contracts/relations.js';
|
|
@@ -14,5 +15,6 @@ export * from './domain/event-envelope.js';
|
|
|
14
15
|
export * from './domain/match-mode.js';
|
|
15
16
|
export * from './domain/relation.js';
|
|
16
17
|
export * from './infrastructure/cached-journal-view.js';
|
|
18
|
+
export * from './infrastructure/journal-change-signal.js';
|
|
17
19
|
export * from './infrastructure/system-clock.js';
|
|
18
20
|
export * from './infrastructure/ulid-id-generator.js';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export class InProcessJournalChangeSignal {
|
|
2
|
+
waiters = [];
|
|
3
|
+
notify() {
|
|
4
|
+
const waiters = this.waiters;
|
|
5
|
+
this.waiters = [];
|
|
6
|
+
for (const resolve of waiters)
|
|
7
|
+
resolve();
|
|
8
|
+
}
|
|
9
|
+
waitForChange(signal, fallbackMs) {
|
|
10
|
+
if (signal.aborted)
|
|
11
|
+
return Promise.resolve();
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
let settled = false;
|
|
14
|
+
const done = () => {
|
|
15
|
+
if (settled)
|
|
16
|
+
return;
|
|
17
|
+
settled = true;
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
signal.removeEventListener('abort', done);
|
|
20
|
+
// Must drop this waiter here too, not just in notify() — otherwise
|
|
21
|
+
// every timeout/abort leaks an entry into `waiters` forever.
|
|
22
|
+
this.waiters = this.waiters.filter((waiter) => waiter !== done);
|
|
23
|
+
resolve();
|
|
24
|
+
};
|
|
25
|
+
this.waiters.push(done);
|
|
26
|
+
const timer = setTimeout(done, fallbackMs);
|
|
27
|
+
signal.addEventListener('abort', done, { once: true });
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { appendFile, mkdir, readdir, readFile, stat } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { isDeepStrictEqual } from 'node:util';
|
|
4
|
-
import { decodeEventEnvelope, WrongExpectedSequenceError } from '../../kernel/index.js';
|
|
4
|
+
import { decodeEventEnvelope, InProcessJournalChangeSignal, WrongExpectedSequenceError, } from '../../kernel/index.js';
|
|
5
5
|
import { withFileLock } from './file-lock.js';
|
|
6
6
|
export class FileEventJournal {
|
|
7
7
|
root;
|
|
@@ -10,6 +10,10 @@ export class FileEventJournal {
|
|
|
10
10
|
this.root = root;
|
|
11
11
|
this.clock = clock;
|
|
12
12
|
}
|
|
13
|
+
changeSignalSource = new InProcessJournalChangeSignal();
|
|
14
|
+
get changeSignal() {
|
|
15
|
+
return this.changeSignalSource;
|
|
16
|
+
}
|
|
13
17
|
cached;
|
|
14
18
|
async append(stream, expectedSequence, drafts) {
|
|
15
19
|
return withFileLock(join(this.root, 'locks', 'event-journal.lock'), async () => {
|
|
@@ -56,6 +60,7 @@ export class FileEventJournal {
|
|
|
56
60
|
const file = `${day}.jsonl`;
|
|
57
61
|
await appendFile(join(directory, file), newEnvelopes.map((event) => JSON.stringify(event)).join('\n') + '\n', 'utf8');
|
|
58
62
|
await this.extendCache(file, current, newEnvelopes);
|
|
63
|
+
this.changeSignalSource.notify();
|
|
59
64
|
}
|
|
60
65
|
return finalizedEnvelopes;
|
|
61
66
|
});
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
-
import { WrongExpectedSequenceError } from '../../kernel/index.js';
|
|
2
|
+
import { InProcessJournalChangeSignal, WrongExpectedSequenceError } from '../../kernel/index.js';
|
|
3
3
|
export class InMemoryEventJournal {
|
|
4
4
|
clock;
|
|
5
5
|
streams = new Map();
|
|
6
6
|
events = [];
|
|
7
7
|
eventIds = new Map();
|
|
8
|
+
changeSignalSource = new InProcessJournalChangeSignal();
|
|
8
9
|
constructor(clock) {
|
|
9
10
|
this.clock = clock;
|
|
10
11
|
}
|
|
12
|
+
get changeSignal() {
|
|
13
|
+
return this.changeSignalSource;
|
|
14
|
+
}
|
|
11
15
|
async append(stream, expectedSequence, events) {
|
|
12
16
|
validateBatch(stream, events);
|
|
13
17
|
const existingEvents = events.map((draft) => this.eventIds.get(draft.eventId));
|
|
@@ -21,6 +25,7 @@ export class InMemoryEventJournal {
|
|
|
21
25
|
}
|
|
22
26
|
const recordedAt = this.clock.now().toISOString();
|
|
23
27
|
const appended = [];
|
|
28
|
+
let newCount = 0;
|
|
24
29
|
for (const [index, draft] of events.entries()) {
|
|
25
30
|
const prior = existingEvents[index];
|
|
26
31
|
if (prior !== undefined) {
|
|
@@ -37,8 +42,11 @@ export class InMemoryEventJournal {
|
|
|
37
42
|
this.events.push(envelope);
|
|
38
43
|
this.eventIds.set(draft.eventId, { draft, envelope });
|
|
39
44
|
appended.push(envelope);
|
|
45
|
+
newCount += 1;
|
|
40
46
|
}
|
|
41
47
|
this.streams.set(streamKey(stream), streamEvents);
|
|
48
|
+
if (newCount > 0)
|
|
49
|
+
this.changeSignalSource.notify();
|
|
42
50
|
return appended;
|
|
43
51
|
}
|
|
44
52
|
async readStream(stream) {
|