Merge pull request #2754 from pikasTech/fix/2753-agent-observer-retention-window
Pipelines as Code CI / hwlab-nc01-v03-ci-poll-2c7e867c74d3b2ac0497c9d11f79a8fc806cb246 Success

修复 Agent observer Kafka retention 到 live 交接
This commit is contained in:
Lyon
2026-07-22 05:34:33 +08:00
committed by GitHub
4 changed files with 106 additions and 7 deletions
+73 -1
View File
@@ -6,7 +6,7 @@ import { test } from "bun:test";
import { createCloudApiBunServer } from "./bun-server.ts";
import { buildCloudApiReadiness } from "./health-contract.ts";
import { decodeCanonicalAgentRunKafkaMessage, kafkaDnsLookup, kafkaEventBridgeConfig, kafkaMessageKeyCanMatchPartitionKey, kafkaPartitionForKey, projectAgentRunKafkaEventToHwlabEvent, publishAgentRunKafkaMessageLive, relayHwlabKafkaOutboxOnce, startHwlabKafkaEventBridge } from "./kafka-event-bridge.ts";
import { decodeCanonicalAgentRunKafkaMessage, kafkaDnsLookup, kafkaEventBridgeConfig, kafkaMessageKeyCanMatchPartitionKey, kafkaPartitionForKey, projectAgentRunKafkaEventToHwlabEvent, publishAgentRunKafkaMessageLive, queryKafkaEventStream, relayHwlabKafkaOutboxOnce, startHwlabKafkaEventBridge } from "./kafka-event-bridge.ts";
test("Workbench refresh replay resolves the same Kafka partition as the session-keyed producer", () => {
const offsets = [
@@ -30,6 +30,78 @@ test("Workbench refresh replay rejects foreign keyed values before JSON parsing"
assert.equal(kafkaMessageKeyCanMatchPartitionKey("ses_target", null), true);
});
test("Agent observer retains a bounded latest window while scanning through the Kafka barrier", async () => {
const messages = Array.from({ length: 5 }, (_, offset) => ({
offset: String(offset),
key: Buffer.from(`run-${offset}`),
value: Buffer.from(JSON.stringify({ schema: "hwlab.event.v1", eventId: `event-${offset}` }))
}));
const queried = await queryKafkaEventStream({
env: { HWLAB_KAFKA_BOOTSTRAP_SERVERS: "kafka.test:9092", HWLAB_KAFKA_CLIENT_ID: "hwlab-test" },
topic: "hwlab.event.v1",
limit: 2,
scanLimit: 10,
retainLatestMatches: true,
groupIdPrefix: "hwlab-agent-observer-test",
kafkaFactory: kafkaQueryTestFactory(messages)
});
assert.equal(queried.completionReason, "end-offset");
assert.equal(queried.completion.complete, true);
assert.equal(queried.scannedCount, 5);
assert.equal(queried.matchedCount, 2);
assert.equal(queried.totalMatchedCount, 5);
assert.equal(queried.droppedMatchedCount, 3);
assert.equal(queried.retainLatestMatches, true);
assert.deepEqual(queried.events.map((event) => event.offset), ["3", "4"]);
});
test("Workbench Kafka query keeps the existing stop-at-match-limit behavior", async () => {
const messages = Array.from({ length: 5 }, (_, offset) => ({
offset: String(offset),
value: Buffer.from(JSON.stringify({ schema: "hwlab.event.v1", eventId: `event-${offset}` }))
}));
const queried = await queryKafkaEventStream({
env: { HWLAB_KAFKA_BOOTSTRAP_SERVERS: "kafka.test:9092", HWLAB_KAFKA_CLIENT_ID: "hwlab-test" },
topic: "hwlab.event.v1",
limit: 2,
scanLimit: 10,
groupIdPrefix: "hwlab-workbench-query-test",
kafkaFactory: kafkaQueryTestFactory(messages)
});
assert.equal(queried.completionReason, "limit");
assert.equal(queried.completion.complete, false);
assert.equal(queried.scannedCount, 2);
assert.equal(queried.totalMatchedCount, 2);
assert.equal(queried.droppedMatchedCount, 0);
assert.equal(queried.retainLatestMatches, false);
assert.deepEqual(queried.events.map((event) => event.offset), ["0", "1"]);
});
function kafkaQueryTestFactory(messages) {
return () => ({
admin() {
return {
async connect() {},
async fetchTopicOffsets() { return [{ partition: 0, low: "0", high: String(messages.length) }]; },
async disconnect() {}
};
},
consumer() {
return {
async connect() {},
async subscribe() {},
async run({ eachBatch }) {
await eachBatch({ batch: { topic: "hwlab.event.v1", partition: 0, messages } });
},
async stop() {},
async disconnect() {}
};
}
});
}
test("Workbench session index uses explicit budgets and defaults missing non-core values with warnings", () => {
const explicit = kafkaEventBridgeConfig({
...LIVE_REFRESH_ENV,
+30 -6
View File
@@ -1021,7 +1021,7 @@ function requireKafkaProjectorStore(runtimeStore, capabilities = {}) {
if (missing.length > 0) throw contractError("hwlab_kafka_projector_store_invalid", `Kafka durable capabilities require a runtime store: ${missing.join(", ")}`);
}
export async function queryKafkaEventStream({ env = process.env, stream = "hwlab", topic = null, traceId = null, sessionId = null, runId = null, commandId = null, partitionKey = null, limit = DEFAULT_QUERY_LIMIT, scanLimit = null, timeoutMs = DEFAULT_QUERY_TIMEOUT_MS, fromBeginning = true, groupIdPrefix = null, signal = null, kafkaFactory = defaultKafkaFactory } = {}) {
export async function queryKafkaEventStream({ env = process.env, stream = "hwlab", topic = null, traceId = null, sessionId = null, runId = null, commandId = null, partitionKey = null, limit = DEFAULT_QUERY_LIMIT, scanLimit = null, timeoutMs = DEFAULT_QUERY_TIMEOUT_MS, fromBeginning = true, groupIdPrefix = null, retainLatestMatches = false, signal = null, kafkaFactory = defaultKafkaFactory } = {}) {
const queryStartedAtMs = Date.now();
const timing = {
endOffsetSnapshotMs: 0,
@@ -1036,6 +1036,7 @@ export async function queryKafkaEventStream({ env = process.env, stream = "hwlab
const clientId = stringValue(env.HWLAB_KAFKA_CLIENT_ID) || DEFAULT_CLIENT_ID;
const resolvedTopic = stringValue(topic) || kafkaTopicForStream(stream, env);
const maxEvents = Math.max(1, integerValue(limit) || DEFAULT_QUERY_LIMIT);
const keepLatestMatches = retainLatestMatches === true;
const resolvedPartitionKey = stringValue(partitionKey);
const maxScannedRecords = scanLimit === null || scanLimit === undefined
? null
@@ -1067,6 +1068,9 @@ export async function queryKafkaEventStream({ env = process.env, stream = "hwlab
maxWaitTimeInMs: 100
});
const events = [];
let retainedEventCursor = 0;
let totalMatchedCount = 0;
let droppedMatchedCount = 0;
const firstScannedOffsetByPartition = new Map();
const lastScannedOffsetByPartition = new Map();
let scannedCount = 0;
@@ -1167,7 +1171,7 @@ export async function queryKafkaEventStream({ env = process.env, stream = "hwlab
parsedCount += 1;
if (!eventMatchesFilters(value, { traceId, sessionId, runId, commandId })) filterRejectedCount += 1;
else {
events.push({
retainMatchedEvent({
topic: messageTopic,
partition,
offset: message.offset,
@@ -1184,7 +1188,7 @@ export async function queryKafkaEventStream({ env = process.env, stream = "hwlab
finish(verifiedStartPartitions.size === endOffsetByPartition.size ? "end-offset" : "retention-start-unverified");
return;
}
if (events.length >= maxEvents) {
if (!keepLatestMatches && events.length >= maxEvents) {
finish("limit");
return;
}
@@ -1210,18 +1214,37 @@ export async function queryKafkaEventStream({ env = process.env, stream = "hwlab
}
return kafkaQueryResult();
function retainMatchedEvent(event) {
totalMatchedCount += 1;
if (!keepLatestMatches || events.length < maxEvents) {
events.push(event);
return;
}
events[retainedEventCursor] = event;
retainedEventCursor = (retainedEventCursor + 1) % maxEvents;
droppedMatchedCount += 1;
}
function retainedEvents() {
if (!keepLatestMatches || droppedMatchedCount === 0 || retainedEventCursor === 0) return events;
return [...events.slice(retainedEventCursor), ...events.slice(0, retainedEventCursor)];
}
function kafkaQueryResult() {
const retentionStartVerified = endOffsetSnapshot.available && verifiedStartPartitions.size === endOffsetByPartition.size;
const complete = completionReason === "end-offset" && retentionStartVerified;
const resultEvents = retainedEvents();
return {
ok: true,
stream,
topic: resolvedTopic,
groupId,
count: events.length,
count: resultEvents.length,
scannedCount,
parsedCount,
matchedCount: events.length,
matchedCount: resultEvents.length,
totalMatchedCount,
droppedMatchedCount,
invalidJsonCount,
filterRejectedCount,
keyRejectedCount,
@@ -1248,6 +1271,7 @@ export async function queryKafkaEventStream({ env = process.env, stream = "hwlab
limit: maxEvents,
scanLimit: maxScannedRecords,
timeoutMs: budgetMs,
retainLatestMatches: keepLatestMatches,
filters: compactObject({ traceId, sessionId, runId, commandId }),
partitionKeyScoped: targetPartition !== null,
targetPartition,
@@ -1256,7 +1280,7 @@ export async function queryKafkaEventStream({ env = process.env, stream = "hwlab
totalMs: Date.now() - queryStartedAtMs,
valuesPrinted: false
},
events,
events: resultEvents,
valuesPrinted: false
};
}
@@ -87,6 +87,7 @@ export async function handleAgentObserverHttp(request, response, options = {}) {
timeoutMs: policy.retentionTimeoutMs,
groupIdPrefix: policy.retentionGroupPrefix,
fromBeginning: true,
retainLatestMatches: true,
signal
}),
deliverEvent: (envelope) => enqueue("hwlab.event.v1", envelope),
@@ -406,6 +406,8 @@ function boundedQueryDiagnostics(result) {
scannedCount: numericValue(result?.scannedCount),
parsedCount: numericValue(result?.parsedCount),
matchedCount: numericValue(result?.matchedCount),
totalMatchedCount: numericValue(result?.totalMatchedCount),
droppedMatchedCount: numericValue(result?.droppedMatchedCount),
filterRejectedCount: numericValue(result?.filterRejectedCount),
keyRejectedCount: numericValue(result?.keyRejectedCount),
partitionKeyScoped: result?.partitionKeyScoped === true,