diff --git a/.gitignore b/.gitignore index e8bc9e4..299d43f 100644 --- a/.gitignore +++ b/.gitignore @@ -47,7 +47,7 @@ bin/ .DS_Store ## Local config -config +/config/ cursor.json run-local.sh diff --git a/src/main/java/com/gpuflight/agent/CursorPosition.java b/src/main/java/com/gpuflight/agent/CursorPosition.java index 117ed11..66f5123 100644 --- a/src/main/java/com/gpuflight/agent/CursorPosition.java +++ b/src/main/java/com/gpuflight/agent/CursorPosition.java @@ -14,8 +14,8 @@ * *

Two identity signals, because the client both renames AND compresses on * rotation: - * - {@code fileKey} (inode on POSIX) survives a rename/shift (active → - * {@code .1.log} → {@code .2.log}) but NOT compression — gzip writes a new + * - {@code fileKey} (inode on POSIX) survives a rename/shift (active -> + * {@code .1.log} -> {@code .2.log}) but NOT compression - gzip writes a new * file ({@code .1.log.gz}) with a different inode. Fast path on Linux while * the rotated file is still uncompressed. * - {@code headSig} (CRC32 of the first bytes of the *uncompressed* content) @@ -24,7 +24,7 @@ * {@code .gz}. * *

