event-store-client
Version:
Client library for connecting to Event Store instances over TCP/IP
423 lines (363 loc) • 18.5 kB
JavaScript
/**
* This module is a port of the catch-up subscription functionality from
* Event Store's official .NET client. Primarily, it's a port of the C# module
* EventStoreCatchUpSubscription: https://github.com/EventStore/EventStore/blob/release-v3.7.0/src/EventStore.ClientAPI/EventStoreCatchUpSubscription.cs
*
* NOTABLE DIFFERENCES
*
* - Instead of passing a logger object and a Verbose flag, we accept a simple debug flag, which, if
* set to true, will cause console log messages to be written in the places where the C# client would have
* written to the logger (verbose mode or no). This behavior more closely matches that of the existing Node
* client so far.
*
* - No reconnect handling. The Connection object in this Node client currently does not emit anything like a Connect
* event, so there is no way to hook into such an occurrence.
*
* - Handles event processing in the main thread, following Node's convention of async/single-threaded execution,
* expecting good async, non-blocking behavior from any client using this package.
*/
(function (catchUpSubscription) {
var ArgValidator = require('argument-validator');
// CONSTANTS
const DefaultReadBatchSize = 500;
const MaxReadSize = 4096;
const DefaultMaxPushQueueSize = 10000;
/**
* Settings for <tt>EventStoreCatchUpSubscription</tt>
*
* @constructor
* @param {number} maxLiveQueueSize The max amount to buffer when processing from live subscription.
* @param {number} readBatchSize The number of events to read per batch when reading history
* @param {boolean} debug True iff in debug mode
* @param {boolean} resolveLinkTos Whether or not to resolve link events
*/
function CatchUpSubscriptionSettings(maxLiveQueueSize, readBatchSize, debug, resolveLinkTos) {
// Validate arguments (supplying defaults where arguments are null or missing).
if (arguments.length < 4 || resolveLinkTos == null) {
resolveLinkTos = false;
} else {
ArgValidator.boolean(resolveLinkTos, 'resolveLinkTos');
}
if (arguments.length < 3 || debug == null) {
debug = false;
} else {
ArgValidator.boolean(debug, 'debug');
}
if (arguments.length < 2 || readBatchSize == null) {
readBatchSize = DefaultReadBatchSize;
} else {
ArgValidator.number(readBatchSize, 'readBatchSize');
}
if (arguments.length < 1 || maxLiveQueueSize == null) {
maxLiveQueueSize = DefaultMaxPushQueueSize;
} else {
ArgValidator.number(maxLiveQueueSize, 'maxLiveQueueSize');
}
if (readBatchSize > MaxReadSize) throw new Error("Read batch size should be less than " + MaxReadSize.toString() + ". For larger reads you should page.");
this.maxLiveQueueSize = maxLiveQueueSize;
this.readBatchSize = readBatchSize;
this.debug = debug;
this.resolveLinkTos = resolveLinkTos;
}
/**
* Base class representing catch-up subscriptions.
*
* @constructor
* @param {Connection} connection The connection to Event Store
* @param {string} streamId The stream name (only if subscribing to a single stream)
* @param {ICredentials} userCredentials User credentials for the operations.
* @param {function} eventAppeared Callback for each event received
* @param {function} liveProcessingStarted Callback when read history phase finishes.
* @param {function} subscriptionDropped Callback when subscription drops or is dropped.
* @param {CatchUpSubscriptionSettings} settings Settings for this subscription.
*/
function EventStoreCatchUpSubscription(connection, streamId, userCredentials, eventAppeared, liveProcessingStarted, subscriptionDropped, settings) {
ArgValidator.notNull(connection, 'connection');
ArgValidator.notNull(eventAppeared, 'eventAppeared');
if (!settings) settings = new CatchUpSubscriptionSettings();
this._connection = connection;
this._streamId = streamId || "";
this._resolveLinkTos = settings.resolveLinkTos;
this._userCredentials = userCredentials;
this.readBatchSize = settings.readBatchSize;
this.maxPushQueueSize = settings.maxLiveQueueSize;
this.eventAppeared = eventAppeared;
this._liveProcessingStarted = liveProcessingStarted;
this._subscriptionDropped = subscriptionDropped;
this.debug = settings.debug;
this._shouldStop = false;
this._dropData = null;
this._liveQueue = [];
this._allowProcessing = false;
this._subscription = null;
}
/**
* Accept parameters to pass on to console.log.
*/
EventStoreCatchUpSubscription.prototype._log = function () {
if (this.debug) console.log.apply(console, arguments);
};
EventStoreCatchUpSubscription.prototype.getCorrelationId = function () {
return this._subscription ? this._subscription.correlationId : null;
};
/**
* Indicates whether the subscription is to all events or to a specific stream.
*/
EventStoreCatchUpSubscription.prototype.isSubscribedToAll = function () { return this._streamId.length == 0; };
EventStoreCatchUpSubscription.prototype.start = function () {
this._log("Catch-up Subscription to %s: starting...", this.isSubscribedToAll() ? "<all>" : this._streamId);
this.runSubscription();
};
/**
* Attempts to stop the subscription without blocking for completion of stop
*/
EventStoreCatchUpSubscription.prototype.stop = function() {
this._log("Catch-up Subscription to %s: requesting stop...", this.isSubscribedToAll() ? "<all>" : this._streamId);
this._shouldStop = true;
this.enqueueSubscriptionDropNotification('UserInitiated', null);
};
EventStoreCatchUpSubscription.prototype.runSubscription = function () {
this.loadHistoricalEvents(this.handleError.bind(this));
};
EventStoreCatchUpSubscription.prototype.loadHistoricalEvents = function(callback) {
this._log("Catch-up Subscription to %s: running...", this.isSubscribedToAll() ? "<all>" : this._streamId);
var _this = this;
this._allowProcessing = false;
if (!this._shouldStop) {
this._log("Catch-up Subscription to %s: pulling events...", this.isSubscribedToAll() ? "<all>" : this._streamId);
this.readEventsTill(null, null, function(err) {
if (err) {
if (callback) callback(err);
} else {
_this.subscribeToStream(callback);
}
});
} else {
this.dropSubscription('UserInitiated');
if (callback) callback();
}
};
EventStoreCatchUpSubscription.prototype.subscribeToStream = function(callback) {
var _this = this;
if (!this._shouldStop) {
this._log("Catch-up Subscription to %s: subscribing...", this.isSubscribedToAll() ? "<all>" : this._streamId);
if (this.isSubscribedToAll()) {
callback(new Error('Cannot do catch-up subscription to all at this time. Not implemented: connection.subscribeToAll'));
} else {
var subscriptionId =
this._connection.subscribeToStream(
this._streamId, this._resolveLinkTos,
this.enqueuePushedEvent.bind(this),
function(confirmation) {
_this._subscription = {
correlationId: subscriptionId,
lastCommitPosition: confirmation.last_commit_position,
lastEventNumber: confirmation.last_event_number
};
_this.readMissedHistoricEvents(callback);
},
this.serverSubscriptionDropped.bind(this),
this._userCredentials);
}
} else {
this.dropSubscription('UserInitiated');
if (callback) callback();
}
};
EventStoreCatchUpSubscription.prototype.readMissedHistoricEvents = function (callback) {
var _this = this;
if (!this._shouldStop) {
this._log("Catch-up Subscription to %s: pulling events (if left)...", this.isSubscribedToAll() ? "<all>" : this._streamId);
this.readEventsTill(this._subscription.lastCommitPosition, this._subscription.lastEventNumber, function (err) {
if (err) {
if (callback) callback(err);
} else {
_this.startLiveProcessing(callback);
}
});
} else {
this.dropSubscription('UserInitiated');
if (callback) callback();
}
};
EventStoreCatchUpSubscription.prototype.startLiveProcessing = function (callback) {
if (this._shouldStop) {
this.dropSubscription('UserInitiated');
if (callback) callback();
return;
}
this._log("Catch-up Subscription to %s: processing live events...", this.isSubscribedToAll() ? "<all>" : this._streamId);
if (this._liveProcessingStarted) this._liveProcessingStarted();
this._allowProcessing = true;
this.processLiveQueue();
if (callback) callback();
};
EventStoreCatchUpSubscription.prototype.enqueuePushedEvent = function(event) {
var origEvent = event.link || event;
this._log("Catch-up Subscription to %s: event appeared (%s, %d, %s).",
this.isSubscribedToAll() ? "<all>" : this._streamId,
origEvent.streamId, origEvent.eventNumber, origEvent.eventType);
if (this._liveQueue.length >= this.maxPushQueueSize) {
this.enqueueSubscriptionDropNotification('ProcessingQueueOverflow');
return;
}
this._liveQueue.push(event);
if (this._allowProcessing) this.processLiveQueue();
};
EventStoreCatchUpSubscription.prototype.serverSubscriptionDropped = function(dropped) {
this.enqueueSubscriptionDropNotification(dropped.reason);
};
EventStoreCatchUpSubscription.prototype.enqueueSubscriptionDropNotification = function(reason, err) {
// if drop data was already set -- no need to enqueue drop again, somebody did that already
if (this._dropData == null) {
this._dropData = { reason: reason, error: err };
this._liveQueue.push(this.dropSubscriptionEvent());
if (this._allowProcessing) this.processLiveQueue();
}
};
EventStoreCatchUpSubscription.prototype.handleError = function(err) {
if (err) {
this.dropSubscription('CatchUpError', err);
}
};
EventStoreCatchUpSubscription.prototype.processLiveQueue = function() {
var e;
var _this = this;
while (e = this._liveQueue.shift()) {
if (e.specialType == 'DropSubscription') {
if (this._dropData == null) this._dropData = { reason: 'Unknown', error: new Error('Drop reason not specified') };
this.dropSubscription(this._dropData.reason, this._dropData.error);
return;
}
this.tryProcess(e, function(err) {
if (err) {
_this.dropSubscription('EventHandlerException', err);
}
});
}
};
EventStoreCatchUpSubscription.prototype.dropSubscriptionEvent = function() { return { specialType: 'DropSubscription' }; };
EventStoreCatchUpSubscription.prototype.dropSubscription = function(reason, err) {
var _this = this;
this._log("Catch-up Subscription to %s: dropping subscription, reason: %s, result: %s, error: %s.",
this.isSubscribedToAll() ? "<all>" : this._streamId, reason,
err == null ? "" : err.result, err == null ? "" : err.error);
if (this._subscription != null) {
this._connection.unsubscribeFromStream(this._subscription.correlationId, this._userCredentials, function (pkg) {
_this._subscription = null;
if (_this._subscriptionDropped) {
_this._subscriptionDropped(_this, reason, err);
}
});
} else if (this._subscriptionDropped) {
_this._subscriptionDropped(_this, reason, err);
}
};
function EventStoreAllCatchUpSubscription() {
throw new Error("NOT IMPLEMENTED: there is not yet any connection.subscribeToAll.")
}
// Wire up prototypal inheritance.
Object.setPrototypeOf(EventStoreAllCatchUpSubscription.prototype, EventStoreCatchUpSubscription.prototype);
/**
* Catch-up subscription for one stream.
*
* @constructor
* @param {Connection} connection The connection to Event Store
* @param {string} streamId The stream name (only if subscribing to a single stream)
* @param {number} fromEventNumberExclusive Which event number to start after (if null, then from the beginning of the stream.)
* @param {ICredentials} userCredentials User credentials for the operations.
* @param {function} eventAppeared Callback for each event received
* @param {function} liveProcessingStarted Callback when read history phase finishes.
* @param {function} subscriptionDropped Callback when subscription drops or is dropped.
* @param {CatchUpSubscriptionSettings} settings Settings for this subscription.
*/
function EventStoreStreamCatchUpSubscription(connection, streamId, fromEventNumberExclusive, userCredentials, eventAppeared, liveProcessingStarted, subscriptionDropped, settings) {
// Base class constructor (JS-style).
EventStoreCatchUpSubscription.call(this, connection, streamId, userCredentials, eventAppeared, liveProcessingStarted, subscriptionDropped, settings);
ArgValidator.notNull(streamId, 'streamId');
this._lastProcessedEventNumber = ArgValidator.isNumber(fromEventNumberExclusive) ? fromEventNumberExclusive : -1;
this._nextReadEventNumber = ArgValidator.isNumber(fromEventNumberExclusive) ? fromEventNumberExclusive : 0;
}
// Wire up prototypal inheritance.
Object.setPrototypeOf(EventStoreStreamCatchUpSubscription.prototype, EventStoreCatchUpSubscription.prototype);
/**
* Read events until the given event number async.
*
* @param {number} lastCommitPosition The commit position to read until.
* @param {number} lastEventNumber The event number to read until.
*/
EventStoreStreamCatchUpSubscription.prototype.readEventsTill = function (lastCommitPosition, lastEventNumber, callback) {
var nextReadEventNumber = this._nextReadEventNumber;
var _this = this;
var eventErr = null;
this._connection.readStreamEventsForward(
this._streamId, this._nextReadEventNumber, this.readBatchSize, this._resolveLinkTos, false,
function (event) {
_this.tryProcess(event, function (err) {
if (err && !eventErr) {
eventErr = err;
}
});
nextReadEventNumber = (event.link ? event.link.eventNumber : event.eventNumber) + 1;
},
this._userCredentials,
function (completed) {
// Check for error.
var effectiveErr = eventErr;
if (!effectiveErr) {
// Check for error reading events.
if (completed.result != 0) {
effectiveErr = new Error('Error reading stream events: (' + completed.result.toString() + ') ' + completed.error);
}
}
// Act on error.
if (effectiveErr) {
if (callback) callback(effectiveErr);
return;
}
var isEndOfStream = nextReadEventNumber == _this._nextReadEventNumber; // Didn't read any events.
var done = lastEventNumber == null ? isEndOfStream : nextReadEventNumber > lastEventNumber;
_this._nextReadEventNumber = nextReadEventNumber;
if (!done && !_this._shouldStop) {
setTimeout(
function () {
_this.readEventsTill(lastCommitPosition, lastEventNumber, callback);
},
isEndOfStream ? 1 : 0 // Waiting for server to flush its data...
);
} else {
_this._log("Catch-up Subscription to %s: finished reading events, nextReadEventNumber = %d.",
_this.isSubscribedToAll() ? "<all>" : _this._streamId, _this._nextReadEventNumber);
if (callback) callback();
}
});
};
/**
* Try to process a single resolved event.
*
* @param event The resolved event to process.
*/
EventStoreStreamCatchUpSubscription.prototype.tryProcess = function (event, callback) {
var origEvent = event.link || event;
var processed = false;
try {
if (origEvent.eventNumber > this._lastProcessedEventNumber) {
this.eventAppeared(event);
this._lastProcessedEventNumber = origEvent.eventNumber;
processed = true;
}
this._log("Catch-up Subscription to %s: %s event (%s, %d, %s)",
this.isSubscribedToAll() ? "<all>" : this._streamId,
processed ? "processed" : "skipping",
origEvent.streamId, origEvent.eventNumber, origEvent.eventType);
if (callback) callback();
}
catch (err) {
if (callback) callback(err);
}
};
// EXPOSE public types.
catchUpSubscription.Settings = CatchUpSubscriptionSettings;
catchUpSubscription.Stream = EventStoreStreamCatchUpSubscription;
catchUpSubscription.All = EventStoreAllCatchUpSubscription; // WARNING: NOT IMPLEMENTED - will throw exception when used.
})(module.exports);