mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(trigger): honest fireCount + last_error via DispatchResult
This commit is contained in:
parent
dac774f15e
commit
2a803d2904
@ -0,0 +1,44 @@
|
||||
package vip.mate.trigger.dispatch;
|
||||
|
||||
/**
|
||||
* Outcome of a trigger fire. The dispatcher used to return either a
|
||||
* {@code WorkflowRunResult} or {@code null}, which led the ingest and
|
||||
* scheduler paths to treat null as "fired" — incrementing
|
||||
* {@code fireCount} / {@code lastFiredAt} even when the dispatch was
|
||||
* a no-op or an error. This record makes the outcome explicit so each
|
||||
* caller can update bookkeeping honestly.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link Kind#FIRED} — a workflow run row was actually created.
|
||||
* {@link #runId()} carries its id; {@link #reason()} is null.</li>
|
||||
* <li>{@link Kind#SKIPPED} — pre-flight rejected the dispatch
|
||||
* (no published revision, unsupported target type, payload render
|
||||
* failed). {@link #reason()} carries the human-readable cause;
|
||||
* {@link #runId()} is null.</li>
|
||||
* <li>{@link Kind#FAILED} — runner threw / persisted with an error
|
||||
* state. {@link #reason()} is the failure message; {@link #runId()}
|
||||
* may be set if a row was created before the failure.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public record DispatchResult(Kind kind, Long runId, String reason) {
|
||||
|
||||
public enum Kind { FIRED, SKIPPED, FAILED }
|
||||
|
||||
public boolean fired() { return kind == Kind.FIRED; }
|
||||
|
||||
public static DispatchResult fired(Long runId) {
|
||||
return new DispatchResult(Kind.FIRED, runId, null);
|
||||
}
|
||||
|
||||
public static DispatchResult skipped(String reason) {
|
||||
return new DispatchResult(Kind.SKIPPED, null, reason);
|
||||
}
|
||||
|
||||
public static DispatchResult failed(String message) {
|
||||
return new DispatchResult(Kind.FAILED, null, message);
|
||||
}
|
||||
|
||||
public static DispatchResult failed(Long runId, String message) {
|
||||
return new DispatchResult(Kind.FAILED, runId, message);
|
||||
}
|
||||
}
|
||||
@ -42,33 +42,60 @@ public class TriggerDispatcher {
|
||||
|
||||
/**
|
||||
* Dispatch a single fire of {@code trigger}. {@code event} is the
|
||||
* source-event context (cron tick metadata, channel message, etc.) — its
|
||||
* top-level fields are exposed to the payload template under
|
||||
* {@code event.*}. Returns {@code null} when the trigger is not
|
||||
* dispatchable (no published revision, unsupported target type) so the
|
||||
* caller can record the skip without erroring.
|
||||
* source-event context (cron tick metadata, channel message, etc.) —
|
||||
* its top-level fields are exposed to the payload template under
|
||||
* {@code event.*}. Returns a {@link DispatchResult} so the caller
|
||||
* can distinguish a real fire from a pre-flight skip or a runner
|
||||
* failure and update {@code fireCount} / {@code lastFiredAt} /
|
||||
* {@code lastError} accordingly.
|
||||
*/
|
||||
public WorkflowRunResult dispatch(TriggerEntity trigger, Map<String, Object> event) {
|
||||
public DispatchResult dispatch(TriggerEntity trigger, Map<String, Object> event) {
|
||||
if (!"workflow".equalsIgnoreCase(trigger.getTargetType())) {
|
||||
log.warn("Trigger {} target_type {} not supported in v0; skipping fire",
|
||||
trigger.getId(), trigger.getTargetType());
|
||||
return null;
|
||||
return DispatchResult.skipped(
|
||||
"unsupported target_type: " + trigger.getTargetType());
|
||||
}
|
||||
WorkflowGraphLoader.Loaded loaded = graphLoader.load(trigger.getTargetId());
|
||||
if (loaded.graph() == null) {
|
||||
log.info("Trigger {} dispatch skipped: no published revision for workflow {}",
|
||||
trigger.getId(), trigger.getTargetId());
|
||||
return null;
|
||||
return DispatchResult.skipped(
|
||||
"no published revision for workflow " + trigger.getTargetId());
|
||||
}
|
||||
|
||||
Map<String, Object> inputs = renderInputs(trigger, event);
|
||||
Map<String, Object> inputs;
|
||||
try {
|
||||
inputs = renderInputs(trigger, event);
|
||||
} catch (Exception e) {
|
||||
return DispatchResult.failed("payload render failed: " + e.getMessage());
|
||||
}
|
||||
WorkflowRunRequest req = new WorkflowRunRequest(
|
||||
trigger.getTargetId(),
|
||||
loaded.revisionId(),
|
||||
trigger.getWorkspaceId(),
|
||||
"trigger:" + trigger.getId(),
|
||||
inputs);
|
||||
return runner.run(loaded.graph(), req);
|
||||
try {
|
||||
WorkflowRunResult result = runner.run(loaded.graph(), req);
|
||||
if (result == null) {
|
||||
return DispatchResult.failed("runner returned null result");
|
||||
}
|
||||
// The runner's state taxonomy: succeeded / paused / running /
|
||||
// failed. Anything other than failed counts as a real fire — a
|
||||
// paused run still consumed the trigger and produced a
|
||||
// workflow_run row that the operator can resume.
|
||||
if ("failed".equalsIgnoreCase(result.state())) {
|
||||
return DispatchResult.failed(result.runId(),
|
||||
"workflow run failed: "
|
||||
+ (result.errorMessage() == null ? "(no message)" : result.errorMessage()));
|
||||
}
|
||||
return DispatchResult.fired(result.runId());
|
||||
} catch (Exception e) {
|
||||
log.error("Trigger {} dispatch failed for workflow {}: {}",
|
||||
trigger.getId(), trigger.getTargetId(), e.getMessage(), e);
|
||||
return DispatchResult.failed("runner threw: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> renderInputs(TriggerEntity trigger, Map<String, Object> event) {
|
||||
|
||||
@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.trigger.dispatch.DispatchResult;
|
||||
import vip.mate.trigger.dispatch.TriggerDispatcher;
|
||||
import vip.mate.trigger.model.TriggerEntity;
|
||||
import vip.mate.trigger.model.TriggerEventEntity;
|
||||
@ -118,14 +119,50 @@ public class TriggerEventIngestService {
|
||||
if (!rateLimiter.tryAcquire(trigger.getId(), limit, Instant.now())) {
|
||||
return IngestResult.dropped(trigger.getId(), Reason.RATE_LIMITED);
|
||||
}
|
||||
DispatchResult outcome;
|
||||
try {
|
||||
dispatcher.dispatch(trigger, envelope.data());
|
||||
outcome = dispatcher.dispatch(trigger, envelope.data());
|
||||
} catch (Exception e) {
|
||||
// Belt-and-suspenders — the dispatcher already wraps its own
|
||||
// exceptions, but if anything escapes we mark it as DISPATCH_ERROR
|
||||
// and persist last_error so the UI surfaces *why*.
|
||||
log.error("Trigger {} dispatch threw on event ingest: {}",
|
||||
trigger.getId(), e.getMessage(), e);
|
||||
persistDispatchOutcome(trigger, DispatchResult.failed("dispatch threw: " + e.getMessage()));
|
||||
return IngestResult.dropped(trigger.getId(), Reason.DISPATCH_ERROR);
|
||||
}
|
||||
return IngestResult.fired(trigger.getId());
|
||||
persistDispatchOutcome(trigger, outcome);
|
||||
return switch (outcome.kind()) {
|
||||
case FIRED -> IngestResult.fired(trigger.getId());
|
||||
case SKIPPED -> IngestResult.dropped(trigger.getId(), Reason.DISPATCH_SKIPPED);
|
||||
case FAILED -> IngestResult.dropped(trigger.getId(), Reason.DISPATCH_ERROR);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the trigger row's bookkeeping based on the dispatch outcome.
|
||||
* Only FIRED bumps {@code fireCount} and {@code lastFiredAt} — SKIPPED
|
||||
* and FAILED outcomes were treated as fires before, which made the
|
||||
* stats lie. {@code lastDispatchedAt} stamps every attempt so the UI
|
||||
* can distinguish "never attempted" from "attempted but skipped".
|
||||
*/
|
||||
private void persistDispatchOutcome(TriggerEntity trigger, DispatchResult outcome) {
|
||||
try {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
trigger.setLastDispatchedAt(now);
|
||||
if (outcome.fired()) {
|
||||
trigger.setFireCount(
|
||||
(trigger.getFireCount() == null ? 0L : trigger.getFireCount()) + 1);
|
||||
trigger.setLastFiredAt(now);
|
||||
trigger.setLastError(null);
|
||||
} else {
|
||||
trigger.setLastError(outcome.reason());
|
||||
}
|
||||
triggerMapper.updateById(trigger);
|
||||
} catch (Exception e) {
|
||||
// Best-effort bookkeeping — never let a stats write fail ingest.
|
||||
log.warn("Trigger {} bookkeeping update failed: {}", trigger.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean recordDedupRow(TriggerEntity trigger, TriggerEventEnvelope envelope) {
|
||||
@ -174,7 +211,11 @@ public class TriggerEventIngestService {
|
||||
}
|
||||
|
||||
public enum Reason {
|
||||
PATTERN_MISMATCH, BOT_SELF, DUPLICATE, RATE_LIMITED, EXHAUSTED, DISPATCH_ERROR
|
||||
PATTERN_MISMATCH, BOT_SELF, DUPLICATE, RATE_LIMITED, EXHAUSTED,
|
||||
/** Dispatcher returned SKIPPED — pre-flight rejected (no published revision, etc.). */
|
||||
DISPATCH_SKIPPED,
|
||||
/** Dispatcher returned FAILED — runner threw or workflow run ended in failed state. */
|
||||
DISPATCH_ERROR
|
||||
}
|
||||
|
||||
public record IngestResult(long triggerId, boolean fired, Reason droppedReason) {
|
||||
|
||||
@ -56,6 +56,16 @@ public class TriggerEntity {
|
||||
@TableField(value = "last_fired_at", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private LocalDateTime lastFiredAt;
|
||||
|
||||
/** Most recent dispatch outcome message; null on success, populated on
|
||||
* SKIPPED / FAILED so the UI can show why a trigger has stopped firing. */
|
||||
@TableField(value = "last_error", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String lastError;
|
||||
|
||||
/** Stamp of the last dispatch attempt regardless of outcome — used to
|
||||
* distinguish "never attempted" from "attempted but skipped". */
|
||||
@TableField(value = "last_dispatched_at", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private LocalDateTime lastDispatchedAt;
|
||||
|
||||
/** Lamport counter — bump on every cron expression / payload template change. */
|
||||
private Long patternVersion;
|
||||
|
||||
|
||||
@ -230,14 +230,31 @@ public class TriggerScheduler {
|
||||
return; // peer is firing
|
||||
}
|
||||
try {
|
||||
dispatcher.dispatch(live, Map.of("firedAt", Instant.now().toString()));
|
||||
// Bump fire bookkeeping post-dispatch so a slow workflow does not
|
||||
// delay subsequent ticks; this row update is best-effort.
|
||||
live.setFireCount((live.getFireCount() == null ? 0L : live.getFireCount()) + 1);
|
||||
live.setLastFiredAt(LocalDateTime.now());
|
||||
vip.mate.trigger.dispatch.DispatchResult outcome =
|
||||
dispatcher.dispatch(live, Map.of("firedAt", Instant.now().toString()));
|
||||
// Bookkeeping is honest: only a real fire bumps fireCount /
|
||||
// lastFiredAt. Skipped (no published revision, etc.) and failed
|
||||
// outcomes still record lastDispatchedAt + lastError so the UI
|
||||
// can show why a cron stopped firing.
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
live.setLastDispatchedAt(now);
|
||||
if (outcome != null && outcome.fired()) {
|
||||
live.setFireCount((live.getFireCount() == null ? 0L : live.getFireCount()) + 1);
|
||||
live.setLastFiredAt(now);
|
||||
live.setLastError(null);
|
||||
} else {
|
||||
live.setLastError(outcome == null ? "dispatcher returned null" : outcome.reason());
|
||||
}
|
||||
triggerMapper.updateById(live);
|
||||
} catch (Exception e) {
|
||||
log.error("[TriggerScheduler] trigger {} fire failed: {}", triggerId, e.getMessage(), e);
|
||||
try {
|
||||
live.setLastDispatchedAt(LocalDateTime.now());
|
||||
live.setLastError("scheduler threw: " + e.getMessage());
|
||||
triggerMapper.updateById(live);
|
||||
} catch (Exception ignored) {
|
||||
// Best-effort — don't let a bookkeeping failure mask the dispatch failure.
|
||||
}
|
||||
} finally {
|
||||
lock.get().unlock();
|
||||
}
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
-- Persist the most recent dispatch outcome message on the trigger row
|
||||
-- itself so the UI can show *why* a trigger has stopped firing without
|
||||
-- joining trigger_event for forensics. The dispatcher writes a non-null
|
||||
-- message on SKIPPED / FAILED outcomes and clears it on FIRED.
|
||||
|
||||
ALTER TABLE mate_trigger ADD COLUMN IF NOT EXISTS last_error VARCHAR(2048);
|
||||
ALTER TABLE mate_trigger ADD COLUMN IF NOT EXISTS last_dispatched_at TIMESTAMP;
|
||||
@ -0,0 +1,29 @@
|
||||
-- See the H2 file for context. MySQL 8.0 doesn't support
|
||||
-- `ADD COLUMN IF NOT EXISTS`, so the existence check goes through
|
||||
-- INFORMATION_SCHEMA + a prepared statement.
|
||||
|
||||
SET @col_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'mate_trigger'
|
||||
AND COLUMN_NAME = 'last_error'
|
||||
);
|
||||
SET @ddl := IF(@col_exists = 0,
|
||||
'ALTER TABLE mate_trigger ADD COLUMN last_error VARCHAR(2048)',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @col_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'mate_trigger'
|
||||
AND COLUMN_NAME = 'last_dispatched_at'
|
||||
);
|
||||
SET @ddl := IF(@col_exists = 0,
|
||||
'ALTER TABLE mate_trigger ADD COLUMN last_dispatched_at TIMESTAMP NULL',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
Loading…
Reference in New Issue
Block a user