Identity fields are nullable/zero and default that way, so cursor files - * written before this change still deserialize cleanly (identity unknown → + * written before this change still deserialize cleanly (identity unknown -> * legacy behavior). */ public record CursorPosition( @@ -34,7 +34,7 @@ public record CursorPosition( @JsonProperty("fileSize") long fileSize, // size at last update (sanity check) @JsonProperty("headSig") long headSig // CRC32 of first uncompressed bytes; survives compression. 0 = unset. ) { - /** Backward-compatible constructor — identity unknown. */ + /** Backward-compatible constructor - identity unknown. */ public CursorPosition(int fileIndex, long offset) { this(fileIndex, offset, null, 0L, 0L); } diff --git a/src/main/java/com/gpuflight/agent/LogArchiver.java b/src/main/java/com/gpuflight/agent/LogArchiver.java index fa22b1c..bda40f0 100644 --- a/src/main/java/com/gpuflight/agent/LogArchiver.java +++ b/src/main/java/com/gpuflight/agent/LogArchiver.java @@ -32,7 +32,7 @@ public void archive(Path file, String objectKey) { .build(); // Blocking S3 upload is fine on a virtual thread. s3.putObject(request, file); - System.out.println("[archiver] Uploaded " + file.getFileName() + " → s3://" + config.bucket() + "/" + objectKey); + System.out.println("[archiver] Uploaded " + file.getFileName() + " -> s3://" + config.bucket() + "/" + objectKey); if (config.deleteAfterUpload()) { file.toFile().delete(); System.out.println("[archiver] Deleted " + file.getFileName()); diff --git a/src/main/java/com/gpuflight/agent/LogTailer.java b/src/main/java/com/gpuflight/agent/LogTailer.java index 55ce998..3dd4386 100644 --- a/src/main/java/com/gpuflight/agent/LogTailer.java +++ b/src/main/java/com/gpuflight/agent/LogTailer.java @@ -2,6 +2,7 @@ import tools.jackson.databind.JsonNode; import com.gpuflight.agent.config.JsonSettings; +import com.gpuflight.agent.config.StreamUploadSettings; import com.gpuflight.agent.filter.DeviceMetricDeduplicator; import com.gpuflight.agent.model.LogWrapper; import com.gpuflight.agent.publisher.Publisher; @@ -18,6 +19,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.BlockingQueue; import java.util.zip.CRC32; import java.util.zip.GZIPInputStream; @@ -28,11 +31,11 @@ * v1.2 file naming (one LogTailer per session/channel pair): * Active file : //.log * Rotated files: //.1.log[.gz] (newest rotated) - * //.2.log[.gz] (older), … + * //.2.log[.gz] (older), ... * * Cursor fileIndex semantics: - * 0 → currently tailing the active file - * N≥1 → finishing the just-rotated file ..log[.gz] after a rotation event, + * 0 -> currently tailing the active file + * N>=1 -> finishing the just-rotated file ..log[.gz] after a rotation event, * then returning to index 0 for the new active file * * Two robustness features guard against the client compressing a rotated file @@ -56,6 +59,7 @@ public class LogTailer { private final CursorManager cursorMgr; private final BlockingQueue consumedFilesQueue; // nullable private final DeviceMetricDeduplicator deduplicator; // nullable + private final StreamUploadSettings streamUploadSettings; private final String streamKey; /** Max rotated index scanned during restart reconciliation. Matches the @@ -72,6 +76,14 @@ public LogTailer(File folder, String sessionId, String logType, String topicPref public LogTailer(File folder, String sessionId, String logType, String topicPrefix, CursorManager cursorMgr, BlockingQueue consumedFilesQueue, DeviceMetricDeduplicator deduplicator) { + this(folder, sessionId, logType, topicPrefix, cursorMgr, consumedFilesQueue, + deduplicator, StreamUploadSettings.DISABLED); + } + + public LogTailer(File folder, String sessionId, String logType, String topicPrefix, + CursorManager cursorMgr, BlockingQueue consumedFilesQueue, + DeviceMetricDeduplicator deduplicator, + StreamUploadSettings streamUploadSettings) { this.folder = folder; this.sessionId = sessionId; this.logType = logType; @@ -79,9 +91,12 @@ public LogTailer(File folder, String sessionId, String logType, String topicPref this.cursorMgr = cursorMgr; this.consumedFilesQueue = consumedFilesQueue; this.deduplicator = deduplicator; + this.streamUploadSettings = streamUploadSettings == null + ? StreamUploadSettings.DISABLED + : streamUploadSettings; // Cursor key namespaces by session_id so two concurrent sessions' // device channels (say `s1.device` and `s2.device`) don't share - // a cursor position. Survives restart — sessions in + // a cursor position. Survives restart - sessions in // cursor.json that are no longer on disk get pruned lazily. this.streamKey = sessionId + "." + logType; } @@ -98,9 +113,9 @@ private File activeFile() { /** * The compressed-active file: {@code //.log.gz}. - * On a CLEAN SHUTDOWN the client compresses the active file IN PLACE — the + * On a CLEAN SHUTDOWN the client compresses the active file IN PLACE - the * name keeps the channel base with NO rotation index (active {@code - * .log} → {@code .log.gz}), distinct from a rotated + * .log} -> {@code .log.gz}), distinct from a rotated * {@code ..log.gz}. A finished session's only data for a channel * therefore lives here, and there is never a plain {@code .log} * alongside it. The tail loop falls back to this when the active file is @@ -111,7 +126,7 @@ private File activeGzFile() { } /** - * The rotated file for {@code index} (≥1). Prefers the uncompressed + * The rotated file for {@code index} (>=1). Prefers the uncompressed * {@code .index.log}; falls back to {@code .index.log.gz} once the client * has compressed it. Returns null if neither exists. */ @@ -122,10 +137,82 @@ private File resolveRotated(int index) { return gz.exists() ? gz : null; } + private int highestRotatedIndex() { + for (int i = MAX_ROTATED_SCAN; i >= 1; i--) { + if (resolveRotated(i) != null) return i; + } + return -1; + } + + private int previousRotatedIndex(int current) { + for (int i = current - 1; i >= 1; i--) { + if (resolveRotated(i) != null) return i; + } + return -1; + } + private static boolean isGz(File f) { return f.getName().endsWith(".gz"); } + private static final class StreamBatch { + private final int maxLines; + private final long maxBytes; + private final List lines = new ArrayList<>(); + private long bytes; + private long startOffset = -1L; + private long endOffset; + + StreamBatch(StreamUploadSettings settings) { + this.maxLines = Math.max(1, settings.maxLines()); + this.maxBytes = Math.max(1L, settings.maxBytes()); + } + + boolean isEmpty() { + return lines.isEmpty(); + } + + long startOffset() { + return startOffset; + } + + long endOffset() { + return endOffset; + } + + int lineCount() { + return lines.size(); + } + + long bytes() { + return bytes; + } + + void add(String line, long lineStart, long lineEnd) { + if (lines.isEmpty()) { + startOffset = lineStart; + } + lines.add(line); + bytes += line.getBytes(StandardCharsets.UTF_8).length + 1L; + endOffset = lineEnd; + } + + boolean shouldFlush() { + return lines.size() >= maxLines || bytes >= maxBytes; + } + + List lines() { + return List.copyOf(lines); + } + + void clear() { + lines.clear(); + bytes = 0L; + startOffset = -1L; + endOffset = 0L; + } + } + // ---- File identity (D) ---- private record Identity(String fileKey, long size, long headSig) {} @@ -147,7 +234,7 @@ private static Identity identityOf(File f) { * CRC32 of the first {@link #HEAD_SIG_BYTES} bytes of the file's * UNCOMPRESSED content (transparently decompresses a .gz). Because it is * computed over content, it survives both the client's rename AND its - * compression — it is the signal that lets a restart find the file it was + * compression - it is the signal that lets a restart find the file it was * reading even after it became a .gz. Returns 0 when unreadable (the * "unset" sentinel); never returns 0 for real content. */ @@ -175,7 +262,7 @@ private static boolean identityMatches(CursorPosition saved, Identity cand) { return false; } - /** Locate the index (0=active, ≥1=rotated) whose current file matches the + /** Locate the index (0=active, >=1=rotated) whose current file matches the * saved identity. Returns -1 if no file matches. */ private int locateByIdentity(CursorPosition saved) { File active = activeFile(); @@ -195,7 +282,7 @@ private int locateByIdentity(CursorPosition saved) { } /** - * Blocking tail loop — designed to run on a virtual thread. + * Blocking tail loop - designed to run on a virtual thread. * Thread.sleep() parks the virtual thread cheaply; no carrier thread is blocked. */ public void tail(Publisher publisher) { @@ -205,7 +292,7 @@ public void tail(Publisher publisher) { // ---- D: restart reconciliation ---- // A brief downtime may have rotated (and compressed) the file we were - // reading out of its slot — active → .1.log → .1.log.gz, or a rotated + // reading out of its slot - active -> .1.log -> .1.log.gz, or a rotated // file shifted to a higher index. If we recorded an identity, find the // file that currently holds it (by content signature, which survives // compression) and resume from there instead of resetting to the new @@ -218,10 +305,10 @@ public void tail(Publisher publisher) { idx = located; cursorMgr.update(streamKey, idx, offset); } - // located == -1 → file not found anywhere; keep cursor, legacy fallback applies. + // located == -1 -> file not found anywhere; keep cursor, legacy fallback applies. } - System.out.println("[" + logType + "] Starting – fileIndex=" + idx + ", offset=" + offset); + System.out.println("[" + logType + "] Starting - fileIndex=" + idx + ", offset=" + offset); System.out.println("[" + logType + "] Active file: " + activeFile().getAbsolutePath()); System.out.println("[" + logType + "] Active file exists: " + activeFile().exists() + ", size: " + (activeFile().exists() ? activeFile().length() : -1)); @@ -250,23 +337,33 @@ public void tail(Publisher publisher) { // only data for this channel lives there. Drain it once from // our current offset: drainGz skips already-read uncompressed // bytes, so this is correct whether we discovered the session - // cold (offset 0 → whole file) or were live-tailing + // cold (offset 0 -> whole file) or were live-tailing // .log when it was compressed at shutdown (offset = - // bytes already published → just the tail). + // bytes already published -> just the tail). File activeGz = activeGzFile(); if (activeGz.exists()) { long resume = drainGz(activeGz, offset, publisher, 0); if (resume < 0) { System.out.println("[" + logType + "] Drained compressed-active " - + activeGz.getName() + " – session finished."); + + activeGz.getName() + " - session finished."); if (consumedFilesQueue != null) consumedFilesQueue.offer(activeGz.toPath()); return; // compressed-active is terminal: no more data will arrive } - // Publish failed mid-drain — back off, retry from persisted offset. + // Publish failed mid-drain - back off, retry from persisted offset. offset = resume; sleep(5000); continue; } + int oldestRotated = highestRotatedIndex(); + if (oldestRotated > 0) { + File rotated = resolveRotated(oldestRotated); + System.out.println("[" + logType + "] Active file is absent; draining rotated " + + rotated.getName() + " from offset 0."); + idx = oldestRotated; + offset = 0; + cursorMgr.update(streamKey, idx, offset); + continue; + } sleep(1000); continue; } @@ -275,14 +372,31 @@ public void tail(Publisher publisher) { if (idx > 0 && isGz(file)) { long resume = drainGz(file, offset, publisher, idx); if (resume < 0) { - System.out.println("[" + logType + "] Finished " + file.getName() + " (gz), resuming active file."); + System.out.println("[" + logType + "] Finished " + file.getName() + " (gz)."); if (consumedFilesQueue != null) consumedFilesQueue.offer(file.toPath()); lastConsumedSig = headSignature(file); + int nextRotated = activeFile().exists() ? -1 : previousRotatedIndex(idx); + if (nextRotated > 0) { + idx = nextRotated; + offset = 0; + System.out.println("[" + logType + "] Draining next rotated file " + + resolveRotated(idx).getName() + "."); + cursorMgr.update(streamKey, idx, offset); + continue; + } + if (!activeFile().exists()) { + idx = 0; + offset = 0; + cursorMgr.update(streamKey, idx, offset); + System.out.println("[" + logType + "] No active file remains; session finished."); + return; + } + System.out.println("[" + logType + "] Resuming active file."); idx = 0; offset = 0; cursorMgr.update(streamKey, idx, offset); } else { - // Publish failed mid-gz — back off and retry from persisted offset. + // Publish failed mid-gz - back off and retry from persisted offset. offset = resume; sleep(5000); } @@ -301,17 +415,56 @@ public void tail(Publisher publisher) { if (file.length() > offset) { String line; boolean publishFailed = false; - // NOTE: RandomAccessFile.readLine() decodes bytes as ISO-8859-1, - // corrupting multi-byte UTF-8 characters. Use readLineUtf8(). - while ((line = readLineUtf8(reader)) != null) { - long lineStart = offset; - if (!handleLine(line, publisher)) { - System.out.println("[" + logType + "] Publish FAILED – will retry in 5s"); - reader.seek(lineStart); // retry this line next iteration - publishFailed = true; - break; + if (streamUploadSettings.enabled()) { + StreamBatch batch = new StreamBatch(streamUploadSettings); + while (true) { + long lineStart = reader.getFilePointer(); + line = readLineUtf8(reader); + if (line == null) break; + long lineEnd = reader.getFilePointer(); + + String prepared = prepareStreamLine(line); + if (prepared == null) { + offset = lineEnd; + continue; + } + batch.add(prepared, lineStart, lineEnd); + if (batch.shouldFlush()) { + if (!flushStreamBatch(batch, publisher)) { + System.out.println("[" + logType + "] Stream publish FAILED - will retry in 5s"); + reader.seek(batch.startOffset()); + offset = batch.startOffset(); + publishFailed = true; + break; + } + offset = batch.endOffset(); + batch.clear(); + } + } + if (!publishFailed && !batch.isEmpty()) { + if (!flushStreamBatch(batch, publisher)) { + System.out.println("[" + logType + "] Stream publish FAILED - will retry in 5s"); + reader.seek(batch.startOffset()); + offset = batch.startOffset(); + publishFailed = true; + } else { + offset = batch.endOffset(); + batch.clear(); + } + } + } else { + // NOTE: RandomAccessFile.readLine() decodes bytes as ISO-8859-1, + // corrupting multi-byte UTF-8 characters. Use readLineUtf8(). + while ((line = readLineUtf8(reader)) != null) { + long lineStart = offset; + if (!handleLine(line, publisher)) { + System.out.println("[" + logType + "] Publish FAILED - will retry in 5s"); + reader.seek(lineStart); // retry this line next iteration + publishFailed = true; + break; + } + offset = reader.getFilePointer(); } - offset = reader.getFilePointer(); } cursorMgr.update(streamKey, idx, offset, id.fileKey(), id.size(), id.headSig()); if (publishFailed) { @@ -332,12 +485,12 @@ public void tail(Publisher publisher) { if (rotated != null && rsig != 0L && rsig == lastConsumedSig) { sleep(200); // already consumed this exact rotated content; wait for a real new rotation } - // A compressed rotated file (.gz) is always valid to switch to — + // A compressed rotated file (.gz) is always valid to switch to - // its uncompressed size isn't comparable to our byte offset, so we // rely on skip-to-offset in drainGz. For an uncompressed .log we keep // the length>=offset guard (it must be at least the old active size). else if (rotated != null && (isGz(rotated) || rotated.length() >= offset)) { - System.out.println("[" + logType + "] Rotation detected – switching to " + rotated.getName() + " at offset " + offset); + System.out.println("[" + logType + "] Rotation detected - switching to " + rotated.getName() + " at offset " + offset); idx = 1; cursorMgr.update(streamKey, idx, offset, id.fileKey(), id.size(), id.headSig()); } else { @@ -345,11 +498,29 @@ else if (rotated != null && (isGz(rotated) || rotated.length() >= offset)) { } } else { // Finished draining an uncompressed rotated file. - System.out.println("[" + logType + "] Finished " + file.getName() + ", resuming active file."); + System.out.println("[" + logType + "] Finished " + file.getName() + "."); if (consumedFilesQueue != null) { consumedFilesQueue.offer(file.toPath()); } lastConsumedSig = id.headSig(); + int nextRotated = activeFile().exists() ? -1 : previousRotatedIndex(idx); + if (nextRotated > 0) { + idx = nextRotated; + offset = 0; + System.out.println("[" + logType + "] Draining next rotated file " + + resolveRotated(idx).getName() + "."); + cursorMgr.update(streamKey, idx, offset); + fileIsDone = true; + continue; + } + if (!activeFile().exists()) { + idx = 0; + offset = 0; + cursorMgr.update(streamKey, idx, offset); + System.out.println("[" + logType + "] No active file remains; session finished."); + return; + } + System.out.println("[" + logType + "] Resuming active file."); idx = 0; offset = 0; cursorMgr.update(streamKey, idx, offset); @@ -370,19 +541,58 @@ else if (rotated != null && (isGz(rotated) || rotated.length() >= offset)) { * (EOF); otherwise the offset to resume from after a publish failure. The * cursor is updated as progress is made, so a crash mid-drain resumes * correctly. gz streams have no random seek, so a retry re-opens and - * re-skips — bounded by the 50 MB rotated-file size. + * re-skips - bounded by the 50 MB rotated-file size. */ private long drainGz(File gz, long startOffset, Publisher publisher, int idx) { Identity id = identityOf(gz); try (InputStream in = new BufferedInputStream(new GZIPInputStream(new FileInputStream(gz)))) { long start = skipFully(in, startOffset); long[] consumed = { start }; + if (streamUploadSettings.enabled()) { + StreamBatch batch = new StreamBatch(streamUploadSettings); + while (!Thread.currentThread().isInterrupted()) { + long lineStart = consumed[0]; + String line = readLineCounting(in, consumed); + if (line == null) { + if (!batch.isEmpty()) { + if (!flushStreamBatch(batch, publisher)) { + System.out.println("[" + logType + "] Stream publish FAILED (gz) - will retry in 5s"); + cursorMgr.update(streamKey, idx, batch.startOffset(), + id.fileKey(), id.size(), id.headSig()); + return batch.startOffset(); + } + cursorMgr.update(streamKey, idx, batch.endOffset(), + id.fileKey(), id.size(), id.headSig()); + } + return -1; // EOF -> fully drained + } + + String prepared = prepareStreamLine(line); + if (prepared == null) { + cursorMgr.update(streamKey, idx, consumed[0], id.fileKey(), id.size(), id.headSig()); + continue; + } + batch.add(prepared, lineStart, consumed[0]); + if (batch.shouldFlush()) { + if (!flushStreamBatch(batch, publisher)) { + System.out.println("[" + logType + "] Stream publish FAILED (gz) - will retry in 5s"); + cursorMgr.update(streamKey, idx, batch.startOffset(), + id.fileKey(), id.size(), id.headSig()); + return batch.startOffset(); + } + cursorMgr.update(streamKey, idx, batch.endOffset(), + id.fileKey(), id.size(), id.headSig()); + batch.clear(); + } + } + return batch.isEmpty() ? consumed[0] : batch.startOffset(); + } while (!Thread.currentThread().isInterrupted()) { long lineStart = consumed[0]; String line = readLineCounting(in, consumed); - if (line == null) return -1; // EOF → fully drained + if (line == null) return -1; // EOF -> fully drained if (!handleLine(line, publisher)) { - System.out.println("[" + logType + "] Publish FAILED (gz) – will retry in 5s"); + System.out.println("[" + logType + "] Publish FAILED (gz) - will retry in 5s"); cursorMgr.update(streamKey, idx, lineStart, id.fileKey(), id.size(), id.headSig()); return lineStart; } @@ -395,6 +605,47 @@ private long drainGz(File gz, long startOffset, Publisher publisher, int idx) { } } + private boolean flushStreamBatch(StreamBatch batch, Publisher publisher) { + if (batch.isEmpty()) { + return true; + } + System.out.println("[" + logType + "] Sending stream batch: session=" + sessionId + + " lines=" + batch.lineCount() + + " bytes=" + batch.bytes() + + " offset=" + batch.startOffset() + "->" + batch.endOffset()); + boolean accepted = publisher.publishStream(sessionId, batch.lines()); + if (accepted) { + System.out.println("[" + logType + "] Stream batch accepted: session=" + sessionId + + " lines=" + batch.lineCount() + + " nextOffset=" + batch.endOffset()); + } else { + System.out.println("[" + logType + "] Stream batch rejected: session=" + sessionId + + " retryOffset=" + batch.startOffset()); + } + return accepted; + } + + /** + * Validate and optionally deduplicate one raw NDJSON line for stream upload. + * Returns null when the line should be skipped. + */ + private String prepareStreamLine(String line) { + if (line.isBlank()) return null; + try { + JsonNode node = JsonSettings.MAPPER.readTree(line); + String type = node.path("type").asText("unknown"); + if (deduplicator != null && "device_metric_batch".equals(type)) { + return deduplicator.filterBatch(line); + } + return line; + } catch (Exception e) { + // Occasional parse failures are expected when tailing an actively-written + // file - the reader may see a partial line before the writer flushes. + System.err.println("[LogTailer] Skipping malformed line: " + e.getMessage()); + return null; + } + } + /** * Parse, dedup, and publish one raw line. Returns true if the line was * published or intentionally skipped (blank / malformed / all rows @@ -403,7 +654,7 @@ private long drainGz(File gz, long startOffset, Publisher publisher, int idx) { private boolean handleLine(String line, Publisher publisher) { if (line.isBlank()) return true; LogWrapper wrapper = processLine(line); - if (wrapper == null) return true; // malformed — skip (matches prior behavior) + if (wrapper == null) return true; // malformed - skip (matches prior behavior) if (deduplicator != null && "device_metric_batch".equals(wrapper.type())) { String filtered = deduplicator.filterBatch(wrapper.data()); if (filtered == null) return true; // all rows suppressed @@ -429,7 +680,7 @@ private LogWrapper processLine(String rawLine) { ); } catch (Exception e) { // Occasional parse failures are expected when tailing an actively-written - // file — the reader may see a partial line before the writer flushes. + // file - the reader may see a partial line before the writer flushes. System.err.println("[LogTailer] Skipping malformed line: " + e.getMessage()); return null; } diff --git a/src/main/java/com/gpuflight/agent/Main.java b/src/main/java/com/gpuflight/agent/Main.java index f8fdb60..45f316d 100644 --- a/src/main/java/com/gpuflight/agent/Main.java +++ b/src/main/java/com/gpuflight/agent/Main.java @@ -4,6 +4,7 @@ import com.gpuflight.agent.config.JsonSettings; import com.gpuflight.agent.config.KafkaConfig; import com.gpuflight.agent.config.PublisherConfig; +import com.gpuflight.agent.config.StreamUploadSettings; import com.gpuflight.agent.model.AgentConfig; import com.gpuflight.agent.model.ArchiverConfig; import com.gpuflight.agent.model.LogSourceConfig; @@ -31,7 +32,7 @@ static void main(String[] args) throws Exception { Publisher publisher = PublisherFactory.create(config.publisher()); System.out.println("[agent] Publisher: " + config.publisher().getClass().getSimpleName()); - // In v1.2 a "source" is just a FOLDER to watch — each run writes its + // In v1.2 a "source" is just a FOLDER to watch - each run writes its // logs under //.log[.gz], so sessions are // auto-discovered (no per-session config). Collect every folder to // watch with its channel filter (logTypes); we discover + tail the @@ -62,7 +63,7 @@ static void main(String[] args) throws Exception { } if (watchedFolders.isEmpty()) { - System.err.println("❌ No log sources configured (set --folder, --folders, or GPUFL_SOURCE_FOLDERS)"); + System.err.println("ERROR: No log sources configured (set --folder, --folders, or GPUFL_SOURCE_FOLDERS)"); System.exit(1); } @@ -71,6 +72,15 @@ static void main(String[] args) throws Exception { var consumedFilesQueue = new LinkedBlockingQueue(); String topicPrefix = topicPrefix(config); + StreamUploadSettings streamUploadSettings = switch (config.publisher()) { + case HttpConfig http -> StreamUploadSettings.from(http); + default -> StreamUploadSettings.DISABLED; + }; + if (streamUploadSettings.enabled()) { + System.out.println("[agent] HTTP upload mode: stream" + + " maxLines=" + streamUploadSettings.maxLines() + + " maxBytes=" + streamUploadSettings.maxBytes()); + } var executor = Executors.newVirtualThreadPerTaskExecutor(); var deduplicator = new DeviceMetricDeduplicator(); @@ -80,12 +90,12 @@ static void main(String[] args) throws Exception { // initial spawn can all insert without losing races. A session is // announced + spawned exactly once; the 2s rescan is then a no-op for // it. (With the compressed-active drain in LogTailer, a finished - // session's tailers exit on their own once drained — they are never + // session's tailers exit on their own once drained - they are never // re-watched, so this set also marks "done" for the agent's lifetime.) var startedSessions = java.util.concurrent.ConcurrentHashMap.newKeySet(); // Spawn the per-channel tailer set for one discovered session. - // Idempotent — a second call for the same (folder, session_id) is a + // Idempotent - a second call for the same (folder, session_id) is a // no-op, so the rescan can call it freely without re-announcing. java.util.function.Consumer spawnSessionTailers = (session) -> { String key = session.folder().getAbsolutePath() + "::" + session.sessionId(); @@ -97,7 +107,8 @@ static void main(String[] args) throws Exception { // Only the "system" channel carries device_metric_batch events. var dedup = "system".equals(type) ? deduplicator : null; var tailer = new LogTailer(session.folder(), session.sessionId(), type, - topicPrefix, cursorMgr, consumedFilesQueue, dedup); + topicPrefix, cursorMgr, consumedFilesQueue, dedup, + streamUploadSettings); tailer.tail(publisher); }); } @@ -111,7 +122,7 @@ static void main(String[] args) throws Exception { } // New-session watcher. The agent must notice sessions that start AFTER - // it booted — without this, a long-running agent would only ship the + // it booted - without this, a long-running agent would only ship the // sessions that existed at startup. We poll each watched folder (2s) // instead of inotify/ReadDirectoryChangesW for portability: the latency // is negligible vs. typical session lifetimes, and the cost is one @@ -156,7 +167,7 @@ static void main(String[] args) throws Exception { } Runtime.getRuntime().addShutdownHook(new Thread(() -> { - System.out.println("Shutting down…"); + System.out.println("Shutting down..."); executor.shutdownNow(); try { publisher.close(); } catch (Exception ignored) {} })); @@ -171,7 +182,7 @@ static void main(String[] args) throws Exception { /** * Builds AgentConfig from CLI flags and/or environment variables. - * Resolution order per field: CLI flag → env var → default. + * Resolution order per field: CLI flag -> env var -> default. * Falls back to the bundled local.json when no flags or GPUFL_* vars are present. */ static AgentConfig loadFromArgs(String[] args) { @@ -183,19 +194,19 @@ static AgentConfig loadFromArgs(String[] args, Map env) { || env.keySet().stream().anyMatch(k -> k.startsWith("GPUFL_")); if (!hasAnyConfig) { - System.out.println("No flags or GPUFL_* env vars found — using bundled local.json"); + System.out.println("No flags or GPUFL_* env vars found - using bundled local.json"); return loadClasspathConfig("config/local.json"); } String folder = resolve(args, "folder", "GPUFL_SOURCE_FOLDER", null, env); String foldersEnv = resolve(args, "folders", "GPUFL_SOURCE_FOLDERS", null, env); if (folder == null && foldersEnv == null) { - System.err.println("❌ --folder (or env GPUFL_SOURCE_FOLDER) or --folders (or env GPUFL_SOURCE_FOLDERS) is required"); + System.err.println("ERROR: --folder (or env GPUFL_SOURCE_FOLDER) or --folders (or env GPUFL_SOURCE_FOLDERS) is required"); printUsage(); System.exit(1); } String logTypesRaw = resolve(args, "log-types", "GPUFL_LOG_TYPES", null, env); - List logTypes = parseLogTypes(logTypesRaw); // null → LogSourceConfig applies the default + List logTypes = parseLogTypes(logTypesRaw); // null -> LogSourceConfig applies the default String type = require(args, "type", "GPUFL_PUBLISHER_TYPE", env); // Reject the legacy --url / GPUFL_HTTP_URL flag with a migration @@ -203,10 +214,10 @@ static AgentConfig loadFromArgs(String[] args, Map env) { // HttpConfig javadoc). Without this explicit check the user // would silently lose the URL value (resolve() would return // null for the new --host name) and hit the IllegalArgumentException - // out of HttpConfig's compact constructor — same outcome but + // out of HttpConfig's compact constructor - same outcome but // less obvious about WHICH legacy flag is at fault. if (resolve(args, "url", "GPUFL_HTTP_URL", null, env) != null) { - System.err.println("❌ --url / GPUFL_HTTP_URL is no longer supported."); + System.err.println("ERROR: --url / GPUFL_HTTP_URL is no longer supported."); System.err.println(" Use --host= (or env GPUFL_HTTP_HOST)"); System.err.println(" --api-version= (or env GPUFL_HTTP_API_VERSION, default: v1)"); System.err.println(" The /api/{version}/events/ path is now built automatically."); @@ -217,14 +228,17 @@ static AgentConfig loadFromArgs(String[] args, Map env) { require(args, "host", "GPUFL_HTTP_HOST", env), resolve(args, "api-version", "GPUFL_HTTP_API_VERSION", HttpConfig.DEFAULT_API_VERSION, env), resolve(args, "token", "GPUFL_HTTP_TOKEN", null, env), - Long.parseLong(resolve(args, "timeout", "GPUFL_HTTP_TIMEOUT_SEC", "10", env))); + Long.parseLong(resolve(args, "timeout", "GPUFL_HTTP_TIMEOUT_SEC", "10", env)), + resolve(args, "upload-mode", "GPUFL_AGENT_UPLOAD_MODE", HttpConfig.DEFAULT_UPLOAD_MODE, env), + Integer.parseInt(resolve(args, "stream-max-lines", "GPUFL_AGENT_STREAM_MAX_LINES", "0", env)), + Long.parseLong(resolve(args, "stream-max-bytes", "GPUFL_AGENT_STREAM_MAX_BYTES", "0", env))); case "kafka" -> new KafkaConfig( require(args, "brokers", "GPUFL_KAFKA_BROKERS", env), resolve(args, "topic-prefix", "GPUFL_KAFKA_TOPIC_PREFIX", null, env), resolve(args, "compression", "GPUFL_KAFKA_COMPRESSION", null, env), Integer.parseInt(resolve(args, "kafka-linger-ms", "GPUFL_KAFKA_LINGER_MS", "0", env))); default -> { - System.err.println("❌ Unknown publisher type: '" + type + "' (expected: http, kafka)"); + System.err.println("ERROR: Unknown publisher type: '" + type + "' (expected: http, kafka)"); printUsage(); System.exit(1); yield null; // unreachable @@ -288,7 +302,7 @@ static String require(String[] args, String flag, String envVar) { static String require(String[] args, String flag, String envVar, Map env) { String val = resolve(args, flag, envVar, null, env); if (val == null) { - System.err.println("❌ --" + flag + " (or env " + envVar + ") is required"); + System.err.println("ERROR: --" + flag + " (or env " + envVar + ") is required"); printUsage(); System.exit(1); } @@ -297,14 +311,14 @@ static String require(String[] args, String flag, String envVar, Map//{device,scope,system}.log[.N.log[.gz]] + * //{device,scope,system,sass}.log[.N.log[.gz]] * * Pattern matches the filenames INSIDE a session subdir (no prefix * component). Used as a quick check that a candidate subdir looks * like a gpufl session (vs. an unrelated user-created folder). */ private static final Pattern CHANNEL_FILE_PATTERN = - Pattern.compile("^(device|scope|system)(?:\\.\\d+)?\\.log(?:\\.gz)?$"); + Pattern.compile("^(device|scope|system|sass)(?:\\.\\d+)?\\.log(?:\\.gz)?$"); /** Folders we've already emitted the pre-v1.2 migration warning for. * {@link #discoverSources} runs on a 2-second rescan loop, so without @@ -313,8 +327,11 @@ static String require(String[] args, String flag, String envVar, Map warnedLegacyFolders = java.util.concurrent.ConcurrentHashMap.newKeySet(); - /** Default channels to tail when a source doesn't restrict them. */ - private static final List DEFAULT_LOG_TYPES = List.of("device", "scope", "system"); + /** Default channels to tail when a source doesn't restrict them. + * "sass" carries the bulky SASS-disassembly / source-content artifacts + * split out of device.log; it must be tailed or those artifacts miss + * live upload. */ + private static final List DEFAULT_LOG_TYPES = List.of("device", "scope", "system", "sass"); /** A session found under a watched folder: the folder, the session_id * (the subdir name), and the channels to tail. Carries the session_id @@ -323,7 +340,7 @@ record DiscoveredSession(File folder, String sessionId, List logTypes) { /** * v1.2: scan {@code folder} for session subdirectories. Each subdir is a - * session — its name is the session_id; its contents are channel files + * session - its name is the session_id; its contents are channel files * matching {@link #CHANNEL_FILE_PATTERN}. Returns one * {@link DiscoveredSession} per discovered session, each carrying the * given {@code logTypes} (or the default channels when null/empty). @@ -333,7 +350,7 @@ record DiscoveredSession(File folder, String sessionId, List logTypes) { * that match the pre-v1.2 flat pattern {@code ..log} * trigger a one-time warning so a user upgrading from v1.1 sees the * migration hint. The per-session announcement happens once in - * spawnSessionTailers, NOT here — this method runs on a 2s rescan loop, so + * spawnSessionTailers, NOT here - this method runs on a 2s rescan loop, so * logging a "discovered" line per session every tick would just spam. */ static List discoverSources(File folder, List logTypes) { @@ -343,16 +360,16 @@ static List discoverSources(File folder, List logType List types = (logTypes == null || logTypes.isEmpty()) ? DEFAULT_LOG_TYPES : logTypes; // Legacy-format warning: if there are pre-v1.2 flat files at - // the folder's top level, the agent won't see them — the new + // the folder's top level, the agent won't see them - the new // discovery walks subdirs only. Pattern legacyTop = Pattern.compile( - "^(.+)\\.(device|scope|system)(?:\\.\\d+)?\\.log(?:\\.gz)?$"); + "^(.+)\\.(device|scope|system|sass)(?:\\.\\d+)?\\.log(?:\\.gz)?$"); File[] topFiles = folder.listFiles(); if (topFiles != null) { for (File f : topFiles) { if (f.isFile() && legacyTop.matcher(f.getName()).matches()) { // Warn only the first time we see legacy files in this - // folder — the rescan loop calls this every 2s. + // folder - the rescan loop calls this every 2s. if (warnedLegacyFolders.add(folder.getAbsolutePath())) { System.err.println( "[agent] warning: found pre-v1.2 flat log file '" + @@ -414,7 +431,7 @@ static String topicPrefix(AgentConfig config) { private static AgentConfig loadExternalConfig(String path) { var file = new File(path); if (!file.exists()) { - System.err.println("❌ Config file not found: '" + path + "'"); + System.err.println("ERROR: Config file not found: '" + path + "'"); System.exit(1); } try { @@ -429,7 +446,7 @@ private static AgentConfig loadExternalConfig(String path) { private static AgentConfig loadClasspathConfig(String resourcePath) { try (var stream = Main.class.getClassLoader().getResourceAsStream(resourcePath)) { if (stream == null) { - System.err.println("❌ Bundled config not found: '" + resourcePath + "'"); + System.err.println("ERROR: Bundled config not found: '" + resourcePath + "'"); System.exit(1); } return JsonSettings.MAPPER.readValue(stream, AgentConfig.class); @@ -455,7 +472,7 @@ Config file (overrides all flags): Source (at least one of --folder or --folders is required): --folder= Log folder path [GPUFL_SOURCE_FOLDER] --folders= Auto-discover folders [GPUFL_SOURCE_FOLDERS] - --log-types= Log channels to tail [GPUFL_LOG_TYPES] default: device,scope,system + --log-types= Log channels to tail [GPUFL_LOG_TYPES] default: device,scope,system,sass --cursor-file= Cursor state file [GPUFL_CURSOR_FILE] default: ./cursor.json Publisher (required): @@ -466,6 +483,9 @@ Config file (overrides all flags): --api-version= Backend API version [GPUFL_HTTP_API_VERSION] default: v1 --token= Bearer auth token [GPUFL_HTTP_TOKEN] --timeout= Request timeout [GPUFL_HTTP_TIMEOUT_SEC] default: 10 + --upload-mode= Upload protocol [GPUFL_AGENT_UPLOAD_MODE] default: stream + --stream-max-lines= Stream batch line limit [GPUFL_AGENT_STREAM_MAX_LINES] default: 5000 + --stream-max-bytes= Stream batch byte limit [GPUFL_AGENT_STREAM_MAX_BYTES] default: 1000000 Kafka publisher: --brokers= Bootstrap servers [GPUFL_KAFKA_BROKERS] @@ -473,7 +493,7 @@ Config file (overrides all flags): --compression= Compression codec [GPUFL_KAFKA_COMPRESSION] default: snappy --kafka-linger-ms= Producer linger.ms [GPUFL_KAFKA_LINGER_MS] default: 100 - Archiver (optional — disabled if --archiver-endpoint is absent): + Archiver (optional - disabled if --archiver-endpoint is absent): --archiver-endpoint= S3-compatible endpoint [GPUFL_ARCHIVER_ENDPOINT] --archiver-bucket= S3 bucket [GPUFL_ARCHIVER_BUCKET] --archiver-region= Region [GPUFL_ARCHIVER_REGION] default: nyc3 @@ -492,7 +512,7 @@ Config file (overrides all flags): # JSON config file gpufl-agent --config=/etc/gpuflight/agent.json - # No args → uses bundled local.json (development only) + # No args -> uses bundled local.json (development only) gpufl-agent """); } diff --git a/src/main/java/com/gpuflight/agent/config/HttpConfig.java b/src/main/java/com/gpuflight/agent/config/HttpConfig.java index 4ab1eaa..6fc1b8a 100644 --- a/src/main/java/com/gpuflight/agent/config/HttpConfig.java +++ b/src/main/java/com/gpuflight/agent/config/HttpConfig.java @@ -2,22 +2,24 @@ import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Locale; + /** * Configuration for the HTTP publisher. * - *

The destination is split across {@code hostUrl} + {@code apiVersion} — + *

The destination is split across {@code hostUrl} + {@code apiVersion}; * the full path is built internally via {@link #endpointFor(String)} so - * the user only specifies what changes per-deployment (the host) and - * occasionally per-rollout (the version). The {@code /api/{version}/events/} + * the user only specifies what changes per deployment (the host) and + * occasionally per rollout (the version). The {@code /api/{version}/events/} * path is a structural contract baked into this record. * *

Examples: *

  *   hostUrl="https://api.gpuflight.com"     apiVersion="v1"
- *     → endpointFor("init") = "https://api.gpuflight.com/api/v1/events/init"
+ *     endpointFor("init") = "https://api.gpuflight.com/api/v1/events/init"
  *
  *   hostUrl="http://localhost:8080"         apiVersion="v2"
- *     → endpointFor("init") = "http://localhost:8080/api/v2/events/init"
+ *     endpointFor("init") = "http://localhost:8080/api/v2/events/init"
  * 
* *

Migration note (May 2026): previous releases used a @@ -33,23 +35,53 @@ public record HttpConfig( @JsonProperty("hostUrl") String hostUrl, @JsonProperty("apiVersion") String apiVersion, @JsonProperty("authToken") String authToken, - @JsonProperty("timeoutSeconds") long timeoutSeconds + @JsonProperty("timeoutSeconds") long timeoutSeconds, + @JsonProperty("uploadMode") String uploadMode, + @JsonProperty("streamMaxLines") int streamMaxLines, + @JsonProperty("streamMaxBytes") long streamMaxBytes ) implements PublisherConfig { /** Default API version when the user doesn't specify one. */ public static final String DEFAULT_API_VERSION = "v1"; + public static final String DEFAULT_UPLOAD_MODE = "stream"; + public static final int DEFAULT_STREAM_MAX_LINES = 5_000; + public static final long DEFAULT_STREAM_MAX_BYTES = 1_000_000L; + + private static final String LEGACY_UPLOAD_MODE = "legacy"; + + public HttpConfig(String hostUrl, String apiVersion, String authToken, long timeoutSeconds) { + this(hostUrl, apiVersion, authToken, timeoutSeconds, DEFAULT_UPLOAD_MODE, 0, 0); + } public HttpConfig { // Field-level defaults / normalization. Run inside the compact - // constructor so they apply to BOTH the JSON-deserialized path - // (via Jackson) and any direct callers (tests, CLI plumbing). - + // constructor so they apply to both JSON deserialization and direct callers. if (timeoutSeconds <= 0) { timeoutSeconds = 10L; } if (apiVersion == null || apiVersion.isBlank()) { apiVersion = DEFAULT_API_VERSION; } + if (uploadMode == null || uploadMode.isBlank()) { + uploadMode = DEFAULT_UPLOAD_MODE; + } + uploadMode = uploadMode.trim().toLowerCase(Locale.ROOT); + if (!DEFAULT_UPLOAD_MODE.equals(uploadMode) && !LEGACY_UPLOAD_MODE.equals(uploadMode)) { + throw new IllegalArgumentException( + "HttpConfig.uploadMode must be 'stream' or 'legacy', got: " + uploadMode); + } + if (DEFAULT_UPLOAD_MODE.equals(uploadMode)) { + if (streamMaxLines <= 0) { + streamMaxLines = DEFAULT_STREAM_MAX_LINES; + } + if (streamMaxBytes <= 0) { + streamMaxBytes = DEFAULT_STREAM_MAX_BYTES; + } + } else { + streamMaxLines = 0; + streamMaxBytes = 0L; + } + // Hard reject blank/missing host with a migration-aware error. // Jackson silently sets unknown fields to null (we disabled // FAIL_ON_UNKNOWN_PROPERTIES in JsonSettings), so a config that @@ -73,11 +105,19 @@ public record HttpConfig( } } + public boolean isStreamMode() { + return DEFAULT_UPLOAD_MODE.equals(uploadMode); + } + + public String streamEndpoint() { + return endpointFor(DEFAULT_UPLOAD_MODE); + } + /** * Build the full upload URL for a given event type. * * @param type the event-type segment, e.g. {@code "init"}, - * {@code "kernel"}, {@code "metric"} — appended after + * {@code "kernel"}, {@code "metric"}; appended after * the {@code /api/{version}/events/} prefix. */ public String endpointFor(String type) { diff --git a/src/main/java/com/gpuflight/agent/config/StreamUploadSettings.java b/src/main/java/com/gpuflight/agent/config/StreamUploadSettings.java new file mode 100644 index 0000000..550dbc9 --- /dev/null +++ b/src/main/java/com/gpuflight/agent/config/StreamUploadSettings.java @@ -0,0 +1,24 @@ +package com.gpuflight.agent.config; + +/** + * Per-tailer batching knobs for stream upload mode. When {@code enabled}, the + * tailer collects lines into a batch and hands the whole batch to + * {@code Publisher.publishStream} - flushing when {@code maxLines} or + * {@code maxBytes} (uncompressed) is reached, or at EOF (no artificial + * latency: the moment the file has no more data, whatever is buffered ships). + * The cursor advances only after a flush is accepted, so a crash or rejected + * batch re-reads from the last accepted offset. + * + *

{@code DISABLED} keeps the original one-publish-per-line flow (legacy + * mode, Kafka, and existing tests). + */ +public record StreamUploadSettings(boolean enabled, int maxLines, long maxBytes) { + + public static final StreamUploadSettings DISABLED = new StreamUploadSettings(false, 0, 0); + + public static StreamUploadSettings from(HttpConfig http) { + return http.isStreamMode() + ? new StreamUploadSettings(true, http.streamMaxLines(), http.streamMaxBytes()) + : DISABLED; + } +} diff --git a/src/main/java/com/gpuflight/agent/model/LogSourceConfig.java b/src/main/java/com/gpuflight/agent/model/LogSourceConfig.java index 833fcc3..50fde06 100644 --- a/src/main/java/com/gpuflight/agent/model/LogSourceConfig.java +++ b/src/main/java/com/gpuflight/agent/model/LogSourceConfig.java @@ -20,6 +20,8 @@ public record LogSourceConfig( ) { public LogSourceConfig { if (folder == null) folder = "."; - if (logTypes == null || logTypes.isEmpty()) logTypes = List.of("device", "scope", "system"); + // "sass" carries the SASS-disassembly / source-content artifacts split + // out of device.log; tail it by default or they miss live upload. + if (logTypes == null || logTypes.isEmpty()) logTypes = List.of("device", "scope", "system", "sass"); } } diff --git a/src/main/java/com/gpuflight/agent/publisher/HttpPublisher.java b/src/main/java/com/gpuflight/agent/publisher/HttpPublisher.java index 42bc29e..d2979ae 100644 --- a/src/main/java/com/gpuflight/agent/publisher/HttpPublisher.java +++ b/src/main/java/com/gpuflight/agent/publisher/HttpPublisher.java @@ -4,11 +4,16 @@ import com.gpuflight.agent.config.JsonSettings; import com.gpuflight.agent.model.LogWrapper; +import java.io.ByteArrayOutputStream; +import java.net.InetAddress; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.List; +import java.util.zip.GZIPOutputStream; public class HttpPublisher implements Publisher { private final HttpConfig config; @@ -25,11 +30,15 @@ public HttpPublisher(HttpConfig config) { @Override public boolean publish(String topic, String key, LogWrapper log) { try { - // Path is structural — assembled inside HttpConfig from + // Path is structural - assembled inside HttpConfig from // hostUrl + apiVersion + event-type segment. See // HttpConfig.endpointFor() for the template. String url = config.endpointFor(log.type()); String message = JsonSettings.MAPPER.writeValueAsString(log); + System.out.println("[agent] HTTP event POST starting: url=" + url + + " topic=" + topic + + " key=" + key + + " type=" + log.type()); var requestBuilder = HttpRequest.newBuilder() .uri(URI.create(url)) @@ -38,23 +47,23 @@ public boolean publish(String topic, String key, LogWrapper log) { .header("X-Key", key) .POST(HttpRequest.BodyPublishers.ofString(message)); - if (config.authToken() != null) { - requestBuilder.header("Authorization", "Bearer " + config.authToken()); - } + addAuthHeader(requestBuilder); - // Blocking send is fine on a virtual thread — the carrier thread parks, not blocks. + // Blocking send is fine on a virtual thread - the carrier thread parks, not blocks. var response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); if (response.statusCode() >= 200 && response.statusCode() <= 299) { + System.out.println("[agent] HTTP event POST accepted: status=" + + response.statusCode() + " type=" + log.type()); return true; } if (response.statusCode() == 402) { - // GPU limit exceeded — permanent failure, do not retry + // GPU limit exceeded - permanent failure, do not retry System.err.println("[GPUFL] ========================================"); System.err.println("[GPUFL] GPU limit exceeded for this workspace."); System.err.println("[GPUFL] " + response.body()); System.err.println("[GPUFL] Upgrade at https://gpuflight.com/pricing"); System.err.println("[GPUFL] ========================================"); - return true; // advance cursor — retrying won't help + return true; // advance cursor - retrying won't help } System.out.println("HTTP Publish failed [" + url + "]: " + response.statusCode() + " - " + response.body()); return false; @@ -67,6 +76,62 @@ public boolean publish(String topic, String key, LogWrapper log) { } } + @Override + public boolean publishStream(String sessionId, List ndjsonLines) { + if (ndjsonLines == null || ndjsonLines.isEmpty()) { + return true; + } + try { + String ndjson = String.join("\n", ndjsonLines) + "\n"; + byte[] body = gzip(ndjson.getBytes(StandardCharsets.UTF_8)); + String url = config.streamEndpoint(); + System.out.println("[agent] HTTP stream POST starting: url=" + url + + " session=" + sessionId + + " lines=" + ndjsonLines.size() + + " gzipBytes=" + body.length); + + var requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Content-Type", "application/x-ndjson") + .header("Content-Encoding", "gzip") + .header("X-GpuFlight-Session-Id", sessionId) + .header("X-GpuFlight-Hostname", InetAddress.getLocalHost().getHostName()) + .POST(HttpRequest.BodyPublishers.ofByteArray(body)); + + addAuthHeader(requestBuilder); + + var response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() >= 200 && response.statusCode() <= 299) { + System.out.println("[agent] HTTP stream POST accepted: status=" + + response.statusCode() + " session=" + sessionId); + return true; + } + System.out.println("HTTP stream publish failed [" + url + "]: " + + response.statusCode() + " - " + response.body()); + return false; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } catch (Exception e) { + System.out.println("HTTP stream connection error: " + e); + return false; + } + } + + private void addAuthHeader(HttpRequest.Builder requestBuilder) { + if (config.authToken() != null && !config.authToken().isBlank()) { + requestBuilder.header("Authorization", "Bearer " + config.authToken()); + } + } + + private static byte[] gzip(byte[] content) throws Exception { + var out = new ByteArrayOutputStream(); + try (var gzip = new GZIPOutputStream(out)) { + gzip.write(content); + } + return out.toByteArray(); + } + @Override public void close() { // Java HttpClient has no explicit close; resources are reclaimed on GC. diff --git a/src/main/java/com/gpuflight/agent/publisher/KafkaPublisher.java b/src/main/java/com/gpuflight/agent/publisher/KafkaPublisher.java index b502278..4c8c8a4 100644 --- a/src/main/java/com/gpuflight/agent/publisher/KafkaPublisher.java +++ b/src/main/java/com/gpuflight/agent/publisher/KafkaPublisher.java @@ -27,7 +27,7 @@ public boolean publish(String topic, String key, LogWrapper log) { try { String message = JsonSettings.MAPPER.writeValueAsString(log.data()); // producer.send() is async and only blocks when the internal buffer is full. - // Blocking on a virtual thread is fine — carrier thread parks, not blocks. + // Blocking on a virtual thread is fine - carrier thread parks, not blocks. producer.send(new ProducerRecord<>(topic, key, message)).get(); return true; } catch (Exception e) { diff --git a/src/main/java/com/gpuflight/agent/publisher/Publisher.java b/src/main/java/com/gpuflight/agent/publisher/Publisher.java index 545d76e..8228d41 100644 --- a/src/main/java/com/gpuflight/agent/publisher/Publisher.java +++ b/src/main/java/com/gpuflight/agent/publisher/Publisher.java @@ -3,8 +3,14 @@ import com.gpuflight.agent.model.LogWrapper; import java.io.Closeable; +import java.util.List; public interface Publisher extends Closeable { /** Returns true if the event was accepted by the backend, false on failure. */ boolean publish(String topic, String key, LogWrapper log); + + /** Returns true if the NDJSON batch was accepted by the backend. */ + default boolean publishStream(String sessionId, List ndjsonLines) { + return false; + } } diff --git a/src/test/java/com/gpuflight/agent/LogTailerTest.java b/src/test/java/com/gpuflight/agent/LogTailerTest.java index 50ce7ce..bdd2a72 100644 --- a/src/test/java/com/gpuflight/agent/LogTailerTest.java +++ b/src/test/java/com/gpuflight/agent/LogTailerTest.java @@ -1,5 +1,6 @@ package com.gpuflight.agent; +import com.gpuflight.agent.config.StreamUploadSettings; import com.gpuflight.agent.model.LogWrapper; import com.gpuflight.agent.publisher.Publisher; import org.junit.jupiter.api.Test; @@ -54,6 +55,26 @@ public boolean publish(String topic, String key, LogWrapper log) { public void close() {} } + static class CapturingStreamPublisher implements Publisher { + final List> batches = new CopyOnWriteArrayList<>(); + final List sessionIds = new CopyOnWriteArrayList<>(); + + @Override + public boolean publish(String topic, String key, LogWrapper log) { + return false; + } + + @Override + public boolean publishStream(String sessionId, List ndjsonLines) { + sessionIds.add(sessionId); + batches.add(List.copyOf(ndjsonLines)); + return true; + } + + @Override + public void close() {} + } + /** gzip src → dest (used to simulate the C++ client compressing a rotated file). */ private static void gzip(Path src, Path dest) throws IOException { try (var in = Files.newInputStream(src); @@ -78,6 +99,28 @@ private static void awaitEvents(List list, int count, long timeoutMs) throws // ---- happy-path: reads existing lines ---- + @Test + void tail_sassChannel_publishes(@TempDir Path tempDir) throws Exception { + // sass.log carries SASS-disassembly / source-content artifacts split + // out of device.log; the agent must tail it like any other channel + // or those artifacts silently miss live upload. + Files.createDirectories(tempDir.resolve("app")); + Files.writeString(tempDir.resolve("app/sass.log"), + "{\"type\":\"cubin_disassembly\"}\n"); + + CursorManager cursorMgr = new CursorManager(tempDir.resolve("cursor.json").toFile()); + CapturingPublisher publisher = new CapturingPublisher(); + LogTailer tailer = new LogTailer(tempDir.toFile(), "app", "sass", "gpu-trace", cursorMgr, new LinkedBlockingQueue<>()); + + Thread t = startTailer(tailer, publisher); + awaitEvents(publisher.events, 1, 3000); + t.interrupt(); + t.join(2000); + + assertEquals(1, publisher.events.size()); + assertEquals("cubin_disassembly", publisher.events.get(0).type()); + } + @Test void tail_readsExistingLinesAndPublishes(@TempDir Path tempDir) throws Exception { // v1.2: log files live under //.log. @@ -192,6 +235,65 @@ void tail_persistsCursorAfterReading(@TempDir Path tempDir) throws Exception { assertTrue(pos.offset() > 0, "Cursor offset should have advanced past the line"); } + @Test + void tail_streamMode_publishesNdjsonBatchAndPersistsCursor(@TempDir Path tempDir) throws Exception { + Files.createDirectories(tempDir.resolve("app")); + Path logFile = tempDir.resolve("app/device.log"); + Files.writeString(logFile, + "{\"type\":\"kernel_event\",\"session_id\":\"app\",\"name\":\"k1\"}\n" + + "{\"type\":\"kernel_event\",\"session_id\":\"app\",\"name\":\"k2\"}\n" + ); + + File cursorFile = tempDir.resolve("cursor.json").toFile(); + CursorManager cursorMgr = new CursorManager(cursorFile); + CapturingStreamPublisher publisher = new CapturingStreamPublisher(); + LogTailer tailer = new LogTailer(tempDir.toFile(), "app", "device", + "gpu-trace", cursorMgr, new LinkedBlockingQueue<>(), null, + new StreamUploadSettings(true, 10, 1_000_000L)); + + Thread t = startTailer(tailer, publisher); + awaitEvents(publisher.batches, 1, 3000); + t.interrupt(); + t.join(2000); + + assertEquals(List.of("app"), publisher.sessionIds); + assertEquals(1, publisher.batches.size()); + assertEquals(2, publisher.batches.get(0).size()); + assertTrue(publisher.batches.get(0).get(0).contains("\"k1\"")); + + CursorPosition pos = new CursorManager(cursorFile).get("app.device"); + assertEquals(0, pos.fileIndex()); + assertTrue(pos.offset() > 0, "Cursor offset should advance after stream batch accept"); + } + + @Test + void tail_streamMode_drainsRotatedGzWhenActiveFileMissing(@TempDir Path tempDir) throws Exception { + Files.createDirectories(tempDir.resolve("app")); + String content = + "{\"type\":\"kernel_event\",\"session_id\":\"app\",\"name\":\"k1\"}\n" + + "{\"type\":\"kernel_event\",\"session_id\":\"app\",\"name\":\"k2\"}\n"; + Path plain = tempDir.resolve("app/device.1.log"); + Files.writeString(plain, content); + Path gz = tempDir.resolve("app/device.1.log.gz"); + gzip(plain, gz); + Files.delete(plain); + + File cursorFile = tempDir.resolve("cursor.json").toFile(); + CapturingStreamPublisher publisher = new CapturingStreamPublisher(); + LogTailer tailer = new LogTailer(tempDir.toFile(), "app", "device", + "gpu-trace", new CursorManager(cursorFile), new LinkedBlockingQueue<>(), null, + new StreamUploadSettings(true, 10, 1_000_000L)); + + Thread t = startTailer(tailer, publisher); + awaitEvents(publisher.batches, 1, 3000); + t.interrupt(); + t.join(2000); + + assertEquals(1, publisher.batches.size()); + assertEquals(2, publisher.batches.get(0).size()); + assertTrue(publisher.batches.get(0).get(1).contains("\"k2\"")); + } + // ---- waits when active file is absent ---- @Test diff --git a/src/test/java/com/gpuflight/agent/MainTest.java b/src/test/java/com/gpuflight/agent/MainTest.java index 3885d7a..891f853 100644 --- a/src/test/java/com/gpuflight/agent/MainTest.java +++ b/src/test/java/com/gpuflight/agent/MainTest.java @@ -97,7 +97,7 @@ void logTypesOrDefault_usesExplicitFolderLogTypes() { @Test void logTypesOrDefault_usesDefaultWhenAbsent() { - assertEquals(List.of("device", "scope", "system"), + assertEquals(List.of("device", "scope", "system", "sass"), Main.logTypesOrDefault(new String[]{}, Collections.emptyMap())); } diff --git a/src/test/java/com/gpuflight/agent/config/HttpConfigUploadModeTest.java b/src/test/java/com/gpuflight/agent/config/HttpConfigUploadModeTest.java new file mode 100644 index 0000000..ced183f --- /dev/null +++ b/src/test/java/com/gpuflight/agent/config/HttpConfigUploadModeTest.java @@ -0,0 +1,52 @@ +package com.gpuflight.agent.config; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** Upload-mode + stream-batch fields on HttpConfig. */ +class HttpConfigUploadModeTest { + + @Test + void defaults_streamModeWithBatchLimits() { + HttpConfig c = new HttpConfig("https://api.gpuflight.com", "v1", null, 10, + null, 0, 0L); + assertEquals("stream", c.uploadMode()); + assertTrue(c.isStreamMode()); + assertEquals(HttpConfig.DEFAULT_STREAM_MAX_LINES, c.streamMaxLines()); + assertEquals(HttpConfig.DEFAULT_STREAM_MAX_BYTES, c.streamMaxBytes()); + assertEquals("https://api.gpuflight.com/api/v1/events/stream", c.streamEndpoint()); + } + + @Test + void legacyFourFieldConstructor_getsStreamDefaults() { + HttpConfig c = new HttpConfig("https://api.gpuflight.com", "v1", null, 10); + assertTrue(c.isStreamMode()); + assertEquals(HttpConfig.DEFAULT_STREAM_MAX_LINES, c.streamMaxLines()); + } + + @Test + void legacyMode_disablesStream() { + HttpConfig c = new HttpConfig("https://api.gpuflight.com", "v1", null, 10, + "legacy", 0, 0L); + assertFalse(c.isStreamMode()); + assertEquals(StreamUploadSettings.DISABLED, StreamUploadSettings.from(c)); + } + + @Test + void streamMode_settingsCarryLimits() { + HttpConfig c = new HttpConfig("https://api.gpuflight.com", "v1", null, 10, + "stream", 250, 1_000_000L); + StreamUploadSettings s = StreamUploadSettings.from(c); + assertTrue(s.enabled()); + assertEquals(250, s.maxLines()); + assertEquals(1_000_000L, s.maxBytes()); + } + + @Test + void unknownUploadMode_rejectedAtConstruction() { + assertThrows(IllegalArgumentException.class, () -> + new HttpConfig("https://api.gpuflight.com", "v1", null, 10, + "carrier-pigeon", 0, 0L)); + } +} diff --git a/src/test/java/com/gpuflight/agent/model/LogSourceConfigTest.java b/src/test/java/com/gpuflight/agent/model/LogSourceConfigTest.java index a959a9e..326e9d6 100644 --- a/src/test/java/com/gpuflight/agent/model/LogSourceConfigTest.java +++ b/src/test/java/com/gpuflight/agent/model/LogSourceConfigTest.java @@ -17,7 +17,7 @@ void nullFolder_defaultsToDot() { @Test void nullLogTypes_defaultsToAllChannels() { LogSourceConfig config = new LogSourceConfig("/logs", null); - assertEquals(List.of("device", "scope", "system"), config.logTypes()); + assertEquals(List.of("device", "scope", "system", "sass"), config.logTypes()); } @Test diff --git a/src/test/java/com/gpuflight/agent/publisher/HttpPublisherTest.java b/src/test/java/com/gpuflight/agent/publisher/HttpPublisherTest.java index a07cd52..ff245a5 100644 --- a/src/test/java/com/gpuflight/agent/publisher/HttpPublisherTest.java +++ b/src/test/java/com/gpuflight/agent/publisher/HttpPublisherTest.java @@ -7,10 +7,14 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import java.util.zip.GZIPInputStream; import static org.junit.jupiter.api.Assertions.*; @@ -21,13 +25,26 @@ class HttpPublisherTest { private final AtomicInteger statusToReturn = new AtomicInteger(200); private final AtomicReference lastBody = new AtomicReference<>(); private final AtomicReference lastAuthHeader = new AtomicReference<>(); + private final AtomicReference lastContentEncoding = new AtomicReference<>(); + private final AtomicReference lastContentType = new AtomicReference<>(); + private final AtomicReference lastPath = new AtomicReference<>(); + private final AtomicReference lastSessionId = new AtomicReference<>(); @BeforeEach void startServer() throws IOException { server = HttpServer.create(new InetSocketAddress(0), 0); server.createContext("/api/", exchange -> { - lastBody.set(new String(exchange.getRequestBody().readAllBytes())); - lastAuthHeader.set(exchange.getRequestHeaders().getFirst("Authorization")); + var headers = exchange.getRequestHeaders(); + byte[] body = exchange.getRequestBody().readAllBytes(); + String encoding = headers.getFirst("Content-Encoding"); + lastBody.set("gzip".equalsIgnoreCase(encoding) + ? gunzip(body) + : new String(body, StandardCharsets.UTF_8)); + lastAuthHeader.set(headers.getFirst("Authorization")); + lastContentEncoding.set(encoding); + lastContentType.set(headers.getFirst("Content-Type")); + lastPath.set(exchange.getRequestURI().getPath()); + lastSessionId.set(headers.getFirst("X-GpuFlight-Session-Id")); exchange.sendResponseHeaders(statusToReturn.get(), 0); exchange.close(); }); @@ -55,6 +72,12 @@ private LogWrapper sampleLog(String type) { return new LogWrapper(System.currentTimeMillis(), "{\"key\":\"val\"}", type, "testhost", "127.0.0.1"); } + private static String gunzip(byte[] body) throws IOException { + try (var gzip = new GZIPInputStream(new ByteArrayInputStream(body))) { + return new String(gzip.readAllBytes(), StandardCharsets.UTF_8); + } + } + @Test void publish_sendsPostRequest() throws InterruptedException { HttpConfig config = new HttpConfig(hostUrl(), "v1", null, 5); @@ -90,6 +113,36 @@ void publish_noToken_doesNotSendAuthHeader() throws InterruptedException { assertNull(lastAuthHeader.get()); } + @Test + void publish_blankToken_doesNotSendAuthHeader() { + HttpConfig config = new HttpConfig(hostUrl(), "v1", "", 5); + HttpPublisher pub = new HttpPublisher(config); + + pub.publish("topic", "system", sampleLog("system_event")); + + assertNull(lastAuthHeader.get()); + } + + @Test + void publishStream_sendsGzippedNdjsonBatch() { + statusToReturn.set(202); + HttpConfig config = new HttpConfig(hostUrl(), "v1", null, 5, + "stream", 100, 1_000_000L); + HttpPublisher pub = new HttpPublisher(config); + + boolean ok = pub.publishStream("session-1", List.of( + "{\"type\":\"kernel_event\",\"session_id\":\"session-1\"}", + "{\"type\":\"scope_event\",\"session_id\":\"session-1\"}")); + + assertTrue(ok); + assertEquals("/api/v1/events/stream", lastPath.get()); + assertEquals("gzip", lastContentEncoding.get()); + assertEquals("application/x-ndjson", lastContentType.get()); + assertEquals("session-1", lastSessionId.get()); + assertTrue(lastBody.get().contains("\"kernel_event\"")); + assertTrue(lastBody.get().endsWith("\n")); + } + @Test void publish_nonSuccessResponse_doesNotThrow() throws InterruptedException { statusToReturn.set(500);