unrip/src/apps/history-writer.mjs
philipp d9e7d570f4
All checks were successful
deploy / deploy (push) Successful in 1m13s
Bound raw quote retention drain
Proof: Raw NEAR Intents quote retention now keeps only a 30 minute raw firehose window and drains up to 10M stale unlinked raw rows per pass. Targeted raw retention tests, full npm test, and operator dashboard bundle build pass.

Assumptions: raw quote firehose rows are debug evidence, while normalized quote demand, decisions, commands, executor results, outcomes, inventory, pricing, and DB config remain the durable trading evidence.

Still fake: venue-native terminal fill ids and fee-complete realized PnL remain unavailable; raw quote firehose rows truncated during emergency recovery are intentionally no longer readable.
2026-05-24 14:59:58 +02:00

713 lines
23 KiB
JavaScript

import process from 'node:process';
import { createConsumer } from '../bus/kafka/consumer.mjs';
import { startControlApi } from '../core/control-api.mjs';
import { routeHistoryRecord } from '../core/history-records.mjs';
import { shouldRunDerivedRefreshForEvent } from '../core/history-writer-refresh-policy.mjs';
import { createLogger, serializeError } from '../core/log.mjs';
import {
buildIntentRequestOutcomeNotification,
buildLiquidityActionNotification,
buildQuoteOutcomeNotification,
createNotificationDispatcher,
observedAtOrAfter,
} from '../core/notification-layer.mjs';
import { createNtfyNotificationClient } from '../core/notification-client.mjs';
import { buildPortfolioMetricId, computePortfolioMetric } from '../core/portfolio-metrics.mjs';
import { parseEventMessage } from '../core/event-envelope.mjs';
import { loadConfig } from '../lib/config.mjs';
import {
claimNotificationDelivery,
createTradingConfigStore,
createPostgresPool,
ensureHistorySchema,
insertEnvironmentStatusChange,
finishNotificationDelivery,
insertHistoryEvents,
loadLatestPortfolioMetric,
loadPortfolioMetricInputs,
pruneRawNearIntentsQuoteHistory,
refreshIntentRequestOutcomes,
refreshQuoteOutcomes,
seedTradingConfig,
upsertPortfolioMetric,
} from '../lib/postgres.mjs';
const config = loadConfig();
const logger = createLogger({
service: 'history-writer',
component: 'history',
namespace: config.projectNamespace,
});
const pool = createPostgresPool({
connectionString: config.postgresUrl,
});
await ensureHistorySchema(pool);
await seedTradingConfig(pool);
const tradingConfigStore = createTradingConfigStore({
pool,
logger: logger.child({ component: 'trading-config' }),
});
await tradingConfigStore.forceRefresh();
const notificationLogger = logger.child({ component: 'notifications' });
const notificationClient = createNtfyNotificationClient({
baseUrl: config.notificationNtfyBaseUrl,
topic: config.notificationNtfyTopic,
token: config.notificationNtfyToken,
timeoutMs: config.notificationNtfyTimeoutMs,
logger: notificationLogger,
});
const notificationDispatcher = createNotificationDispatcher({
client: notificationClient,
logger: notificationLogger,
store: {
claim: (notification) => claimNotificationDelivery(pool, {
notificationKey: notification.notification_key,
notificationType: notification.notification_type,
sourceKind: notification.source_kind,
sourceId: notification.source_id,
payload: notification.data || {},
}),
finish: (notification, result) => finishNotificationDelivery(pool, {
notificationKey: notification.notification_key,
ok: result.ok,
response: result.ok ? { status: result.status, response: result.response || null } : null,
error: result.ok ? null : result.error || { status: result.status, response: result.response || null },
}),
},
});
const durableHistoryConsumerCount = 3;
const durableConsumers = [];
for (let index = 0; index < durableHistoryConsumerCount; index += 1) {
durableConsumers.push(await createConsumer({
groupId: config.kafkaConsumerGroupHistory,
brokers: config.kafkaBrokers,
clientId: config.kafkaClientId,
logger,
}));
}
const liveEvidenceConsumerCount = 2;
const liveEvidenceConsumers = [];
for (let index = 0; index < liveEvidenceConsumerCount; index += 1) {
liveEvidenceConsumers.push(await createConsumer({
groupId: `${config.kafkaConsumerGroupHistory}-live`,
brokers: config.kafkaBrokers,
clientId: config.kafkaClientId,
logger,
}));
}
const rawQuoteConsumer = await createConsumer({
groupId: `${config.kafkaConsumerGroupHistory}-raw`,
brokers: config.kafkaBrokers,
clientId: config.kafkaClientId,
logger,
});
const topics = [
config.kafkaTopicNormSwapDemand,
config.kafkaTopicRefMarketPrice,
config.kafkaTopicStateIntentInventory,
config.kafkaTopicOpsLiquidityAction,
config.kafkaTopicOpsFundingObservation,
config.kafkaTopicOpsAlert,
config.kafkaTopicOpsEnvironmentStatus,
config.kafkaTopicDecisionTradeDecision,
config.kafkaTopicCmdExecuteTrade,
config.kafkaTopicExecTradeResult,
];
const rawQuoteTopics = [
config.kafkaTopicRawNearIntentsQuote,
];
const rawQuoteHistoryPruneIntervalMs = 60 * 1000;
const rawQuoteHistoryRetainRecentMs = 30 * 60 * 1000;
const rawQuoteHistoryPruneBatchSize = 500_000;
const rawQuoteHistoryPruneMaxBatches = 20;
const liveEvidenceTopics = [
config.kafkaTopicNormSwapDemand,
config.kafkaTopicDecisionTradeDecision,
];
const portfolioMetricTopics = new Set([
config.kafkaTopicRefMarketPrice,
config.kafkaTopicStateIntentInventory,
config.kafkaTopicOpsLiquidityAction,
config.kafkaTopicCmdExecuteTrade,
config.kafkaTopicExecTradeResult,
]);
const quoteOutcomeTopics = new Set([
config.kafkaTopicStateIntentInventory,
config.kafkaTopicCmdExecuteTrade,
config.kafkaTopicExecTradeResult,
]);
const intentRequestOutcomeTopics = new Set([
config.kafkaTopicStateIntentInventory,
]);
for (const historyConsumer of durableConsumers) {
for (const topic of topics) {
await historyConsumer.subscribe({
topic,
fromBeginning: true,
});
}
}
for (const historyConsumer of liveEvidenceConsumers) {
for (const topic of liveEvidenceTopics) {
// The durable group keeps replaying retained history. This live group keeps
// current quote/decision truth visible while that backlog drains.
await historyConsumer.subscribe({
topic,
fromBeginning: false,
});
}
}
for (const topic of rawQuoteTopics) {
// Raw quote volume is a live firehose. It gets a dedicated consumer group so
// raw storage cannot starve durable strategy/execution topics.
await rawQuoteConsumer.subscribe({
topic,
fromBeginning: false,
});
}
const state = {
paused: false,
draining: false,
last_write_at: null,
last_funding_observation_write_at: null,
last_alert_write_at: null,
last_environment_status_write_at: null,
last_environment_status_seen_at: null,
last_environment_status_duplicate_at: null,
last_environment_status_fingerprint: null,
last_metrics_at: null,
last_error: null,
error_count: 0,
offsets: {},
latest_portfolio_metrics: null,
metrics_error: null,
last_quote_outcomes_at: null,
latest_quote_outcomes: null,
quote_outcomes_error: null,
last_intent_request_outcomes_at: null,
latest_intent_request_outcomes: null,
intent_request_outcomes_error: null,
last_raw_quote_prune_at: null,
raw_quote_prune_deleted_count: 0,
raw_quote_prune_error: null,
derived_refresh_skipped_count: 0,
last_derived_refresh_skipped_at: null,
last_derived_refresh_skipped_topic: null,
notification_count: 0,
notification_error_count: 0,
last_notification_at: null,
last_notification_error: null,
};
await refreshPortfolioMetrics().catch((error) => {
state.metrics_error = serializeError(error);
});
await refreshQuoteOutcomeAttributions().catch((error) => {
state.quote_outcomes_error = serializeError(error);
});
await refreshIntentRequestOutcomeAttributions().catch((error) => {
state.intent_request_outcomes_error = serializeError(error);
});
for (const historyConsumer of durableConsumers) {
await runHistoryConsumer(historyConsumer);
}
for (const historyConsumer of liveEvidenceConsumers) {
await runHistoryConsumer(historyConsumer);
}
await runHistoryConsumer(rawQuoteConsumer);
async function runHistoryConsumer(historyConsumer) {
await historyConsumer.run({
eachBatch: async ({ batch, heartbeat }) => {
if (state.paused) return;
const contexts = [];
const batchEntries = [];
for (const message of batch.messages) {
if (!message.value) continue;
try {
const event = parseEventMessage(message.value.toString());
const routed = routeHistoryRecord({ topic: batch.topic, event });
const context = {
topic: batch.topic,
partition: batch.partition,
message,
event,
routed,
writeResult: null,
};
contexts.push(context);
if (batch.topic === config.kafkaTopicOpsEnvironmentStatus) {
context.writeResult = await insertEnvironmentStatusChange(pool, {
topic: batch.topic,
event,
record: routed.record,
});
} else {
batchEntries.push({
table: routed.table,
topic: batch.topic,
event,
record: routed.record,
});
}
} catch (error) {
recordHistoryError(batch.topic, error);
}
}
let insertedEventIds = new Set();
try {
({ insertedEventIds } = await insertHistoryEvents(pool, batchEntries));
} catch (error) {
recordHistoryError(batch.topic, error);
throw error;
}
for (const context of contexts) {
if (!context.writeResult) {
context.writeResult = {
inserted: insertedEventIds.has(context.event.event_id),
};
}
await handleWrittenHistoryEvent(context);
await heartbeat();
}
if (batch.topic === config.kafkaTopicRawNearIntentsQuote) {
await maybePruneRawQuoteHistory();
}
if (state.draining) {
setTimeout(() => shutdown(), 0);
}
},
});
}
async function maybePruneRawQuoteHistory({ force = false } = {}) {
const nowMs = Date.now();
const lastPruneMs = state.last_raw_quote_prune_at
? Date.parse(state.last_raw_quote_prune_at)
: 0;
if (
!force
&& Number.isFinite(lastPruneMs)
&& nowMs - lastPruneMs < rawQuoteHistoryPruneIntervalMs
) {
return null;
}
try {
const result = await pruneRawNearIntentsQuoteHistory(pool, {
now: new Date(nowMs).toISOString(),
retainRecentMs: rawQuoteHistoryRetainRecentMs,
batchSize: rawQuoteHistoryPruneBatchSize,
maxBatches: rawQuoteHistoryPruneMaxBatches,
});
state.last_raw_quote_prune_at = new Date(nowMs).toISOString();
state.raw_quote_prune_deleted_count += result.deletedCount;
state.raw_quote_prune_error = null;
if (result.deletedCount > 0) {
logger.info('raw_near_intents_quote_history_pruned', {
details: result,
});
}
return result;
} catch (error) {
state.raw_quote_prune_error = serializeError(error);
logger.error('raw_near_intents_quote_history_prune_failed', {
details: {
error: state.raw_quote_prune_error,
},
});
return null;
}
}
async function handleWrittenHistoryEvent({
topic,
partition,
message,
event,
writeResult,
}) {
const handledAt = new Date().toISOString();
if (writeResult.inserted) {
state.last_write_at = handledAt;
}
state.last_error = null;
state.offsets[topic] = {
partition,
offset: message.offset,
};
const shouldRunDerivedRefresh = shouldRunDerivedRefreshForEvent({
event,
now: handledAt,
maxEventAgeMs: config.historyWriterDerivedRefreshMaxEventAgeMs,
});
if (!shouldRunDerivedRefresh) {
state.derived_refresh_skipped_count += 1;
state.last_derived_refresh_skipped_at = handledAt;
state.last_derived_refresh_skipped_topic = topic;
}
if (topic === config.kafkaTopicOpsFundingObservation && writeResult.inserted) {
state.last_funding_observation_write_at = state.last_write_at;
}
if (topic === config.kafkaTopicOpsAlert && writeResult.inserted) {
state.last_alert_write_at = state.last_write_at;
}
if (topic === config.kafkaTopicOpsEnvironmentStatus) {
state.last_environment_status_seen_at = handledAt;
state.last_environment_status_fingerprint = writeResult.status_fingerprint || event.payload.status_fingerprint || null;
if (writeResult.inserted) {
state.last_environment_status_write_at = handledAt;
} else {
state.last_environment_status_duplicate_at = handledAt;
}
}
if (shouldRunDerivedRefresh) {
await publishLiquidityNotification({ topic, event });
}
if (shouldRunDerivedRefresh && portfolioMetricTopics.has(topic)) {
try {
await refreshPortfolioMetrics();
} catch (error) {
state.metrics_error = serializeError(error);
logger.error('portfolio_metrics_refresh_failed', {
topic,
details: {
error: serializeError(error),
},
});
}
}
if (shouldRunDerivedRefresh && quoteOutcomeTopics.has(topic)) {
try {
const records = await refreshQuoteOutcomeAttributions();
await publishQuoteOutcomeNotifications(records, {
minObservedAt: event.observed_at || event.ingested_at || state.last_write_at,
});
} catch (error) {
state.quote_outcomes_error = serializeError(error);
logger.error('quote_outcomes_refresh_failed', {
topic,
details: {
error: serializeError(error),
},
});
}
}
if (shouldRunDerivedRefresh && intentRequestOutcomeTopics.has(topic)) {
try {
const records = await refreshIntentRequestOutcomeAttributions();
await publishIntentRequestOutcomeNotifications(records, {
minObservedAt: event.observed_at || event.ingested_at || state.last_write_at,
});
} catch (error) {
state.intent_request_outcomes_error = serializeError(error);
logger.error('intent_request_outcomes_refresh_failed', {
topic,
details: {
error: serializeError(error),
},
});
}
}
}
function recordHistoryError(topic, error) {
state.last_error = serializeError(error);
state.error_count += 1;
logger.error('history_write_failed', {
topic,
details: {
error: serializeError(error),
},
});
}
const controlApi = startControlApi({
host: config.historyWriterControlHost,
port: config.historyWriterControlPort,
logger: logger.child({ component: 'control-api' }),
service: 'history-writer',
namespace: config.projectNamespace,
stateProvider: {
async getState() {
const connectivity = await pool.query('SELECT 1').then(() => true).catch(() => false);
return {
...state,
database_connectivity: connectivity,
trading_config: tradingConfigStore.getState(),
};
},
},
healthProvider: {
async getHealth() {
const connectivity = await pool.query('SELECT 1').then(() => true).catch(() => false);
const tradingConfig = tradingConfigStore.getState();
const lastTruthAt = state.last_write_at || state.last_metrics_at || null;
const freshnessAgeMs = lastTruthAt ? Date.now() - new Date(lastTruthAt).getTime() : null;
return {
ok: connectivity
&& tradingConfig.ok === true
&& (freshnessAgeMs == null || freshnessAgeMs <= config.opsSentinelHistoryWriterStaleMs),
paused: state.paused,
last_write_at: state.last_write_at,
last_alert_write_at: state.last_alert_write_at,
last_environment_status_write_at: state.last_environment_status_write_at,
last_environment_status_seen_at: state.last_environment_status_seen_at,
last_environment_status_duplicate_at: state.last_environment_status_duplicate_at,
last_environment_status_fingerprint: state.last_environment_status_fingerprint,
last_metrics_at: state.last_metrics_at,
freshness_age_ms: Number.isFinite(freshnessAgeMs) ? Math.max(0, freshnessAgeMs) : null,
database_connectivity: connectivity,
trading_config_ok: tradingConfig.ok,
trading_config_block_reason: tradingConfig.block_reason,
last_error: state.last_error,
reason: tradingConfig.ok === true
? null
: tradingConfig.block_reason || 'trading config unavailable',
};
},
},
routes: [
{
method: 'GET',
path: '/portfolio-metrics',
readBody: false,
handler: async () => {
const latest = await loadLatestPortfolioMetric(pool);
if (!latest) {
return {
statusCode: 404,
payload: {
error: 'portfolio_metrics_unavailable',
},
};
}
return latest;
},
},
{
method: 'POST',
path: '/pause',
handler: () => {
state.paused = true;
pauseConsumers();
return { ok: true, paused: true };
},
},
{
method: 'POST',
path: '/resume',
handler: () => {
state.paused = false;
resumeConsumers();
return { ok: true, paused: false };
},
},
{
method: 'POST',
path: '/drain',
handler: () => {
state.draining = true;
state.paused = true;
pauseConsumers();
setTimeout(() => shutdown(), 0);
return { ok: true, draining: true };
},
},
],
});
async function refreshPortfolioMetrics() {
const tradingConfig = await requireTradingConfig();
const inputs = await loadPortfolioMetricInputs(pool, {
btcAsset: tradingConfig.tradingBtc,
btcAssets: tradingConfig.tradingBtcAssets,
eureAsset: tradingConfig.tradingEure,
trackedAssets: tradingConfig.trackedAssets,
});
const payload = computePortfolioMetric({
baseline: inputs.baseline,
currentInventory: inputs.currentInventory?.payload,
currentPrice: inputs.currentPrice?.payload,
externalFlows: inputs.externalFlows || [],
btcAsset: tradingConfig.tradingBtc,
btcAssets: tradingConfig.tradingBtcAssets,
eureAsset: tradingConfig.tradingEure,
valuationAssets: inputs.valuationAssets || [],
commandCount: inputs.commandCount,
resultCount: inputs.resultCount,
});
if (!payload) return null;
const computedAt = new Date().toISOString();
const metricId = buildPortfolioMetricId({
baselineInventoryId: inputs.baseline?.inventory?.inventory_id || null,
currentInventoryId: inputs.currentInventory?.payload?.inventory_id || null,
currentPriceId: [
inputs.currentPrice?.payload?.price_id,
...(inputs.valuationAssets || []).map((asset) => asset.priceId),
].filter(Boolean).join('+') || null,
});
await upsertPortfolioMetric(pool, {
metricId,
computedAt,
baselineAnchorAt: inputs.baseline?.command_at || null,
baselineStatus: payload.baseline_status,
payload,
});
state.last_metrics_at = computedAt;
state.metrics_error = null;
state.latest_portfolio_metrics = summarizePortfolioMetric({
metric_id: metricId,
computed_at: computedAt,
baseline_anchor_at: inputs.baseline?.command_at || null,
baseline_status: payload.baseline_status,
payload,
});
return state.latest_portfolio_metrics;
}
async function refreshIntentRequestOutcomeAttributions() {
const tradingConfig = await requireTradingConfig();
const records = await refreshIntentRequestOutcomes(pool, {
btcAsset: tradingConfig.tradingBtc,
eureAsset: tradingConfig.tradingEure,
});
state.last_intent_request_outcomes_at = new Date().toISOString();
state.latest_intent_request_outcomes = {
refreshed_count: records.length,
completed_count: records.filter((entry) => entry.outcome_status === 'completed').length,
not_filled_count: records.filter((entry) => entry.outcome_status === 'not_filled').length,
awaiting_settlement_count: records.filter((entry) => entry.outcome_status === 'awaiting_settlement').length,
};
state.intent_request_outcomes_error = null;
return records;
}
async function refreshQuoteOutcomeAttributions() {
const tradingConfig = await requireTradingConfig();
const records = await refreshQuoteOutcomes(pool, {
btcAsset: tradingConfig.tradingBtc,
eureAsset: tradingConfig.tradingEure,
});
state.last_quote_outcomes_at = new Date().toISOString();
state.quote_outcomes_error = null;
state.latest_quote_outcomes = {
count: records.length,
completed_count: records.filter((entry) => entry.outcome_status === 'completed').length,
not_filled_count: records.filter((entry) => entry.outcome_status === 'not_filled').length,
submitted_count: records.filter((entry) => entry.outcome_status === 'submitted').length,
};
return records;
}
async function requireTradingConfig() {
const tradingConfig = await tradingConfigStore.getConfig();
if (!tradingConfig.ok) {
throw new Error(`trading config unavailable: ${tradingConfig.blockReason || 'unavailable'}`);
}
return tradingConfig;
}
async function publishLiquidityNotification({ topic, event }) {
if (topic !== config.kafkaTopicOpsLiquidityAction) return;
const notification = buildLiquidityActionNotification({ event, config });
await publishNotification(notification);
}
async function publishQuoteOutcomeNotifications(records, { minObservedAt = null } = {}) {
for (const record of records || []) {
if (!observedAtOrAfter(record.outcome_observed_at, minObservedAt)) continue;
await publishNotification(buildQuoteOutcomeNotification({ record, config }));
}
}
async function publishIntentRequestOutcomeNotifications(records, { minObservedAt = null } = {}) {
for (const record of records || []) {
if (!observedAtOrAfter(record.outcome_observed_at, minObservedAt)) continue;
await publishNotification(buildIntentRequestOutcomeNotification({ record, config }));
}
}
async function publishNotification(notification) {
if (!notification) return null;
const result = await notificationDispatcher.publishOnce(notification);
if (result.delivered) {
state.notification_count += 1;
state.last_notification_at = new Date().toISOString();
state.last_notification_error = null;
} else if (result.ok === false) {
state.notification_error_count += 1;
state.last_notification_error = result.error || { status: result.status, response: result.response || null };
}
return result;
}
function summarizePortfolioMetric(metric) {
if (!metric) return null;
return {
metric_id: metric.metric_id,
computed_at: metric.computed_at,
baseline_anchor_at: metric.baseline_anchor_at,
baseline_status: metric.baseline_status,
current_portfolio_value_eure: metric.payload?.current_portfolio_value_eure ?? null,
portfolio_vs_simple_hold_eure: metric.payload?.portfolio_vs_simple_hold_eure ?? null,
mark_to_market_pnl_eure: metric.payload?.mark_to_market_pnl_eure ?? null,
price_move_pnl_eure: metric.payload?.price_move_pnl_eure ?? null,
command_count: metric.payload?.command_count ?? 0,
result_count: metric.payload?.result_count ?? 0,
};
}
function topicRefs(topicNames) {
return topicNames.map((topic) => ({ topic }));
}
function pauseConsumers() {
for (const historyConsumer of durableConsumers) {
historyConsumer.pause(topicRefs(topics));
}
for (const historyConsumer of liveEvidenceConsumers) {
historyConsumer.pause(topicRefs(liveEvidenceTopics));
}
rawQuoteConsumer.pause(topicRefs(rawQuoteTopics));
}
function resumeConsumers() {
for (const historyConsumer of durableConsumers) {
historyConsumer.resume(topicRefs(topics));
}
for (const historyConsumer of liveEvidenceConsumers) {
historyConsumer.resume(topicRefs(liveEvidenceTopics));
}
rawQuoteConsumer.resume(topicRefs(rawQuoteTopics));
}
async function shutdown() {
await controlApi.close().catch(() => {});
await Promise.allSettled([
...durableConsumers.map((historyConsumer) => historyConsumer.disconnect()),
...liveEvidenceConsumers.map((historyConsumer) => historyConsumer.disconnect()),
rawQuoteConsumer.disconnect(),
]);
await pool.end();
process.exit(0);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);