zamza
Version:
Apache Kafka discovery, indexing, searches, storage, hooks and HTTP gateway
312 lines (311 loc) • 13.3 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const Debug = require("debug");
const debug = Debug("zamza:hookdealer");
const Bluebird = require("bluebird");
const HookClient_1 = require("./HookClient");
const MessageHandler_1 = require("./MessageHandler");
const DEFAULT_TIMEOUT = 1500;
const DEFAUT_RETRIES = 0;
const DEFAULT_RETRY_TIMEOUT = 1000;
const DEFAULT_SUBSCRIPTION_CONCURRENCY = 4;
const DEFAULT_REPLAY_CONCURRENCY = 4;
class HookDealer {
constructor(zamza) {
this.initialHooksLoaded = false;
this.oldTopicSubscriptionLength = 0;
this.metrics = zamza.metrics;
zamza.config.hooks = zamza.config.hooks ? zamza.config.hooks : {};
this.timeout = zamza.config.hooks.timeout ? zamza.config.hooks.timeout : DEFAULT_TIMEOUT;
this.retries = typeof zamza.config.hooks.retries === "number" ? zamza.config.hooks.retries : DEFAUT_RETRIES;
this.retryTimeout = zamza.config.hooks.retryTimeoutMs ?
zamza.config.hooks.retryTimeoutMs : DEFAULT_RETRY_TIMEOUT;
this.subscriptionConcurrency = zamza.config.hooks.subscriptionConcurrency ?
zamza.config.hooks.subscriptionConcurrency : DEFAULT_SUBSCRIPTION_CONCURRENCY;
this.replayConcurrency = zamza.config.hooks.replayConcurrency ?
zamza.config.hooks.replayConcurrency : DEFAULT_REPLAY_CONCURRENCY;
try {
this.validateConfigForConvenience(zamza.config.hooks.ignoreConfigValidation);
}
catch (error) {
debug("(HookDealer) Validation error: " + error.message);
process.exit(1);
}
this.mongoPoller = zamza.mongoPoller;
this.hookModel = zamza.mongoWrapper.getHook();
this.hookClient = new HookClient_1.default(zamza);
this.retryProducer = zamza.retryProducer;
this.producer = zamza.producer;
this.topicSubscriptionMap = {};
}
validateConfigForConvenience(ignoreConfigValidation) {
if (ignoreConfigValidation) {
return;
}
if (typeof this.timeout !== "number" || this.timeout < 50 || this.timeout > 45000) {
throw new Error("Configuring the hook timeout below 50 ms or above 45 seconds is not a good idea.");
}
if (typeof this.retries !== "number" || this.retries < 0 || this.retries > 25) {
throw new Error("Configuring the retries below 0 or above 25 is not a good idea.");
}
if (typeof this.retryTimeout !== "number" || this.retryTimeout < 0 || this.retryTimeout > 15000) {
throw new Error("Configuring the retryTimeout below 0 ms or above 15 seconds is not a good idea.");
}
if (typeof this.subscriptionConcurrency !== "number" || this.subscriptionConcurrency < 1 ||
this.subscriptionConcurrency > 150) {
throw new Error("Configuring the subscriptionConcurrency below 1 or above 150 is not a good idea.");
}
if (typeof this.replayConcurrency !== "number" || this.replayConcurrency < 1 ||
this.replayConcurrency > 150) {
throw new Error("Configuring the replayConcurrency below 1 or above 150 is not a good idea.");
}
}
findConfigForTopic(topic) {
const topicConfigs = this.mongoPoller.getCollected().topicConfigs;
for (let i = topicConfigs.length - 1; i >= 0; i--) {
if (topicConfigs[i].topic === topic) {
return topicConfigs[i];
}
}
return null;
}
processHookUpdate(hooks) {
if (!hooks) {
return;
}
if (!this.initialHooksLoaded) {
debug("Initial hooks loaded", hooks.length);
this.initialHooksLoaded = true;
}
let endpoints = 0;
// transform the hooks into a structure that is more performant to process
const topicSubscriptionMap = {};
hooks.forEach((hook) => {
if (hook.disabled || !hook.subscriptions) {
return; // skip
}
hook.subscriptions.forEach((subscription) => {
if (subscription.disabled) {
return; // skip
}
if (topicSubscriptionMap[subscription.topic]) {
topicSubscriptionMap[subscription.topic] = [];
}
const hookClone = JSON.parse(JSON.stringify(hook));
delete hookClone.subscriptions;
hookClone.ignoreReplay = subscription.ignoreReplay;
topicSubscriptionMap[subscription.topic].push(hookClone);
endpoints++;
});
});
if (endpoints !== this.oldTopicSubscriptionLength) {
debug("Topic Subscription Map has changed from", this.oldTopicSubscriptionLength, "to", endpoints);
this.oldTopicSubscriptionLength = endpoints;
this.metrics.set("configured_active_subscriptions", endpoints);
}
this.topicSubscriptionMap = topicSubscriptionMap;
}
async handleSubscription(message, mappedHook, replayPayload, retryPayload) {
const body = {
message,
context: null,
};
if (replayPayload) {
delete replayPayload.message;
body.context = {
type: "replay",
data: replayPayload,
};
}
else if (retryPayload) {
delete retryPayload.message;
body.context = {
type: "retry",
data: replayPayload,
};
}
const options = {
method: "POST",
url: mappedHook.endpoint,
headers: {
"content-type": "application/json",
},
timeout: this.timeout,
body: JSON.stringify(body),
};
if (mappedHook.authorizationHeader && mappedHook.authorizationValue) {
options.headers[mappedHook.authorizationHeader] = mappedHook.authorizationValue;
}
const { status, body: responseBody } = await this.hookClient.call(options);
// simple delivery hook
if (status === 200) {
return;
}
// hook delivered, however a message should be produced in return
if (status === 205) {
try {
const { topic, partition, key, value, } = JSON.parse(responseBody);
await this.producer.produceMessage(topic, partition, key, value);
}
catch (error) {
debug("Failed to produce message on 205 hook response: "
+ error.message + ", " + mappedHook.endpoint);
}
return;
}
throw new Error(`Expected status code did not match 200 or 205 ${status}.`);
}
async handleMessage(message) {
// called after message handler has handled mongo storage
const subscriptions = this.topicSubscriptionMap[message.topic];
if (!subscriptions) {
return false;
}
this.metrics.inc("hook_processed_messages");
await Bluebird.map(subscriptions, (subscription) => {
return this.handleSubscription(message, subscription).then(() => {
this.metrics.inc("hook_delivered");
}).catch((_) => {
this.metrics.inc("hook_failed");
if (this.retries > 0) {
const retryMessage = {
message,
hookId: subscription._id,
retryCount: 0,
fromReplay: false,
};
this.metrics.inc("hook_produce_retry");
setTimeout(() => {
this.retryProducer.produceMessage(MessageHandler_1.INTERNAL_TOPICS.RETRY_TOPIC, undefined, undefined, JSON.stringify(retryMessage));
}, this.retryTimeout);
}
});
}, { concurrency: this.subscriptionConcurrency });
this.metrics.inc("hook_processed_messages_success");
return true;
}
async handleRetryMessage(message) {
// called before message handler has handled mongo storage
this.metrics.inc("hook_processed_retry_messages");
if (!message.value) {
return false;
}
let parsedMessage = null;
try {
if (typeof message.value === "string") {
parsedMessage = JSON.parse(Buffer.isBuffer(message.value) ?
message.value.toString("utf8") : message.value);
}
else {
parsedMessage = message.value;
}
if (!parsedMessage) {
throw new Error("Parsed Message empty.");
}
if (!parsedMessage.hookId) {
throw new Error("HookID missing on retry message payload.");
}
}
catch (error) {
debug("Failed to parse retry message payload: " + error.message);
return false;
}
// no topic config present anymore, skip hooks for replays
if (!this.findConfigForTopic(parsedMessage.message.topic)) {
this.metrics.inc("hook_processed_retry_messages_success");
return false;
}
// subscription has been deleted OR is disabled currently
// let error fall through for retry
const subscription = await this.hookModel.get(parsedMessage.hookId);
if (!subscription || subscription.disabled) {
return false;
}
return this.handleSubscription(parsedMessage.message, subscription, undefined, parsedMessage).then(() => {
this.metrics.inc("hook_retry_delivered");
this.metrics.inc("hook_processed_retry_messages_success");
return true;
}).catch((_) => {
this.metrics.inc("hook_retry_failed");
if (this.retries > parsedMessage.retryCount) {
const retryMessage = {
message,
hookId: subscription._id,
retryCount: parsedMessage.retryCount + 1,
};
this.metrics.inc("hook_produce_retry");
setTimeout(() => {
this.retryProducer.produceMessage(MessageHandler_1.INTERNAL_TOPICS.RETRY_TOPIC, undefined, undefined, JSON.stringify(retryMessage));
}, this.retryTimeout);
}
else {
this.metrics.inc("hook_retry_reached");
}
this.metrics.inc("hook_processed_retry_messages_success");
return true;
});
}
async handleReplayMessage(message) {
// called before message handler has handled mongo storage
this.metrics.inc("hook_processed_replay_messages");
if (!message.value) {
return false;
}
let parsedMessage = null;
try {
if (typeof message.value === "string") {
parsedMessage = JSON.parse(Buffer.isBuffer(message.value) ?
message.value.toString("utf8") : message.value);
}
else {
parsedMessage = message.value;
}
if (!parsedMessage) {
throw new Error("Parsed Message empty.");
}
}
catch (error) {
debug("Failed to parse retry message payload: " + error.message);
return false;
}
// no topic config present anymore, skip hooks for replays
if (!this.findConfigForTopic(parsedMessage.message.topic)) {
this.metrics.inc("hook_processed_retry_messages_success");
return false;
}
let subscriptions = this.topicSubscriptionMap[parsedMessage.message.topic];
if (!subscriptions) {
return false;
}
// filter out subscriptions that dont participate in replays
subscriptions = subscriptions.filter((subscription) => !subscription.ignoreReplay);
await Bluebird.map(subscriptions, (subscription) => {
return this.handleSubscription(parsedMessage.message, subscription, parsedMessage).then(() => {
this.metrics.inc("hook_replay_delivered");
}).catch((_) => {
this.metrics.inc("hook_replay_failed");
if (this.retries > 0) {
const retryMessage = {
message,
hookId: subscription._id,
retryCount: 0,
fromReplay: true,
};
this.metrics.inc("hook_replay_produce_retry");
setTimeout(() => {
this.retryProducer.produceMessage(MessageHandler_1.INTERNAL_TOPICS.RETRY_TOPIC, undefined, undefined, JSON.stringify(retryMessage));
}, this.retryTimeout);
}
});
}, { concurrency: this.replayConcurrency });
this.metrics.inc("hook_processed_replay_messages_success");
return true;
}
close() {
if (this.hookClient) {
return this.hookClient.close();
}
return null;
}
}
exports.default = HookDealer;