@nivinjoseph/n-eda
Version:
Event Driven Architecture framework
493 lines • 22.7 kB
JavaScript
import { given } from "@nivinjoseph/n-defensive";
import { Delay, Deserializer, Duration, Make } from "@nivinjoseph/n-util";
// import * as Redis from "redis";
import { ApplicationException, ObjectDisposedException } from "@nivinjoseph/n-exception";
import * as otelApi from "@opentelemetry/api";
import * as semCon from "@opentelemetry/semantic-conventions";
import Zlib from "zlib";
import { EdaManager } from "../eda-manager.js";
import { EventRegistration } from "../event-registration.js";
import { Broker } from "./broker.js";
import { NedaClearTrackedKeysEvent } from "./neda-clear-tracked-keys-event.js";
import { NedaDistributedObserverNotifyEvent } from "./neda-distributed-observer-notify-event.js";
// import * as MessagePack from "msgpackr";
// import * as Snappy from "snappy";
export class Consumer {
get _writeIndexKey() { return `${this.id}-write-index`; }
get _readIndexKey() { return `${this._fullId}-read-index`; }
get _trackedKeysKey() { return `${this._fullId}-tracked_keys`; }
get _fullId() { return `${this.id}-${this._manager.consumerGroupId}`; }
get id() { return `{${this._edaPrefix}-${this._topic}-${this._partition}}`; }
constructor(client, manager, topic, partition, flush = false) {
this._edaPrefix = "n-eda";
this._nedaClearTrackedKeysEventName = NedaClearTrackedKeysEvent.getTypeName();
this._nedaDistributedObserverNotifyEventName = NedaDistributedObserverNotifyEvent.getTypeName();
this._isDisposed = false;
this._maxTrackedSize = 3000;
this._keepTrackedSize = 1000;
this._trackedKeysArray = new Array();
this._trackedKeysSet = new Set();
this._keysToTrack = new Array();
this._consumePromise = null;
this._broker = null;
this._delayCanceller = null;
this._lastReportTime = 0;
given(client, "client").ensureHasValue().ensureIsObject();
this._client = client;
given(manager, "manager").ensureHasValue().ensureIsObject().ensureIsType(EdaManager);
this._manager = manager;
this._logger = this._manager.serviceLocator.resolve("Logger");
given(topic, "topic").ensureHasValue().ensureIsString();
this._topic = topic;
given(partition, "partition").ensureHasValue().ensureIsNumber();
this._partition = partition;
this._cleanKeys = this._manager.cleanKeys;
given(flush, "flush").ensureHasValue().ensureIsBoolean();
this._flush = flush;
}
registerBroker(broker) {
given(broker, "broker").ensureHasValue().ensureIsObject().ensureIsObject().ensureIsType(Broker);
this._broker = broker;
}
consume() {
if (this._isDisposed)
throw new ObjectDisposedException("Consumer");
given(this, "this").ensure(t => !t._consumePromise, "consumption has already commenced");
this._consumePromise = this._beginConsume();
}
async dispose() {
var _a;
if (!this._isDisposed) {
this._isDisposed = true;
if (this._delayCanceller != null)
this._delayCanceller.cancel();
// console.warn(`Disposing consumer ${this.id}`);
}
return ((_a = this._consumePromise) === null || _a === void 0 ? void 0 : _a.then(() => {
// console.warn(`Consumer disposed ${this.id}`);
})) || Promise.resolve().then(() => {
// console.warn(`Consumer disposed ${this.id}`);
});
}
awaken() {
if (this._delayCanceller != null)
this._delayCanceller.cancel();
}
async _beginConsume() {
await this._loadTrackedKeys();
await this._logger.logInfo(`Loaded tracked keys for Consumer ${this.id} => ${this._trackedKeysSet.size}`);
const maxReadAttempts = 50;
// eslint-disable-next-line no-constant-condition
while (true) {
if (this._isDisposed)
return;
try {
// const writeIndex = await this._fetchPartitionWriteIndex();
// const readIndex = await this._fetchConsumerPartitionReadIndex();
const [writeIndex, readIndex] = await this._fetchPartitionWriteAndConsumerPartitionReadIndexes();
const now = Date.now();
if ((now - this._lastReportTime) > Duration.fromMinutes(1).toMilliSeconds()) {
this._broker.report(this._partition, writeIndex, readIndex);
this._lastReportTime = now;
}
if (readIndex >= writeIndex) {
this._delayCanceller = {};
await Delay.milliseconds(Make.randomInt(2500, 5000), this._delayCanceller);
// await Delay.seconds(1, this._delayCanceller);
continue;
}
const maxRead = 50;
const depth = writeIndex - readIndex;
const lowerBoundReadIndex = readIndex + 1;
let upperBoundReadIndex = writeIndex;
if (depth > maxRead) {
upperBoundReadIndex = readIndex + maxRead - 1;
await this._logger.logWarning(`Event queue depth for ${this.id} is ${depth}.`);
}
const eventsData = await this._batchRetrieveEvents(lowerBoundReadIndex, upperBoundReadIndex);
if (this._flush) {
await this._incrementConsumerPartitionReadIndex(upperBoundReadIndex);
await this._removeKeys(eventsData.map(t => t.key));
continue;
}
const routed = new Array();
const eventDataKeys = new Array();
for (const item of eventsData) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this._isDisposed)
return;
let eventData = item.value;
if (this._cleanKeys)
eventDataKeys.push(item.key);
let numReadAttempts = 1;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (eventData == null && numReadAttempts < maxReadAttempts) // we need to do this to deal with race condition
{
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this._isDisposed)
return;
await Delay.milliseconds(100);
eventData = await this._retrieveEvent(item.key);
numReadAttempts++;
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (eventData == null) {
try {
throw new ApplicationException(`Failed to read event data after ${maxReadAttempts} read attempts => Topic=${this._topic}; Partition=${this._partition}; ReadIndex=${item.index};`);
}
catch (error) {
await this._logger.logError(error);
}
await this._incrementConsumerPartitionReadIndex();
continue;
}
const events = await this._decompressEvents(eventData);
for (const event of events) {
// const eventId = (<any>event).$id || (<any>event).id; // for compatibility with n-domain DomainEvent
// if (this._trackedKeysSet.has(eventId))
// continue;
// const eventName = (<any>event).$name || (<any>event).name; // for compatibility with n-domain DomainEvent
const deserializedEvent = Deserializer.deserialize(event);
const eventId = deserializedEvent.id;
if (this._trackedKeysSet.has(eventId))
continue;
if (deserializedEvent.name === this._nedaClearTrackedKeysEventName) {
await this._logger.logWarning(`NedaClearTrackedKeysEvent (${this._fullId}) --- clearing all event tracking data`);
await this._clearAllEventTracking();
await this._logger.logWarning(`NedaClearTrackedKeysEvent (${this._fullId}) --- event tracking data cleared`);
continue;
}
const eventName = deserializedEvent.name;
let eventRegistration;
if (eventName === this._nedaDistributedObserverNotifyEventName) {
const distributedObserverEvent = deserializedEvent;
const observationKey = EventRegistration.generateObservationKey(distributedObserverEvent.observerTypeName, distributedObserverEvent.observedEvent.refType, distributedObserverEvent.observedEvent.name);
eventRegistration = this._manager.observerEventMap.get(observationKey);
}
else
eventRegistration = this._manager.eventMap.get(eventName);
if (eventRegistration == null) // Because we check event registrations on publish, if the registration is null here, then that is a consequence of rolling deployment
{
this._track(eventId);
continue;
}
routed.push(this._attemptRoute(eventName, eventRegistration, item.index, item.key, eventId, event, deserializedEvent));
}
}
await Promise.all(routed);
await this._saveTrackedKeys();
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this._isDisposed)
return;
await this._incrementConsumerPartitionReadIndex(upperBoundReadIndex);
if (this._cleanKeys)
await this._removeKeys(eventDataKeys);
}
catch (error) {
await this._logger.logWarning(`Error in consumer => ConsumerGroupId: ${this._manager.consumerGroupId}; Topic: ${this._topic}; Partition: ${this._partition};`);
await this._logger.logError(error);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this._isDisposed)
return;
await Delay.seconds(5);
}
}
}
async _attemptRoute(eventName, eventRegistration, eventIndex, eventKey, eventId, rawEvent, event) {
var _a;
const traceData = (_a = rawEvent["$traceData"]) !== null && _a !== void 0 ? _a : {};
const parentContext = otelApi.propagation.extract(otelApi.ROOT_CONTEXT, traceData);
const tracer = otelApi.trace.getTracer("n-eda");
const span = tracer.startSpan(`event.${event.name} receive`, {
kind: otelApi.SpanKind.INTERNAL,
attributes: {
[semCon.SemanticAttributes.MESSAGING_SYSTEM]: "n-eda",
[semCon.SemanticAttributes.MESSAGING_OPERATION]: "receive",
[semCon.SemanticAttributes.MESSAGING_DESTINATION]: `${this._topic}+++${this._partition}`,
[semCon.SemanticAttributes.MESSAGING_DESTINATION_KIND]: "topic",
[semCon.SemanticAttributes.MESSAGING_TEMP_DESTINATION]: false,
[semCon.SemanticAttributes.MESSAGING_PROTOCOL]: "NEDA",
[semCon.SemanticAttributes.MESSAGE_ID]: event.id,
[semCon.SemanticAttributes.MESSAGING_CONVERSATION_ID]: event.partitionKey
}
}, parentContext);
// otelApi.trace.setSpan(otelApi.context.active(), span);
// traceData = {};
// otelApi.propagation.inject(otelApi.trace.setSpan(otelApi.context.active(), span), traceData);
// (<any>rawEvent)["$traceData"] = traceData;
let brokerDisposed = false;
try {
await otelApi.context.with(otelApi.trace.setSpan(otelApi.context.active(), span), async () => {
await this._broker.route({
consumerId: this.id,
topic: this._topic,
partition: this._partition,
eventName,
eventRegistration,
eventIndex,
eventKey,
eventId,
rawEvent,
event,
partitionKey: this._manager.partitionKeyMapper(event),
span
});
});
}
catch (error) {
span.recordException(error);
span.setStatus({ code: otelApi.SpanStatusCode.ERROR });
if (error instanceof ObjectDisposedException)
brokerDisposed = true;
// await this._logger.logWarning(`Failed to consume event of type '${eventName}' with data ${JSON.stringify(event.serialize())}`);
// await this._logger.logError(error as Exception);
}
finally {
// if (failed && this._isDisposed) // cuz it could have failed because things were disposed
// // eslint-disable-next-line no-unsafe-finally
// return;
if (!brokerDisposed)
this._track(eventId);
span.end();
}
}
// private _fetchPartitionWriteIndex(): Promise<number>
// {
// const key = `${this._edaPrefix}-${this._topic}-${this._partition}-write-index`;
// return new Promise((resolve, reject) =>
// {
// this._client.get(key, (err, value) =>
// {
// if (err)
// {
// reject(err);
// return;
// }
// // console.log("fetchPartitionWriteIndex", JSON.parse(value!));
// resolve(value != null ? JSON.parse(value) : 0);
// });
// });
// }
// private _fetchConsumerPartitionReadIndex(): Promise<number>
// {
// const key = `${this._edaPrefix}-${this._topic}-${this._partition}-${this._manager.consumerGroupId}-read-index`;
// return new Promise((resolve, reject) =>
// {
// this._client.get(key, (err, value) =>
// {
// if (err)
// {
// reject(err);
// return;
// }
// // console.log("fetchConsumerPartitionReadIndex", JSON.parse(value!));
// resolve(value != null ? JSON.parse(value) : 0);
// });
// });
// }
_fetchPartitionWriteAndConsumerPartitionReadIndexes() {
return new Promise((resolve, reject) => {
this._client.mget(this._writeIndexKey, this._readIndexKey, (err, results) => {
if (err) {
reject(err);
return;
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
resolve(results.map(value => value != null ? JSON.parse(value) : 0));
}).catch(e => reject(e));
});
}
_incrementConsumerPartitionReadIndex(index) {
if (index != null) {
return new Promise((resolve, reject) => {
this._client.set(this._readIndexKey, index.toString(), (err) => {
if (err) {
reject(err);
return;
}
resolve();
}).catch(e => reject(e));
});
}
return new Promise((resolve, reject) => {
this._client.incr(this._readIndexKey, (err) => {
if (err) {
reject(err);
return;
}
resolve();
}).catch(e => reject(e));
});
}
_retrieveEvent(key) {
return new Promise((resolve, reject) => {
this._client.getBuffer(key, (err, value) => {
if (err) {
reject(err);
return;
}
resolve(value);
}).catch(e => reject(e));
});
}
_batchRetrieveEvents(lowerBoundIndex, upperBoundIndex) {
return new Promise((resolve, reject) => {
const keys = new Array();
for (let i = lowerBoundIndex; i <= upperBoundIndex; i++) {
const key = `${this.id}-${i}`;
keys.push({ index: i, key });
}
this._client.mgetBuffer(...keys.map(t => t.key), (err, values) => {
if (err) {
reject(err);
return;
}
const result = values.map((t, index) => ({
index: keys[index].index,
key: keys[index].key,
value: t
}));
resolve(result);
}).catch(e => reject(e));
});
}
async _clearAllEventTracking() {
await new Promise((resolve, reject) => {
this._client.unlink(this._trackedKeysKey, (err) => {
if (err) {
reject(err);
return;
}
resolve();
}).catch(e => reject(e));
});
this._trackedKeysSet = new Set();
this._trackedKeysArray = new Array();
this._keysToTrack = new Array();
}
_track(eventKey) {
this._trackedKeysSet.add(eventKey);
this._trackedKeysArray.push(eventKey);
this._keysToTrack.push(eventKey);
}
async _saveTrackedKeys() {
if (this._keysToTrack.isNotEmpty) {
if (this._isDisposed)
await this._logger.logInfo(`Saving ${this._keysToTrack.length} tracked keys in ${this.id}`);
await new Promise((resolve, reject) => {
this._client.lpush(this._trackedKeysKey, ...this._keysToTrack, (err) => {
if (err) {
reject(err);
return;
}
resolve();
}).catch(e => reject(e));
});
if (this._isDisposed)
await this._logger.logInfo(`Saved ${this._keysToTrack.length} tracked keys in ${this.id}`);
this._keysToTrack = new Array();
}
if (this._isDisposed)
return;
if (this._trackedKeysSet.size >= this._maxTrackedSize) {
const newTracked = this._trackedKeysArray.skip(this._maxTrackedSize - this._keepTrackedSize);
this._trackedKeysSet = new Set(newTracked);
this._trackedKeysArray = newTracked;
await this._purgeTrackedKeys();
// await Promise.all([
// erasedKeys.isNotEmpty ? this._removeKeys(erasedKeys) : Promise.resolve(),
// this._purgeTrackedKeys()
// ]);
}
}
// private async _track(eventKey: string): Promise<void>
// {
// this._trackedKeysSet.add(eventKey);
// await this._saveTrackedKey(eventKey);
// if (this._trackedKeysSet.size >= 300)
// {
// const trackedKeysArray = [...this._trackedKeysSet.values()];
// this._trackedKeysSet = new Set<string>(trackedKeysArray.skip(200));
// if (this._cleanKeys)
// {
// const erasedKeys = trackedKeysArray.take(200);
// await this._removeKeys(erasedKeys);
// }
// await this._purgeTrackedKeys();
// }
// }
// private _saveTrackedKey(key: string): Promise<void>
// {
// return new Promise((resolve, reject) =>
// {
// this._client.lpush(this._trackedKeysKey, key, (err) =>
// {
// if (err)
// {
// reject(err);
// return;
// }
// resolve();
// });
// });
// }
_purgeTrackedKeys() {
return new Promise((resolve, reject) => {
this._client.ltrim(this._trackedKeysKey, 0, this._keepTrackedSize - 1, (err) => {
if (err) {
reject(err);
return;
}
resolve();
}).catch(e => reject(e));
});
}
// private _purgeTrackedKeys(): void
// {
// this._client.ltrim(this._trackedKeysKey, 0, 1999).catch(e => this._logger.logError(e));
// }
_loadTrackedKeys() {
return new Promise((resolve, reject) => {
this._client.lrange(this._trackedKeysKey, 0, -1, (err, keys) => {
if (err) {
reject(err);
return;
}
keys = keys.reverse().map(t => t.toString("utf8"));
// console.log(keys);
this._trackedKeysSet = new Set(keys);
this._trackedKeysArray = keys;
resolve();
}).catch(e => reject(e));
});
}
// private async _decompressEvent(eventData: Buffer): Promise<object>
// {
// const decompressed = await Make.callbackToPromise<Buffer>(Zlib.brotliDecompress)(eventData,
// { params: { [Zlib.constants.BROTLI_PARAM_MODE]: Zlib.constants.BROTLI_MODE_TEXT } });
// return JSON.parse(decompressed.toString("utf8"));
// }
// private async _decompressEvent(eventData: Buffer): Promise<object>
// {
// const decompressed = await Snappy.uncompress(eventData, { asBuffer: true }) as Buffer;
// return MessagePack.unpack(decompressed);
// }
async _decompressEvents(eventData) {
const decompressed = await Make.callbackToPromise(Zlib.inflateRaw)(eventData);
return JSON.parse(decompressed.toString("utf8"));
}
async _removeKeys(keys) {
if (keys.isEmpty)
return;
return new Promise((resolve, reject) => {
this._client.unlink(...keys, (err) => {
if (err) {
reject(err);
return;
}
resolve();
}).catch(e => reject(e));
});
}
}
//# sourceMappingURL=consumer.js.map