data pipeline refactor and fix

This commit is contained in:
2026-04-13 18:30:04 -04:00
parent 6418729b16
commit 326bf80846
96 changed files with 7107 additions and 1763 deletions

View File

@@ -2,7 +2,8 @@ package com.dexorder.flink;
import com.dexorder.flink.config.AppConfig;
import com.dexorder.flink.iceberg.SchemaInitializer;
import com.dexorder.flink.ingestor.IngestorWorkQueue;
import com.dexorder.flink.ingestor.IngestorBroker;
import com.dexorder.flink.ingestor.RealtimeSubscriptionManager;
import com.dexorder.flink.kafka.TopicManager;
import com.dexorder.flink.publisher.HistoryNotificationForwarder;
import com.dexorder.flink.publisher.HistoryNotificationFunction;
@@ -10,6 +11,11 @@ import com.dexorder.flink.publisher.OHLCBatchWrapper;
import com.dexorder.flink.publisher.OHLCBatchDeserializer;
import com.dexorder.flink.publisher.MarketWrapper;
import com.dexorder.flink.publisher.MarketDeserializer;
import com.dexorder.flink.publisher.RealtimeBar;
import com.dexorder.flink.publisher.RealtimeBarFunction;
import com.dexorder.flink.publisher.RealtimeBarPublisher;
import com.dexorder.flink.publisher.TickWrapper;
import com.dexorder.flink.publisher.TickDeserializer;
import com.dexorder.flink.sink.HistoricalBatchWriter;
import com.dexorder.flink.sink.SymbolMetadataWriter;
import com.dexorder.flink.zmq.ZmqChannelManager;
@@ -83,11 +89,16 @@ public class TradingFlinkApp {
catalogProps
);
String warehouse = config.getString("iceberg_warehouse", "s3://warehouse/");
String warehouseBucket = warehouse.replaceFirst("^s3://", "").split("/")[0];
org.apache.iceberg.catalog.Catalog catalog = catalogLoader.loadCatalog();
try {
SchemaInitializer schemaInitializer = new SchemaInitializer(
catalog,
config.getIcebergNamespace()
config.getIcebergNamespace(),
config.getString("s3_endpoint", "http://minio:9000"),
warehouseBucket
);
schemaInitializer.initializeSchemas();
} finally {
@@ -107,20 +118,28 @@ public class TradingFlinkApp {
zmqManager.initializeChannels();
LOG.info("ZeroMQ channels initialized");
// Initialize history notification forwarder (runs in job manager)
// Binds PULL socket to receive notifications from task managers, forwards to MARKET_DATA_PUB
// Initialize ingestor broker — manages ROUTER/DEALER work queue for all ingestors
IngestorBroker broker = new IngestorBroker(zmqManager);
broker.start();
LOG.info("IngestorBroker started");
// Initialize realtime subscription manager — owns MARKET_DATA_PUB socket exclusively,
// detects XPUB subscription events, and calls broker for realtime job lifecycle.
// Other components publish via subscriptionManager.enqueuePublish() (thread-safe).
RealtimeSubscriptionManager subscriptionManager = new RealtimeSubscriptionManager(zmqManager, broker);
subscriptionManager.start();
LOG.info("RealtimeSubscriptionManager started");
// Initialize history notification forwarder (runs in job manager).
// Binds PULL socket to receive notifications from task managers, enqueues them for
// publication via RealtimeSubscriptionManager (sole owner of MARKET_DATA_PUB).
HistoryNotificationForwarder notificationForwarder = new HistoryNotificationForwarder(
config.getNotificationPullPort(),
zmqManager.getSocket(ZmqChannelManager.Channel.MARKET_DATA_PUB)
subscriptionManager::enqueuePublish
);
notificationForwarder.start();
LOG.info("History notification forwarder started on port {}", config.getNotificationPullPort());
// Initialize ingestor work queue
IngestorWorkQueue workQueue = new IngestorWorkQueue(zmqManager);
workQueue.start();
LOG.info("Ingestor work queue started");
// Set up Flink streaming environment
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
@@ -224,8 +243,37 @@ public class TradingFlinkApp {
LOG.info("Symbol metadata pipeline configured: SymbolMetadataWriter -> Iceberg -> METADATA_UPDATE notification");
// Realtime tick pipeline: Kafka market-tick → OHLC bars → ZMQ notify → clients
KafkaSource<TickWrapper> tickSource = KafkaSource.<TickWrapper>builder()
.setBootstrapServers(config.getKafkaBootstrapServers())
.setTopics(config.getKafkaTickTopic())
.setGroupId("flink-tick-consumer")
.setStartingOffsets(OffsetsInitializer.latest())
.setValueOnlyDeserializer(new TickDeserializer())
.build();
DataStream<TickWrapper> tickStream = env
.fromSource(tickSource, WatermarkStrategy.noWatermarks(), "Tick Kafka Source")
.filter(t -> t != null)
.setParallelism(1);
// Aggregate ticks into OHLC bars for each configured period.
// keyBy ticker so all ticks for a ticker land on the same slot and accumulate together.
int[] periods = config.getRealtimePeriods();
DataStream<RealtimeBar> barStream = tickStream
.keyBy(TickWrapper::getTicker)
.flatMap(new RealtimeBarFunction(periods))
.setParallelism(1);
barStream.addSink(new RealtimeBarPublisher(notificationEndpoint))
.setParallelism(1)
.name("RealtimeBarPublisher");
LOG.info("Realtime tick pipeline configured: market-tick → OHLC bars → clients (periods={})",
java.util.Arrays.toString(periods));
// TODO: Set up CEP patterns and triggers
// TODO: Set up realtime tick processing
LOG.info("Flink job configured, starting execution");
@@ -233,15 +281,10 @@ public class TradingFlinkApp {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
LOG.info("Shutting down Trading Flink Application");
try {
// Stop work queue
workQueue.stop();
// Stop notification forwarder
notificationForwarder.close();
// Close ZMQ channels
subscriptionManager.stop();
broker.stop();
zmqManager.close();
LOG.info("Shutdown complete");
} catch (Exception e) {
LOG.error("Error during shutdown", e);

View File

@@ -91,14 +91,20 @@ public class AppConfig {
}
// ZMQ port getters
public int getIngestorWorkQueuePort() {
return getInt("zmq_ingestor_work_queue_port", 5555);
}
public int getMarketDataPubPort() {
return getInt("zmq_market_data_pub_port", 5558);
}
/** Port where Flink's IngestorBroker binds a PULL socket to receive requests from relay PUSH */
public int getFlinkRequestPullPort() {
return getInt("zmq_flink_request_pull_port", 5566);
}
/** Port where Flink's IngestorBroker binds a ROUTER for ingestor DEALER connections */
public int getIngestorBrokerPort() {
return getInt("zmq_ingestor_broker_port", 5567);
}
public String getBindAddress() {
return getString("zmq_bind_address", "tcp://*");
}
@@ -112,6 +118,20 @@ public class AppConfig {
return getString("kafka_tick_topic", "market-tick");
}
/**
* Comma-separated OHLC period lengths in seconds for realtime bar computation.
* Default covers common chart periods: 1m, 5m, 15m, 1h, 4h, 1d.
*/
public int[] getRealtimePeriods() {
String raw = getString("realtime_periods", "60,300,900,3600,14400,86400");
String[] parts = raw.split(",");
int[] periods = new int[parts.length];
for (int i = 0; i < parts.length; i++) {
periods[i] = Integer.parseInt(parts[i].trim());
}
return periods;
}
public String getKafkaOhlcTopic() {
return getString("kafka_ohlc_topic", "market-ohlc");
}

View File

@@ -9,8 +9,16 @@ import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.types.Types;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
import software.amazon.awssdk.services.s3.model.HeadBucketRequest;
import software.amazon.awssdk.services.s3.model.NoSuchBucketException;
import java.io.IOException;
import java.net.URI;
import static org.apache.iceberg.types.Types.NestedField.optional;
import static org.apache.iceberg.types.Types.NestedField.required;
@@ -26,10 +34,14 @@ public class SchemaInitializer {
private final Catalog catalog;
private final String namespace;
private final String s3Endpoint;
private final String warehouseBucket;
public SchemaInitializer(Catalog catalog, String namespace) {
public SchemaInitializer(Catalog catalog, String namespace, String s3Endpoint, String warehouseBucket) {
this.catalog = catalog;
this.namespace = namespace;
this.s3Endpoint = s3Endpoint;
this.warehouseBucket = warehouseBucket;
}
/**
@@ -40,6 +52,9 @@ public class SchemaInitializer {
public void initializeSchemas() throws IOException {
LOG.info("Initializing Iceberg schemas in namespace: {}", namespace);
// Ensure S3 bucket exists before attempting to create tables
ensureS3BucketExists();
// Ensure namespace exists
ensureNamespaceExists();
@@ -52,6 +67,36 @@ public class SchemaInitializer {
LOG.info("Schema initialization completed successfully");
}
/**
* Ensure the S3 warehouse bucket exists, creating it if necessary.
* Runs before any table creation so a fresh MinIO deployment doesn't crash Flink.
*/
private void ensureS3BucketExists() {
if (s3Endpoint == null || warehouseBucket == null || warehouseBucket.isEmpty()) {
LOG.warn("S3 endpoint or warehouse bucket not configured, skipping bucket check");
return;
}
LOG.info("Ensuring S3 bucket '{}' exists at {}", warehouseBucket, s3Endpoint);
try (S3Client s3 = S3Client.builder()
.endpointOverride(URI.create(s3Endpoint))
.region(Region.of("us-east-1"))
.serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build())
.credentialsProvider(DefaultCredentialsProvider.create())
.build()) {
try {
s3.headBucket(HeadBucketRequest.builder().bucket(warehouseBucket).build());
LOG.info("S3 bucket '{}' already exists", warehouseBucket);
} catch (NoSuchBucketException e) {
LOG.warn("S3 bucket '{}' not found — creating it now", warehouseBucket);
s3.createBucket(CreateBucketRequest.builder().bucket(warehouseBucket).build());
LOG.info("Created S3 bucket '{}'", warehouseBucket);
}
} catch (Exception e) {
LOG.error("Failed to ensure S3 bucket '{}' exists at {}", warehouseBucket, s3Endpoint, e);
throw new RuntimeException("S3 bucket initialization failed for: " + warehouseBucket, e);
}
}
/**
* Ensure the namespace exists in the catalog.
*/

View File

@@ -0,0 +1,503 @@
package com.dexorder.flink.ingestor;
import com.dexorder.flink.zmq.ZmqChannelManager;
import com.dexorder.proto.DataRequest;
import com.dexorder.proto.RealtimeParams;
import com.dexorder.proto.SubmitHistoricalRequest;
import com.dexorder.proto.WorkComplete;
import com.dexorder.proto.WorkHeartbeat;
import com.dexorder.proto.WorkReject;
import com.dexorder.proto.WorkStop;
import com.dexorder.proto.WorkerReady;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.zeromq.ZMQ;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
/**
* LRU-style work broker for ingestors.
*
* Ingestors connect via DEALER to the ROUTER socket on port 5567. They register with READY,
* are dispatched WORK messages, and respond with COMPLETE (historical) or HEARTBEAT (realtime).
* If a heartbeat times out the job is re-queued and dispatched to another available worker.
*
* Also receives SubmitHistoricalRequest messages forwarded by the relay on the PULL socket (5566).
*
* Message type IDs (ZMQ framing, not Kafka):
* 0x10 SubmitHistoricalRequest (relay → Flink via PULL, same as client wire type)
* 0x20 WorkerReady (ingestor → Flink)
* 0x21 WorkComplete (ingestor → Flink)
* 0x22 WorkHeartbeat (ingestor → Flink)
* 0x23 WorkReject (ingestor → Flink)
* 0x01 DataRequest/WorkAssign (Flink → ingestor via ROUTER)
* 0x25 WorkStop (Flink → ingestor via ROUTER)
*/
public class IngestorBroker implements AutoCloseable {
private static final Logger LOG = LoggerFactory.getLogger(IngestorBroker.class);
private static final byte PROTOCOL_VERSION = 0x01;
private static final byte MSG_TYPE_SUBMIT_REQUEST = 0x10;
private static final byte MSG_TYPE_WORKER_READY = 0x20;
private static final byte MSG_TYPE_WORK_COMPLETE = 0x21;
private static final byte MSG_TYPE_WORK_HEARTBEAT = 0x22;
private static final byte MSG_TYPE_WORK_REJECT = 0x23;
private static final byte MSG_TYPE_WORK_ASSIGN = 0x01; // DataRequest type on wire
private static final byte MSG_TYPE_WORK_STOP = 0x25;
/** Re-queue realtime job if no heartbeat received within this window (ms) */
private static final long HEARTBEAT_TIMEOUT_MS = 25_000;
/** Re-queue historical job if not completed within this window (ms) */
private static final long HISTORICAL_TIMEOUT_MS = 60_000;
private final ZmqChannelManager zmqManager;
private volatile boolean running;
private Thread brokerThread;
// ── Worker tracking ──────────────────────────────────────────────────────
/** Workers ready to accept a job, in LRU order (head = least recently used) */
private final Deque<WorkerInfo> freeWorkers = new ArrayDeque<>();
/** Jobs waiting for a compatible free worker */
private final Queue<DataRequest> pendingJobs = new ArrayDeque<>();
/** Jobs currently executing on a worker */
private final Map<String, ActiveJob> activeJobs = new ConcurrentHashMap<>();
/** Worker identity → supported exchanges (set once on READY) */
private final Map<String, WorkerInfo> knownWorkers = new ConcurrentHashMap<>();
// ── Thread-safe inbound queue from RealtimeSubscriptionManager ───────────
private final Queue<DataRequest> externalSubmissions = new ConcurrentLinkedQueue<>();
public IngestorBroker(ZmqChannelManager zmqManager) {
this.zmqManager = zmqManager;
}
public void start() {
if (running) {
LOG.warn("IngestorBroker already running");
return;
}
running = true;
brokerThread = new Thread(this::brokerLoop, "IngestorBroker-Thread");
brokerThread.setDaemon(false);
brokerThread.start();
LOG.info("IngestorBroker started");
}
public void stop() {
running = false;
if (brokerThread != null) {
brokerThread.interrupt();
try {
brokerThread.join(5000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
LOG.info("IngestorBroker stopped");
}
/**
* Submit a realtime data request from outside the broker thread (thread-safe).
* Called by RealtimeSubscriptionManager when subscription ref count goes 0→1.
*/
public void submitRealtimeRequest(String ticker) {
String jobId = UUID.randomUUID().toString();
DataRequest request = DataRequest.newBuilder()
.setRequestId(jobId)
.setJobId(jobId)
.setType(DataRequest.RequestType.REALTIME_TICKS)
.setTicker(ticker)
.setRealtime(RealtimeParams.newBuilder()
.setIncludeTicks(true)
.setIncludeOhlc(false)
.build())
.build();
externalSubmissions.add(request);
LOG.info("Enqueued realtime request: ticker={}, jobId={}", ticker, jobId);
}
/**
* Stop all realtime jobs for a ticker (called when last subscriber leaves).
* Thread-safe — posts a stop marker via externalSubmissions is complex; instead we
* directly find and stop active jobs. Protected by ConcurrentHashMap.
*/
public void stopRealtimeJobsForTicker(String ticker) {
List<String> toStop = new ArrayList<>();
for (Map.Entry<String, ActiveJob> entry : activeJobs.entrySet()) {
if (entry.getValue().ticker.equals(ticker) &&
entry.getValue().type == DataRequest.RequestType.REALTIME_TICKS) {
toStop.add(entry.getKey());
}
}
for (String jobId : toStop) {
ActiveJob job = activeJobs.remove(jobId);
if (job != null) {
sendStop(job.workerIdentity, jobId);
LOG.info("Sent STOP to ingestor: ticker={}, jobId={}", ticker, jobId);
}
}
}
// ── Broker loop ──────────────────────────────────────────────────────────
private void brokerLoop() {
ZMQ.Socket pullSocket = zmqManager.getSocket(ZmqChannelManager.Channel.CLIENT_REQUEST);
ZMQ.Socket routerSocket = zmqManager.getSocket(ZmqChannelManager.Channel.INGESTOR_BROKER);
ZMQ.Poller poller = zmqManager.createPoller(2);
poller.register(pullSocket, ZMQ.Poller.POLLIN);
poller.register(routerSocket, ZMQ.Poller.POLLIN);
LOG.info("IngestorBroker loop running");
while (running) {
try {
// Drain external submissions (realtime requests from subscription manager)
DataRequest ext;
while ((ext = externalSubmissions.poll()) != null) {
enqueueJob(ext);
}
// Poll sockets (100ms timeout)
poller.poll(100);
if (poller.pollin(0)) {
handleClientRequest(pullSocket);
}
if (poller.pollin(1)) {
handleWorkerMessage(routerSocket);
}
// Check for heartbeat / completion timeouts
checkTimeouts();
} catch (Exception e) {
if (running) {
LOG.error("Error in broker loop", e);
}
}
}
LOG.info("IngestorBroker loop exited");
}
/** Receive a SubmitHistoricalRequest forwarded by relay and enqueue it. */
private void handleClientRequest(ZMQ.Socket pullSocket) {
byte[] versionFrame = pullSocket.recv(ZMQ.DONTWAIT);
if (versionFrame == null) return;
if (!pullSocket.hasReceiveMore()) return;
byte[] messageFrame = pullSocket.recv(0);
if (messageFrame == null || messageFrame.length < 2) return;
if (versionFrame.length != 1 || versionFrame[0] != PROTOCOL_VERSION) {
LOG.warn("Bad protocol version on PULL socket");
return;
}
byte msgType = messageFrame[0];
byte[] payload = Arrays.copyOfRange(messageFrame, 1, messageFrame.length);
if (msgType != MSG_TYPE_SUBMIT_REQUEST) {
LOG.warn("Unexpected message type on PULL socket: 0x{}", Integer.toHexString(msgType & 0xFF));
return;
}
try {
SubmitHistoricalRequest req = SubmitHistoricalRequest.parseFrom(payload);
String jobId = UUID.randomUUID().toString();
DataRequest dataRequest = DataRequest.newBuilder()
.setRequestId(req.getRequestId())
.setJobId(jobId)
.setType(DataRequest.RequestType.HISTORICAL_OHLC)
.setTicker(req.getTicker())
.setHistorical(com.dexorder.proto.HistoricalParams.newBuilder()
.setStartTime(req.getStartTime())
.setEndTime(req.getEndTime())
.setPeriodSeconds(req.getPeriodSeconds())
.build())
.setClientId(req.hasClientId() ? req.getClientId() : "")
.build();
enqueueJob(dataRequest);
LOG.info("Received historical request from relay: request_id={}, ticker={}", req.getRequestId(), req.getTicker());
} catch (Exception e) {
LOG.error("Failed to parse SubmitHistoricalRequest from relay", e);
}
}
/** Receive and dispatch a message from an ingestor DEALER. */
private void handleWorkerMessage(ZMQ.Socket routerSocket) {
// ROUTER frame layout: [identity][empty][version][type+payload]
byte[] identity = routerSocket.recv(ZMQ.DONTWAIT);
if (identity == null) return;
if (!routerSocket.hasReceiveMore()) return;
routerSocket.recv(0); // empty delimiter
if (!routerSocket.hasReceiveMore()) return;
byte[] versionFrame = routerSocket.recv(0);
if (!routerSocket.hasReceiveMore()) return;
byte[] messageFrame = routerSocket.recv(0);
if (versionFrame == null || versionFrame.length != 1 || versionFrame[0] != PROTOCOL_VERSION) {
LOG.warn("Bad protocol version from ingestor");
return;
}
if (messageFrame == null || messageFrame.length < 1) return;
byte msgType = messageFrame[0];
byte[] payload = Arrays.copyOfRange(messageFrame, 1, messageFrame.length);
String identityKey = bytesToHex(identity);
try {
switch (msgType & 0xFF) {
case 0x20: handleWorkerReady(identity, identityKey, payload); break;
case 0x21: handleWorkComplete(identityKey, payload); break;
case 0x22: handleWorkHeartbeat(identityKey, payload); break;
case 0x23: handleWorkReject(identityKey, payload); break;
default:
LOG.warn("Unknown message type from ingestor: 0x{}", Integer.toHexString(msgType & 0xFF));
}
} catch (Exception e) {
LOG.error("Error handling worker message type 0x{}", Integer.toHexString(msgType & 0xFF), e);
}
}
private void handleWorkerReady(byte[] identity, String identityKey, byte[] payload) throws Exception {
WorkerReady ready = WorkerReady.parseFrom(payload);
Set<String> exchanges = new HashSet<>(ready.getExchangesList());
WorkerInfo worker = knownWorkers.computeIfAbsent(identityKey,
k -> new WorkerInfo(identity, identityKey, exchanges));
worker.exchanges = exchanges; // update in case re-READY with different config
worker.identity = identity;
if (!freeWorkers.contains(worker)) {
freeWorkers.addLast(worker);
}
LOG.info("Ingestor READY: id={}, exchanges={}, freeWorkers={}", identityKey, exchanges, freeWorkers.size());
dispatchPending();
}
private void handleWorkComplete(String identityKey, byte[] payload) throws Exception {
WorkComplete complete = WorkComplete.parseFrom(payload);
String jobId = complete.getJobId();
ActiveJob job = activeJobs.remove(jobId);
if (job == null) {
LOG.warn("COMPLETE for unknown jobId={}", jobId);
} else {
LOG.info("Job COMPLETE: jobId={}, ticker={}, success={}", jobId, job.ticker, complete.getSuccess());
}
// Worker is free again
WorkerInfo worker = knownWorkers.get(identityKey);
if (worker != null) {
freeWorkers.addLast(worker);
dispatchPending();
}
}
private void handleWorkHeartbeat(String identityKey, byte[] payload) throws Exception {
WorkHeartbeat hb = WorkHeartbeat.parseFrom(payload);
String jobId = hb.getJobId();
ActiveJob job = activeJobs.get(jobId);
if (job != null) {
job.lastHeartbeat = System.currentTimeMillis();
} else {
LOG.warn("HEARTBEAT for unknown jobId={} from worker={}", jobId, identityKey);
}
}
private void handleWorkReject(String identityKey, byte[] payload) throws Exception {
WorkReject reject = WorkReject.parseFrom(payload);
String jobId = reject.getJobId();
LOG.warn("Job REJECTED by worker={}: jobId={}, reason={}", identityKey, jobId, reject.getReason());
ActiveJob job = activeJobs.remove(jobId);
if (job != null) {
// Re-queue with fresh job_id so a different ingestor may pick it up
DataRequest requeued = job.request.toBuilder()
.setJobId(UUID.randomUUID().toString())
.build();
pendingJobs.add(requeued);
}
// Worker is still free (it rejected, not crashed)
WorkerInfo worker = knownWorkers.get(identityKey);
if (worker != null) {
freeWorkers.addLast(worker);
dispatchPending();
}
}
// ── Dispatch ─────────────────────────────────────────────────────────────
private void enqueueJob(DataRequest request) {
// Check if we can immediately dispatch
WorkerInfo worker = findFreeWorker(exchangeOf(request.getTicker()));
if (worker != null) {
dispatch(worker, request);
} else {
pendingJobs.add(request);
LOG.debug("No free worker for {}, queued (pendingJobs={})", request.getTicker(), pendingJobs.size());
}
}
private void dispatchPending() {
Queue<DataRequest> remaining = new ArrayDeque<>();
DataRequest job;
while ((job = pendingJobs.poll()) != null) {
WorkerInfo worker = findFreeWorker(exchangeOf(job.getTicker()));
if (worker != null) {
dispatch(worker, job);
} else {
remaining.add(job);
}
}
pendingJobs.addAll(remaining);
}
private void dispatch(WorkerInfo worker, DataRequest request) {
freeWorkers.remove(worker);
try {
byte[] protoBytes = request.toByteArray();
boolean sent = zmqManager.sendToWorker(worker.identity, PROTOCOL_VERSION, MSG_TYPE_WORK_ASSIGN, protoBytes);
if (!sent) {
LOG.error("Failed to dispatch job to worker={}, re-queuing", worker.identityKey);
freeWorkers.addLast(worker);
pendingJobs.add(request);
return;
}
ActiveJob active = new ActiveJob(worker.identity, worker.identityKey,
request, request.getTicker(), request.getType());
activeJobs.put(request.getJobId(), active);
LOG.info("Dispatched job: jobId={}, ticker={}, type={}, worker={}",
request.getJobId(), request.getTicker(), request.getType(), worker.identityKey);
} catch (Exception e) {
LOG.error("Error dispatching job", e);
freeWorkers.addLast(worker);
}
}
private void sendStop(byte[] workerIdentity, String jobId) {
try {
WorkStop stop = WorkStop.newBuilder().setJobId(jobId).build();
zmqManager.sendToWorker(workerIdentity, PROTOCOL_VERSION, MSG_TYPE_WORK_STOP, stop.toByteArray());
} catch (Exception e) {
LOG.error("Error sending STOP for jobId={}", jobId, e);
}
}
// ── Timeout checking ─────────────────────────────────────────────────────
private void checkTimeouts() {
long now = System.currentTimeMillis();
List<String> timedOut = new ArrayList<>();
for (Map.Entry<String, ActiveJob> entry : activeJobs.entrySet()) {
ActiveJob job = entry.getValue();
long timeout = job.type == DataRequest.RequestType.REALTIME_TICKS
? HEARTBEAT_TIMEOUT_MS : HISTORICAL_TIMEOUT_MS;
if (now - job.lastHeartbeat > timeout) {
timedOut.add(entry.getKey());
}
}
for (String jobId : timedOut) {
ActiveJob job = activeJobs.remove(jobId);
if (job == null) continue;
LOG.warn("Job timed out (no heartbeat/completion): jobId={}, ticker={}, type={}, worker={}",
jobId, job.ticker, job.type, job.workerIdentityKey);
// Re-queue with a new job_id
DataRequest requeued = job.request.toBuilder()
.setJobId(UUID.randomUUID().toString())
.build();
pendingJobs.add(requeued);
dispatchPending();
}
}
// ── Helpers ──────────────────────────────────────────────────────────────
/** Extract exchange name from ticker, e.g. "BTC/USDT.BINANCE" → "BINANCE" */
private static String exchangeOf(String ticker) {
int dot = ticker.lastIndexOf('.');
return dot >= 0 ? ticker.substring(dot + 1).toUpperCase() : "";
}
/** Find and remove a free worker that supports the given exchange. */
private WorkerInfo findFreeWorker(String exchange) {
for (WorkerInfo w : freeWorkers) {
if (exchange.isEmpty() || w.exchanges.contains(exchange)) {
freeWorkers.remove(w);
return w;
}
}
return null;
}
private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) sb.append(String.format("%02x", b));
return sb.toString();
}
@Override
public void close() {
stop();
}
// ── Inner types ──────────────────────────────────────────────────────────
private static class WorkerInfo {
byte[] identity;
final String identityKey;
Set<String> exchanges;
WorkerInfo(byte[] identity, String identityKey, Set<String> exchanges) {
this.identity = identity;
this.identityKey = identityKey;
this.exchanges = exchanges;
}
}
private static class ActiveJob {
final byte[] workerIdentity;
final String workerIdentityKey;
final DataRequest request;
final String ticker;
final DataRequest.RequestType type;
long lastHeartbeat;
ActiveJob(byte[] workerIdentity, String workerIdentityKey,
DataRequest request, String ticker, DataRequest.RequestType type) {
this.workerIdentity = workerIdentity;
this.workerIdentityKey = workerIdentityKey;
this.request = request;
this.ticker = ticker;
this.type = type;
this.lastHeartbeat = System.currentTimeMillis();
}
}
}

View File

@@ -119,7 +119,7 @@ public class IngestorWorkQueue {
String exchangePrefix = extractExchangePrefix(ticker);
boolean sent = zmqManager.sendTopicMessage(
ZmqChannelManager.Channel.INGESTOR_WORK_QUEUE,
ZmqChannelManager.Channel.INGESTOR_BROKER,
exchangePrefix,
PROTOCOL_VERSION,
MSG_TYPE_DATA_REQUEST,

View File

@@ -0,0 +1,204 @@
package com.dexorder.flink.ingestor;
import com.dexorder.flink.zmq.ZmqChannelManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.zeromq.ZMQ;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Monitors XPUB subscription events from the relay and manages realtime ingestor lifecycle.
*
* This class is the <em>sole owner</em> of the MARKET_DATA_PUB XPUB socket. All outbound
* publishes from other threads (e.g., HistoryNotificationForwarder, RealtimeOHLCPublisher)
* must go through {@link #enqueuePublish(byte[]...)} so they are sent from the single loop
* thread — ZMQ sockets are not thread-safe.
*
* Topic format: {@code {ticker}|ohlc:{period_seconds}}
* Example: {@code BTC/USDT.BINANCE|ohlc:60}
*
* Reference counting:
* tickerRefs — across all periods for a ticker; 0→1 triggers ingestor activation
* topicRefs — per (ticker, period); consulted by RealtimeOHLCPublisher to filter output
*/
public class RealtimeSubscriptionManager implements AutoCloseable {
private static final Logger LOG = LoggerFactory.getLogger(RealtimeSubscriptionManager.class);
private static final Pattern TOPIC_PATTERN = Pattern.compile("^(.+)\\|ohlc:(\\d+)$");
private final ZmqChannelManager zmqManager;
private final ZMQ.Socket xpubSocket;
private final IngestorBroker broker;
/** Per-ticker reference count (across all subscribed periods for that ticker) */
private final Map<String, Integer> tickerRefs = new HashMap<>();
/** Per-topic reference count (ticker|ohlc:period → subscriber count) */
private final Map<String, Integer> topicRefs = new HashMap<>();
/**
* Thread-safe outbound publish queue.
* Each entry is one multi-frame message: {@code byte[][] frames}.
*/
private final ConcurrentLinkedQueue<byte[][]> publishQueue = new ConcurrentLinkedQueue<>();
private volatile boolean running;
private Thread thread;
public RealtimeSubscriptionManager(ZmqChannelManager zmqManager, IngestorBroker broker) {
this.zmqManager = zmqManager;
this.xpubSocket = zmqManager.getSocket(ZmqChannelManager.Channel.MARKET_DATA_PUB);
this.broker = broker;
}
/**
* Queue a multi-frame message for publication on MARKET_DATA_PUB.
* Thread-safe — may be called from any thread (HistoryNotificationForwarder,
* RealtimeOHLCPublisher, etc.).
*/
public void enqueuePublish(byte[]... frames) {
publishQueue.add(frames);
}
/**
* Returns the current subscriber count for a topic.
* Thread-safe for reads (value is written only from the loop thread but read from others).
*/
public int getTopicRefCount(String topic) {
return topicRefs.getOrDefault(topic, 0);
}
public void start() {
if (running) {
LOG.warn("RealtimeSubscriptionManager already running");
return;
}
running = true;
thread = new Thread(this::subscriptionLoop, "RealtimeSubscriptionManager");
thread.setDaemon(false);
thread.start();
LOG.info("RealtimeSubscriptionManager started");
}
public void stop() {
running = false;
if (thread != null) {
thread.interrupt();
try {
thread.join(5000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
LOG.info("RealtimeSubscriptionManager stopped");
}
private void subscriptionLoop() {
// Build a poller so we can block-wait rather than busy-spin
ZMQ.Poller poller = zmqManager.createPoller(1);
poller.register(xpubSocket, ZMQ.Poller.POLLIN);
LOG.info("RealtimeSubscriptionManager loop running");
while (running) {
try {
// 1. Flush any queued outbound messages before blocking
byte[][] frames;
while ((frames = publishQueue.poll()) != null) {
sendFrames(frames);
}
// 2. Wait up to 50ms for a subscription event
poller.poll(50);
// 3. Drain all available subscription events
if (poller.pollin(0)) {
byte[] event;
while ((event = xpubSocket.recv(ZMQ.DONTWAIT)) != null) {
if (event.length > 0) {
processSubscriptionEvent(event);
}
}
}
} catch (Exception e) {
if (running) {
LOG.error("Error in subscription loop", e);
}
}
}
LOG.info("RealtimeSubscriptionManager loop exited");
}
private void sendFrames(byte[][] frames) {
for (int i = 0; i < frames.length; i++) {
if (i < frames.length - 1) {
xpubSocket.sendMore(frames[i]);
} else {
xpubSocket.send(frames[i], 0);
}
}
}
private void processSubscriptionEvent(byte[] event) {
// XPUB subscription frame: first byte is 0x01 (subscribe) or 0x00 (unsubscribe);
// remaining bytes are the raw topic string.
boolean isSubscribe = event[0] == 0x01;
String topic = new String(event, 1, event.length - 1, ZMQ.CHARSET);
Matcher m = TOPIC_PATTERN.matcher(topic);
if (!m.matches()) {
// Not a realtime OHLC topic — e.g. RESPONSE: or HISTORY_READY: prefixes
LOG.debug("Ignoring subscription event for non-realtime topic: action={}, topic={}",
isSubscribe ? "subscribe" : "unsubscribe", topic);
return;
}
String ticker = m.group(1);
LOG.info("Subscription event: action={}, topic={}", isSubscribe ? "subscribe" : "unsubscribe", topic);
if (isSubscribe) {
handleSubscribe(ticker, topic);
} else {
handleUnsubscribe(ticker, topic);
}
}
private void handleSubscribe(String ticker, String topic) {
int newTopicRef = topicRefs.merge(topic, 1, Integer::sum);
LOG.debug("topicRefs[{}]={}", topic, newTopicRef);
int newTickerRef = tickerRefs.merge(ticker, 1, Integer::sum);
if (newTickerRef == 1) {
LOG.info("First subscriber for ticker={} — submitting realtime request", ticker);
broker.submitRealtimeRequest(ticker);
}
LOG.debug("tickerRefs[{}]={}", ticker, newTickerRef);
}
private void handleUnsubscribe(String ticker, String topic) {
int newTopicRef = topicRefs.merge(topic, -1, Integer::sum);
if (newTopicRef <= 0) {
topicRefs.remove(topic);
}
LOG.debug("topicRefs[{}]={}", topic, newTopicRef);
int newTickerRef = tickerRefs.merge(ticker, -1, Integer::sum);
if (newTickerRef <= 0) {
tickerRefs.remove(ticker);
LOG.info("Last subscriber for ticker={} left — stopping realtime jobs", ticker);
broker.stopRealtimeJobsForTicker(ticker);
}
LOG.debug("tickerRefs[{}]={}", ticker, newTickerRef);
}
@Override
public void close() {
stop();
}
}

View File

@@ -6,14 +6,24 @@ import org.zeromq.SocketType;
import org.zeromq.ZContext;
import org.zeromq.ZMQ;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
/**
* Runs in the job manager. Pulls notifications from task managers (via PUSH/PULL)
* and republishes them on the MARKET_DATA_PUB socket that the relay subscribes to.
* and enqueues them for publication on MARKET_DATA_PUB via the provided publish callback.
*
* The publish callback must be thread-safe (e.g., RealtimeSubscriptionManager.enqueuePublish).
* Direct socket access is avoided here because the MARKET_DATA_PUB XPUB socket is owned
* exclusively by RealtimeSubscriptionManager to satisfy ZMQ's single-thread-per-socket rule.
*
* Flow:
* Task manager HistoryNotificationPublisher → PUSH
* ↓
* Job manager HistoryNotificationForwarder PULL → MARKET_DATA_PUB
* Job manager HistoryNotificationForwarder PULL → publishCallback (queue)
* ↓ (RealtimeSubscriptionManager loop)
* MARKET_DATA_PUB
* ↓
* Relay (XSUB) → Relay (XPUB) → Clients
*/
@@ -21,17 +31,17 @@ public class HistoryNotificationForwarder implements AutoCloseable {
private static final Logger LOG = LoggerFactory.getLogger(HistoryNotificationForwarder.class);
private final ZMQ.Socket pullSocket;
private final ZMQ.Socket pubSocket;
private final Consumer<byte[][]> publishCallback;
private final ZContext context;
private volatile boolean running = true;
private Thread thread;
/**
* @param pullPort Port to bind PULL socket on (task managers connect PUSH here)
* @param pubSocket Existing MARKET_DATA_PUB socket from ZmqChannelManager
* @param pullPort Port to bind PULL socket on (task managers connect PUSH here)
* @param publishCallback Thread-safe callback to enqueue outbound multi-frame messages
*/
public HistoryNotificationForwarder(int pullPort, ZMQ.Socket pubSocket) {
this.pubSocket = pubSocket;
public HistoryNotificationForwarder(int pullPort, Consumer<byte[][]> publishCallback) {
this.publishCallback = publishCallback;
this.context = new ZContext();
this.pullSocket = context.createSocket(SocketType.PULL);
this.pullSocket.setRcvHWM(10000);
@@ -53,32 +63,24 @@ public class HistoryNotificationForwarder implements AutoCloseable {
pullSocket.setReceiveTimeOut(200); // ms, so we can check running flag
while (running) {
// Receive all frames of a multi-part message and forward to PUB
byte[] frame = pullSocket.recv(0);
if (frame == null) {
continue; // timeout, check running flag
continue; // timeout check running flag
}
boolean more = pullSocket.hasReceiveMore();
if (more) {
pubSocket.sendMore(frame);
} else {
pubSocket.send(frame, 0);
continue;
}
// Collect all frames of the multi-part message, then enqueue atomically
List<byte[]> frames = new ArrayList<>();
frames.add(frame);
// Receive remaining frames
while (more) {
frame = pullSocket.recv(0);
more = pullSocket.hasReceiveMore();
if (more) {
pubSocket.sendMore(frame);
} else {
pubSocket.send(frame, 0);
while (pullSocket.hasReceiveMore()) {
byte[] next = pullSocket.recv(0);
if (next != null) {
frames.add(next);
}
}
LOG.debug("Forwarded notification to MARKET_DATA_PUB");
publishCallback.accept(frames.toArray(new byte[0][]));
LOG.debug("Enqueued notification ({} frames) for MARKET_DATA_PUB", frames.size());
}
LOG.info("Notification forwarder loop stopped");

View File

@@ -64,8 +64,13 @@ public class HistoryNotificationFunction extends ProcessFunction<OHLCBatchWrappe
String status = batch.getStatus();
int rowCount = batch.getRowCount();
LOG.info("Processing OHLCBatch: request_id={}, status={}, rows={}",
requestId, status, rowCount);
LOG.info("Processing OHLCBatch: request_id={}, status={}, rows={}, isLastPage={}",
requestId, status, rowCount, batch.isLastPage());
// Intermediate pages: data is written to Iceberg but no notification yet
if (!batch.isLastPage()) {
return;
}
// Determine Iceberg table name based on period
String tableName = getIcebergTableName(ticker, periodSeconds);

View File

@@ -87,7 +87,8 @@ public class OHLCBatchDeserializer implements DeserializationSchema<OHLCBatchWra
meta.getEndTime(),
status,
meta.hasErrorMessage() ? meta.getErrorMessage() : null,
rows
rows,
meta.getIsLastPage()
);
}

View File

@@ -19,6 +19,7 @@ public class OHLCBatchWrapper implements Serializable {
private final String status; // OK, NOT_FOUND, ERROR
private final String errorMessage;
private final List<OHLCRow> rows;
private final boolean isLastPage;
public OHLCBatchWrapper(
String requestId,
@@ -29,7 +30,8 @@ public class OHLCBatchWrapper implements Serializable {
long endTime,
String status,
String errorMessage,
List<OHLCRow> rows
List<OHLCRow> rows,
boolean isLastPage
) {
this.requestId = requestId;
this.clientId = clientId;
@@ -40,6 +42,7 @@ public class OHLCBatchWrapper implements Serializable {
this.status = status;
this.errorMessage = errorMessage;
this.rows = rows;
this.isLastPage = isLastPage;
}
public String getRequestId() {
@@ -94,6 +97,10 @@ public class OHLCBatchWrapper implements Serializable {
return "OK".equals(status);
}
public boolean isLastPage() {
return isLastPage;
}
@Override
public String toString() {
return "OHLCBatchWrapper{" +
@@ -103,6 +110,7 @@ public class OHLCBatchWrapper implements Serializable {
", periodSeconds=" + periodSeconds +
", status='" + status + '\'' +
", rowCount=" + getRowCount() +
", isLastPage=" + isLastPage +
'}';
}

View File

@@ -0,0 +1,73 @@
package com.dexorder.flink.publisher;
import java.io.Serializable;
/**
* A single completed OHLC bar for a given ticker and period.
* Output type of RealtimeBarFunction, input type of RealtimeBarPublisher.
*/
public class RealtimeBar implements Serializable {
private static final long serialVersionUID = 1L;
private String ticker;
/** Period in seconds (e.g., 60, 300, 3600) */
private int periodSeconds;
/** Window start timestamp in milliseconds since epoch */
private long windowStartMs;
/** Scaled integer price values (same precision as source Tick) */
private long open;
private long high;
private long low;
private long close;
/** Summed base amount across ticks in this window */
private long volume;
/** Number of ticks in this window */
private int tickCount;
public RealtimeBar() {}
public RealtimeBar(String ticker, int periodSeconds, long windowStartMs,
long open, long high, long low, long close, long volume, int tickCount) {
this.ticker = ticker;
this.periodSeconds = periodSeconds;
this.windowStartMs = windowStartMs;
this.open = open;
this.high = high;
this.low = low;
this.close = close;
this.volume = volume;
this.tickCount = tickCount;
}
public String getTicker() { return ticker; }
public int getPeriodSeconds() { return periodSeconds; }
public long getWindowStartMs() { return windowStartMs; }
public long getOpen() { return open; }
public long getHigh() { return high; }
public long getLow() { return low; }
public long getClose() { return close; }
public long getVolume() { return volume; }
public int getTickCount() { return tickCount; }
public void setTicker(String ticker) { this.ticker = ticker; }
public void setPeriodSeconds(int periodSeconds) { this.periodSeconds = periodSeconds; }
public void setWindowStartMs(long windowStartMs) { this.windowStartMs = windowStartMs; }
public void setOpen(long open) { this.open = open; }
public void setHigh(long high) { this.high = high; }
public void setLow(long low) { this.low = low; }
public void setClose(long close) { this.close = close; }
public void setVolume(long volume) { this.volume = volume; }
public void setTickCount(int tickCount) { this.tickCount = tickCount; }
/** ZMQ topic for this bar: e.g., "BTC/USDT.BINANCE|ohlc:60" */
public String topic() {
return ticker + "|ohlc:" + periodSeconds;
}
@Override
public String toString() {
return "RealtimeBar{ticker='" + ticker + "', period=" + periodSeconds +
"s, windowStart=" + windowStartMs + ", O=" + open + " H=" + high +
" L=" + low + " C=" + close + ", ticks=" + tickCount + '}';
}
}

View File

@@ -0,0 +1,116 @@
package com.dexorder.flink.publisher;
import org.apache.flink.api.common.functions.RichFlatMapFunction;
import org.apache.flink.api.common.state.MapState;
import org.apache.flink.api.common.state.MapStateDescriptor;
import org.apache.flink.api.common.typeinfo.BasicTypeInfo;
import org.apache.flink.api.common.typeinfo.PrimitiveArrayTypeInfo;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.util.Collector;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Accumulates ticks into OHLC bars for each configured period.
*
* Keyed by ticker. Maintains per-period accumulators in MapState.
* Uses a "lazy boundary" approach: a new window is detected when a tick arrives after
* the previous window's end time (based on processing clock). The completed bar is
* emitted immediately when the boundary is crossed, so bars are delayed by at most
* one tick interval (~10s for realtime polling).
*
* Periods are configurable at construction time. All configured periods are computed
* for every ticker receiving ticks; the ZMQ publisher filters to active subscriptions.
*
* Accumulator layout (long[7]):
* [0] open
* [1] high
* [2] low
* [3] close
* [4] volume (sum of base amount)
* [5] windowStartMs (epoch ms)
* [6] tickCount
*/
public class RealtimeBarFunction extends RichFlatMapFunction<TickWrapper, RealtimeBar> {
private static final Logger LOG = LoggerFactory.getLogger(RealtimeBarFunction.class);
private static final long serialVersionUID = 1L;
private final int[] periods;
private transient MapState<Integer, long[]> accumState;
/**
* @param periods Period lengths in seconds (e.g., 60, 300, 900, 3600)
*/
public RealtimeBarFunction(int[] periods) {
this.periods = periods;
}
@Override
public void open(Configuration parameters) {
MapStateDescriptor<Integer, long[]> desc = new MapStateDescriptor<>(
"ohlcAccum",
BasicTypeInfo.INT_TYPE_INFO,
PrimitiveArrayTypeInfo.LONG_PRIMITIVE_ARRAY_TYPE_INFO
);
accumState = getRuntimeContext().getMapState(desc);
}
@Override
public void flatMap(TickWrapper tick, Collector<RealtimeBar> out) throws Exception {
if (tick == null) return;
long nowMs = System.currentTimeMillis();
for (int period : periods) {
long periodMs = period * 1000L;
long windowStart = (nowMs / periodMs) * periodMs;
long[] accum = accumState.get(period);
if (accum == null) {
// First tick for this period
accumState.put(period, openWindow(tick, windowStart));
} else if (accum[5] != windowStart) {
// Window boundary crossed — emit completed bar then start fresh
if (accum[6] > 0) {
out.collect(toBar(tick.getTicker(), period, accum));
LOG.debug("Emitted bar: ticker={}, period={}s, windowStart={}, ticks={}",
tick.getTicker(), period, accum[5], accum[6]);
}
accumState.put(period, openWindow(tick, windowStart));
} else {
// Same window — update
accum[1] = Math.max(accum[1], tick.getPrice()); // high
accum[2] = Math.min(accum[2], tick.getPrice()); // low
accum[3] = tick.getPrice(); // close
accum[4] += tick.getAmount(); // volume
accum[6]++; // tick count
accumState.put(period, accum);
}
}
}
private static long[] openWindow(TickWrapper tick, long windowStart) {
return new long[]{
tick.getPrice(), // open
tick.getPrice(), // high
tick.getPrice(), // low
tick.getPrice(), // close
tick.getAmount(), // volume
windowStart,
1L // tickCount
};
}
private static RealtimeBar toBar(String ticker, int period, long[] accum) {
return new RealtimeBar(
ticker, period,
accum[5], // windowStartMs
accum[0], accum[1], accum[2], accum[3], // O H L C
accum[4], // volume
(int) accum[6] // tickCount
);
}
}

View File

@@ -0,0 +1,91 @@
package com.dexorder.flink.publisher;
import com.dexorder.proto.OHLC;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.functions.sink.RichSinkFunction;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.zeromq.SocketType;
import org.zeromq.ZContext;
import org.zeromq.ZMQ;
/**
* Flink sink that publishes completed realtime OHLC bars to clients.
*
* Connects a ZMQ PUSH socket to the job manager's notification PULL endpoint.
* The HistoryNotificationForwarder (already running on the job manager) receives these
* frames and enqueues them to RealtimeSubscriptionManager, which publishes them on
* the MARKET_DATA_PUB XPUB socket. Clients subscribed to the matching topic receive the bar.
*
* Wire format (matches HistoryNotificationPublisher):
* Frame 1: topic bytes (e.g., "BTC/USDT.BINANCE|ohlc:60")
* Frame 2: [0x01] (protocol version)
* Frame 3: [0x04][OHLC protobuf bytes] (type 0x04 = OHLC single bar)
*
* Parallelism MUST be 1 (same as the rest of the notification pipeline).
*/
public class RealtimeBarPublisher extends RichSinkFunction<RealtimeBar> {
private static final Logger LOG = LoggerFactory.getLogger(RealtimeBarPublisher.class);
private static final long serialVersionUID = 1L;
private static final byte PROTOCOL_VERSION = 0x01;
private static final byte MSG_TYPE_OHLC = 0x04;
private final String jobManagerPullEndpoint;
private transient ZContext context;
private transient ZMQ.Socket pushSocket;
public RealtimeBarPublisher(String jobManagerPullEndpoint) {
this.jobManagerPullEndpoint = jobManagerPullEndpoint;
}
@Override
public void open(Configuration parameters) {
context = new ZContext();
pushSocket = context.createSocket(SocketType.PUSH);
pushSocket.setLinger(1000);
pushSocket.setSndHWM(10000);
pushSocket.connect(jobManagerPullEndpoint);
LOG.info("RealtimeBarPublisher PUSH connected to {}", jobManagerPullEndpoint);
}
@Override
public void invoke(RealtimeBar bar, Context context) {
try {
// Build OHLC proto — timestamp in nanoseconds (bar uses ms, convert)
OHLC ohlc = OHLC.newBuilder()
.setTimestamp(bar.getWindowStartMs() * 1_000_000L) // ms → ns
.setTicker(bar.getTicker())
.setOpen(bar.getOpen())
.setHigh(bar.getHigh())
.setLow(bar.getLow())
.setClose(bar.getClose())
.setVolume(bar.getVolume())
.build();
byte[] protoBytes = ohlc.toByteArray();
byte[] messageFrame = new byte[1 + protoBytes.length];
messageFrame[0] = MSG_TYPE_OHLC;
System.arraycopy(protoBytes, 0, messageFrame, 1, protoBytes.length);
String topic = bar.topic();
pushSocket.sendMore(topic.getBytes(ZMQ.CHARSET));
pushSocket.sendMore(new byte[]{PROTOCOL_VERSION});
pushSocket.send(messageFrame, 0);
LOG.debug("Published realtime bar: topic={}, ticks={}", topic, bar.getTickCount());
} catch (Exception e) {
LOG.error("Failed to publish realtime bar: ticker={}, period={}",
bar.getTicker(), bar.getPeriodSeconds(), e);
}
}
@Override
public void close() {
if (pushSocket != null) pushSocket.close();
if (context != null) context.close();
LOG.info("RealtimeBarPublisher closed");
}
}

View File

@@ -0,0 +1,69 @@
package com.dexorder.flink.publisher;
import com.dexorder.proto.Tick;
import org.apache.flink.api.common.serialization.DeserializationSchema;
import org.apache.flink.api.common.typeinfo.TypeInformation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
/**
* Kafka deserializer for Tick protobuf messages from the market-tick topic.
*
* Wire format: [0x01 version][0x03 TICK type][Tick protobuf bytes]
*/
public class TickDeserializer implements DeserializationSchema<TickWrapper> {
private static final Logger LOG = LoggerFactory.getLogger(TickDeserializer.class);
private static final long serialVersionUID = 1L;
private static final byte PROTOCOL_VERSION = 0x01;
private static final byte MSG_TYPE_TICK = 0x03;
@Override
public TickWrapper deserialize(byte[] message) throws IOException {
try {
if (message.length < 2) {
throw new IOException("Message too short: " + message.length + " bytes");
}
if (message[0] != PROTOCOL_VERSION) {
throw new IOException("Unsupported protocol version: 0x" + Integer.toHexString(message[0] & 0xFF));
}
if (message[1] != MSG_TYPE_TICK) {
throw new IOException("Unexpected message type: 0x" + Integer.toHexString(message[1] & 0xFF));
}
byte[] payload = new byte[message.length - 2];
System.arraycopy(message, 2, payload, 0, payload.length);
Tick tick = Tick.parseFrom(payload);
return new TickWrapper(
tick.getTicker(),
tick.getTradeId(),
tick.getTimestamp(),
tick.getPrice(),
tick.getAmount(),
tick.getQuoteAmount(),
tick.getTakerBuy()
);
} catch (Exception e) {
LOG.warn("Failed to deserialize Tick, skipping: {}", e.getMessage());
// Return null; Flink's KafkaSource skips nulls via filter
return null;
}
}
@Override
public boolean isEndOfStream(TickWrapper nextElement) {
return false;
}
@Override
public TypeInformation<TickWrapper> getProducedType() {
return TypeInformation.of(TickWrapper.class);
}
}

View File

@@ -0,0 +1,58 @@
package com.dexorder.flink.publisher;
import java.io.Serializable;
/**
* Flink-serializable wrapper for a single Tick.
* Fields mirror the Tick protobuf, using primitives to avoid proto-class serialization issues.
*/
public class TickWrapper implements Serializable {
private static final long serialVersionUID = 1L;
private String ticker;
private String tradeId;
/** Timestamp in nanoseconds since epoch */
private long timestamp;
/** Price as scaled integer */
private long price;
/** Base amount as scaled integer */
private long amount;
/** Quote amount as scaled integer */
private long quoteAmount;
private boolean takerBuy;
public TickWrapper() {}
public TickWrapper(String ticker, String tradeId, long timestamp,
long price, long amount, long quoteAmount, boolean takerBuy) {
this.ticker = ticker;
this.tradeId = tradeId;
this.timestamp = timestamp;
this.price = price;
this.amount = amount;
this.quoteAmount = quoteAmount;
this.takerBuy = takerBuy;
}
public String getTicker() { return ticker; }
public String getTradeId() { return tradeId; }
public long getTimestamp() { return timestamp; }
public long getPrice() { return price; }
public long getAmount() { return amount; }
public long getQuoteAmount() { return quoteAmount; }
public boolean isTakerBuy() { return takerBuy; }
public void setTicker(String ticker) { this.ticker = ticker; }
public void setTradeId(String tradeId) { this.tradeId = tradeId; }
public void setTimestamp(long timestamp) { this.timestamp = timestamp; }
public void setPrice(long price) { this.price = price; }
public void setAmount(long amount) { this.amount = amount; }
public void setQuoteAmount(long quoteAmount) { this.quoteAmount = quoteAmount; }
public void setTakerBuy(boolean takerBuy) { this.takerBuy = takerBuy; }
@Override
public String toString() {
return "TickWrapper{ticker='" + ticker + "', tradeId='" + tradeId +
"', timestamp=" + timestamp + ", price=" + price + '}';
}
}

View File

@@ -13,7 +13,14 @@ import java.util.Map;
/**
* Manages all ZeroMQ channels for the Flink application.
* Each channel is bound to a specific port and socket type.
*
* Port layout:
* 5558 XPUB MARKET_DATA_PUB — market data + notifications to clients (via relay XSUB)
* XPUB exposes subscription frames so Flink can detect
* which realtime topics clients are interested in.
* 5561 PULL (internal) — task manager → job manager notifications (unchanged)
* 5566 PULL CLIENT_REQUEST — receives forwarded SubmitHistoricalRequest from relay PUSH
* 5567 ROUTER INGESTOR_BROKER — exclusive work queue; ingestors connect with DEALER
*/
public class ZmqChannelManager implements Closeable {
private static final Logger LOG = LoggerFactory.getLogger(ZmqChannelManager.class);
@@ -23,8 +30,9 @@ public class ZmqChannelManager implements Closeable {
private final AppConfig config;
public enum Channel {
INGESTOR_WORK_QUEUE,
MARKET_DATA_PUB,
CLIENT_REQUEST,
INGESTOR_BROKER,
}
public ZmqChannelManager(AppConfig config) {
@@ -41,20 +49,33 @@ public class ZmqChannelManager implements Closeable {
LOG.info("Initializing ZeroMQ channels on {}", bindAddress);
// 1. Ingestor Work Queue - PUB socket for topic-based work distribution (exchange prefix filtering)
// 1. Market Data Publication — XPUB so subscription events are visible to Flink
// Relay's XSUB connects here to proxy data to clients.
// Subscription frames from relay (forwarded from clients) arrive as readable messages.
ZMQ.Socket marketDataSocket = context.createSocket(SocketType.XPUB);
marketDataSocket.setXpubVerbose(true); // emit every sub/unsub, not just first/last
marketDataSocket.setLinger(1000);
marketDataSocket.setSndHWM(10000);
marketDataSocket.setRcvHWM(10000);
String marketDataEndpoint = bindAddress + ":" + config.getMarketDataPubPort();
marketDataSocket.bind(marketDataEndpoint);
sockets.put(Channel.MARKET_DATA_PUB.name(), marketDataSocket);
LOG.info("Bound Market Data Publication (XPUB) to {}", marketDataEndpoint);
// 2. Client Request Pull — receives SubmitHistoricalRequest forwarded by relay PUSH
createAndBind(
Channel.INGESTOR_WORK_QUEUE,
SocketType.PUB,
bindAddress + ":" + config.getIngestorWorkQueuePort(),
"Ingestor Work Queue (PUB)"
Channel.CLIENT_REQUEST,
SocketType.PULL,
bindAddress + ":" + config.getFlinkRequestPullPort(),
"Client Request (PULL)"
);
// 2. Market Data Publication - PUB socket for market data streaming and HistoryReadyNotification
// 3. Ingestor Broker — ROUTER for exclusive work dispatch to ingestor DEALER workers
createAndBind(
Channel.MARKET_DATA_PUB,
SocketType.PUB,
bindAddress + ":" + config.getMarketDataPubPort(),
"Market Data Publication (PUB)"
Channel.INGESTOR_BROKER,
SocketType.ROUTER,
bindAddress + ":" + config.getIngestorBrokerPort(),
"Ingestor Broker (ROUTER)"
);
LOG.info("All ZeroMQ channels initialized successfully");
@@ -63,15 +84,10 @@ public class ZmqChannelManager implements Closeable {
private void createAndBind(Channel channel, SocketType socketType, String endpoint, String description) {
try {
ZMQ.Socket socket = context.createSocket(socketType);
// Set socket options
socket.setLinger(1000); // 1 second linger on close
socket.setSndHWM(10000); // High water mark for outbound messages
socket.setRcvHWM(10000); // High water mark for inbound messages
// Bind the socket
socket.setLinger(1000);
socket.setSndHWM(10000);
socket.setRcvHWM(10000);
socket.bind(endpoint);
sockets.put(channel.name(), socket);
LOG.info("Bound {} to {}", description, endpoint);
} catch (Exception e) {
@@ -80,6 +96,13 @@ public class ZmqChannelManager implements Closeable {
}
}
/**
* Create a ZMQ Poller backed by this manager's context.
*/
public ZMQ.Poller createPoller(int size) {
return context.getContext().poller(size);
}
/**
* Get a socket by channel type.
*/
@@ -92,18 +115,11 @@ public class ZmqChannelManager implements Closeable {
}
/**
* Send a message on the specified channel.
*
* @param channel The channel to send on
* @param versionByte Protocol version byte
* @param messageTypeByte Message type ID byte
* @param protobufData Serialized protobuf message
* @return true if sent successfully
* Send a message on a channel (no topic prefix — for PULL/PUSH or direct sends).
*/
public boolean sendMessage(Channel channel, byte versionByte, byte messageTypeByte, byte[] protobufData) {
ZMQ.Socket socket = getSocket(channel);
// Send as two frames: [version byte] [type byte + protobuf data]
boolean sentFrame1 = socket.send(new byte[]{versionByte}, ZMQ.SNDMORE);
if (!sentFrame1) {
LOG.error("Failed to send version frame on channel {}", channel);
@@ -124,27 +140,18 @@ public class ZmqChannelManager implements Closeable {
}
/**
* Send a message with a topic prefix (for PUB sockets).
*
* @param channel The channel to send on
* @param topic Topic string for subscription filtering
* @param versionByte Protocol version byte
* @param messageTypeByte Message type ID byte
* @param protobufData Serialized protobuf message
* @return true if sent successfully
* Send a topic-prefixed message (for XPUB market data publishing).
* Frame layout: [topic][version][type+payload]
*/
public boolean sendTopicMessage(Channel channel, String topic, byte versionByte, byte messageTypeByte, byte[] protobufData) {
ZMQ.Socket socket = getSocket(channel);
// Send as three frames: [topic] [version byte] [type byte + protobuf data]
boolean sentTopic = socket.send(topic.getBytes(ZMQ.CHARSET), ZMQ.SNDMORE);
if (!sentTopic) {
if (!socket.send(topic.getBytes(ZMQ.CHARSET), ZMQ.SNDMORE)) {
LOG.error("Failed to send topic frame on channel {}", channel);
return false;
}
boolean sentFrame1 = socket.send(new byte[]{versionByte}, ZMQ.SNDMORE);
if (!sentFrame1) {
if (!socket.send(new byte[]{versionByte}, ZMQ.SNDMORE)) {
LOG.error("Failed to send version frame on channel {}", channel);
return false;
}
@@ -153,8 +160,7 @@ public class ZmqChannelManager implements Closeable {
frame2[0] = messageTypeByte;
System.arraycopy(protobufData, 0, frame2, 1, protobufData.length);
boolean sentFrame2 = socket.send(frame2, 0);
if (!sentFrame2) {
if (!socket.send(frame2, 0)) {
LOG.error("Failed to send message frame on channel {}", channel);
return false;
}
@@ -162,6 +168,24 @@ public class ZmqChannelManager implements Closeable {
return true;
}
/**
* Send a targeted message to a specific DEALER worker via ROUTER.
* Frame layout: [identity][empty][version][type+payload]
*/
public boolean sendToWorker(byte[] identity, byte versionByte, byte messageTypeByte, byte[] protobufData) {
ZMQ.Socket socket = getSocket(Channel.INGESTOR_BROKER);
if (!socket.send(identity, ZMQ.SNDMORE)) return false;
if (!socket.send(new byte[0], ZMQ.SNDMORE)) return false;
if (!socket.send(new byte[]{versionByte}, ZMQ.SNDMORE)) return false;
byte[] frame = new byte[1 + protobufData.length];
frame[0] = messageTypeByte;
System.arraycopy(protobufData, 0, frame, 1, protobufData.length);
return socket.send(frame, 0);
}
@Override
public void close() {
LOG.info("Closing ZeroMQ channels");