mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +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
|
* Dispatch a single fire of {@code trigger}. {@code event} is the
|
||||||
* source-event context (cron tick metadata, channel message, etc.) — its
|
* source-event context (cron tick metadata, channel message, etc.) —
|
||||||
* top-level fields are exposed to the payload template under
|
* its top-level fields are exposed to the payload template under
|
||||||
* {@code event.*}. Returns {@code null} when the trigger is not
|
* {@code event.*}. Returns a {@link DispatchResult} so the caller
|
||||||
* dispatchable (no published revision, unsupported target type) so the
|
* can distinguish a real fire from a pre-flight skip or a runner
|
||||||
* caller can record the skip without erroring.
|
* 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())) {
|
if (!"workflow".equalsIgnoreCase(trigger.getTargetType())) {
|
||||||
log.warn("Trigger {} target_type {} not supported in v0; skipping fire",
|
log.warn("Trigger {} target_type {} not supported in v0; skipping fire",
|
||||||
trigger.getId(), trigger.getTargetType());
|
trigger.getId(), trigger.getTargetType());
|
||||||
return null;
|
return DispatchResult.skipped(
|
||||||
|
"unsupported target_type: " + trigger.getTargetType());
|
||||||
}
|
}
|
||||||
WorkflowGraphLoader.Loaded loaded = graphLoader.load(trigger.getTargetId());
|
WorkflowGraphLoader.Loaded loaded = graphLoader.load(trigger.getTargetId());
|
||||||
if (loaded.graph() == null) {
|
if (loaded.graph() == null) {
|
||||||
log.info("Trigger {} dispatch skipped: no published revision for workflow {}",
|
log.info("Trigger {} dispatch skipped: no published revision for workflow {}",
|
||||||
trigger.getId(), trigger.getTargetId());
|
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(
|
WorkflowRunRequest req = new WorkflowRunRequest(
|
||||||
trigger.getTargetId(),
|
trigger.getTargetId(),
|
||||||
loaded.revisionId(),
|
loaded.revisionId(),
|
||||||
trigger.getWorkspaceId(),
|
trigger.getWorkspaceId(),
|
||||||
"trigger:" + trigger.getId(),
|
"trigger:" + trigger.getId(),
|
||||||
inputs);
|
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) {
|
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 lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import vip.mate.trigger.dispatch.DispatchResult;
|
||||||
import vip.mate.trigger.dispatch.TriggerDispatcher;
|
import vip.mate.trigger.dispatch.TriggerDispatcher;
|
||||||
import vip.mate.trigger.model.TriggerEntity;
|
import vip.mate.trigger.model.TriggerEntity;
|
||||||
import vip.mate.trigger.model.TriggerEventEntity;
|
import vip.mate.trigger.model.TriggerEventEntity;
|
||||||
@ -118,14 +119,50 @@ public class TriggerEventIngestService {
|
|||||||
if (!rateLimiter.tryAcquire(trigger.getId(), limit, Instant.now())) {
|
if (!rateLimiter.tryAcquire(trigger.getId(), limit, Instant.now())) {
|
||||||
return IngestResult.dropped(trigger.getId(), Reason.RATE_LIMITED);
|
return IngestResult.dropped(trigger.getId(), Reason.RATE_LIMITED);
|
||||||
}
|
}
|
||||||
|
DispatchResult outcome;
|
||||||
try {
|
try {
|
||||||
dispatcher.dispatch(trigger, envelope.data());
|
outcome = dispatcher.dispatch(trigger, envelope.data());
|
||||||
} catch (Exception e) {
|
} 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: {}",
|
log.error("Trigger {} dispatch threw on event ingest: {}",
|
||||||
trigger.getId(), e.getMessage(), e);
|
trigger.getId(), e.getMessage(), e);
|
||||||
|
persistDispatchOutcome(trigger, DispatchResult.failed("dispatch threw: " + e.getMessage()));
|
||||||
return IngestResult.dropped(trigger.getId(), Reason.DISPATCH_ERROR);
|
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) {
|
private boolean recordDedupRow(TriggerEntity trigger, TriggerEventEnvelope envelope) {
|
||||||
@ -174,7 +211,11 @@ public class TriggerEventIngestService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public enum Reason {
|
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) {
|
public record IngestResult(long triggerId, boolean fired, Reason droppedReason) {
|
||||||
|
|||||||
@ -56,6 +56,16 @@ public class TriggerEntity {
|
|||||||
@TableField(value = "last_fired_at", updateStrategy = FieldStrategy.ALWAYS)
|
@TableField(value = "last_fired_at", updateStrategy = FieldStrategy.ALWAYS)
|
||||||
private LocalDateTime lastFiredAt;
|
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. */
|
/** Lamport counter — bump on every cron expression / payload template change. */
|
||||||
private Long patternVersion;
|
private Long patternVersion;
|
||||||
|
|
||||||
|
|||||||
@ -230,14 +230,31 @@ public class TriggerScheduler {
|
|||||||
return; // peer is firing
|
return; // peer is firing
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
dispatcher.dispatch(live, Map.of("firedAt", Instant.now().toString()));
|
vip.mate.trigger.dispatch.DispatchResult outcome =
|
||||||
// Bump fire bookkeeping post-dispatch so a slow workflow does not
|
dispatcher.dispatch(live, Map.of("firedAt", Instant.now().toString()));
|
||||||
// delay subsequent ticks; this row update is best-effort.
|
// Bookkeeping is honest: only a real fire bumps fireCount /
|
||||||
live.setFireCount((live.getFireCount() == null ? 0L : live.getFireCount()) + 1);
|
// lastFiredAt. Skipped (no published revision, etc.) and failed
|
||||||
live.setLastFiredAt(LocalDateTime.now());
|
// 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);
|
triggerMapper.updateById(live);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("[TriggerScheduler] trigger {} fire failed: {}", triggerId, e.getMessage(), 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 {
|
} finally {
|
||||||
lock.get().unlock();
|
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