diff --git a/mateclaw-server/src/main/java/vip/mate/trigger/ingest/TriggerEventIngestService.java b/mateclaw-server/src/main/java/vip/mate/trigger/ingest/TriggerEventIngestService.java
index 5ac67cd6..e5ab9bf9 100644
--- a/mateclaw-server/src/main/java/vip/mate/trigger/ingest/TriggerEventIngestService.java
+++ b/mateclaw-server/src/main/java/vip/mate/trigger/ingest/TriggerEventIngestService.java
@@ -2,8 +2,11 @@ package vip.mate.trigger.ingest;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
+import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import vip.mate.trigger.dispatch.DispatchResult;
import vip.mate.trigger.dispatch.TriggerDispatcher;
@@ -59,6 +62,26 @@ public class TriggerEventIngestService {
private final TriggerPatternMatcher patternMatcher;
private final TriggerRateLimiter rateLimiter = new TriggerRateLimiter();
+ /** When true (production default), {@code dispatcher.dispatch} runs on
+ * a worker thread so the caller (webhook / scheduler / runner) returns
+ * quickly. When false, ingest runs the workflow inline on the caller
+ * thread; tests pin to false so they can assert against downstream
+ * workflow state immediately after {@code ingest()} returns. */
+ @Value("${mateclaw.workflow.trigger.async-dispatch:true}")
+ private boolean asyncDispatch;
+
+ @Value("${mateclaw.workflow.trigger.dispatch-pool-size:8}")
+ private int dispatchPoolSize;
+
+ @Value("${mateclaw.workflow.trigger.dispatch-queue-capacity:256}")
+ private int dispatchQueueCapacity;
+
+ /** Lazy-built bounded thread pool used when {@link #asyncDispatch} is
+ * true. CallerRunsPolicy is the back-pressure: when the queue is full
+ * the calling thread runs the dispatch itself, which guarantees no
+ * silent drop while still capping in-flight work. */
+ private volatile java.util.concurrent.ThreadPoolExecutor dispatchExecutor;
+
public TriggerEventIngestService(TriggerMapper triggerMapper,
TriggerEventMapper eventMapper,
TriggerDispatcher dispatcher,
@@ -73,6 +96,44 @@ public class TriggerEventIngestService {
this.patternMatcher = patternMatcher;
}
+ private java.util.concurrent.ThreadPoolExecutor dispatchExecutor() {
+ java.util.concurrent.ThreadPoolExecutor local = dispatchExecutor;
+ if (local != null) return local;
+ synchronized (this) {
+ if (dispatchExecutor == null) {
+ int size = Math.max(1, dispatchPoolSize);
+ int cap = Math.max(1, dispatchQueueCapacity);
+ dispatchExecutor = new java.util.concurrent.ThreadPoolExecutor(
+ size, size,
+ 60L, java.util.concurrent.TimeUnit.SECONDS,
+ new java.util.concurrent.LinkedBlockingQueue<>(cap),
+ r -> {
+ Thread t = new Thread(r, "trigger-dispatch-" + System.currentTimeMillis());
+ t.setDaemon(true);
+ return t;
+ },
+ new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy());
+ }
+ return dispatchExecutor;
+ }
+ }
+
+ @PreDestroy
+ void shutdownDispatchExecutor() {
+ java.util.concurrent.ThreadPoolExecutor local = dispatchExecutor;
+ if (local != null) {
+ local.shutdown();
+ try {
+ if (!local.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)) {
+ local.shutdownNow();
+ }
+ } catch (InterruptedException e) {
+ local.shutdownNow();
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
/**
* Process one envelope through the pipeline. Returns a result per
* candidate trigger so callers can surface a partial-accept summary.
@@ -119,19 +180,29 @@ public class TriggerEventIngestService {
if (!rateLimiter.tryAcquire(trigger.getId(), limit, Instant.now())) {
return IngestResult.dropped(trigger.getId(), Reason.RATE_LIMITED);
}
- DispatchResult outcome;
- try {
- 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);
+ if (asyncDispatch) {
+ // Async path — submit dispatch to the bounded pool so the
+ // caller (webhook / scheduler / runner thread) returns
+ // quickly. Bookkeeping happens inside the worker, so
+ // last_error / fireCount stay accurate. The IngestResult
+ // signals "accepted, fanning out" rather than "ran to
+ // completion"; that's the honest contract for an async
+ // pipeline. CallerRunsPolicy on the executor means we
+ // self-throttle instead of dropping under back-pressure.
+ try {
+ dispatchExecutor().execute(() -> runDispatchAndPersist(trigger, envelope));
+ } catch (Exception e) {
+ log.error("Trigger {} dispatch submit failed: {}",
+ trigger.getId(), e.getMessage(), e);
+ persistDispatchOutcome(trigger,
+ DispatchResult.failed("dispatch submit failed: " + e.getMessage()));
+ return IngestResult.dropped(trigger.getId(), Reason.DISPATCH_ERROR);
+ }
+ return IngestResult.fired(trigger.getId());
}
- persistDispatchOutcome(trigger, outcome);
+ // Synchronous path — used by tests and any deployment that
+ // explicitly opts out via mateclaw.workflow.trigger.async-dispatch=false.
+ DispatchResult outcome = runDispatchAndPersist(trigger, envelope);
return switch (outcome.kind()) {
case FIRED -> IngestResult.fired(trigger.getId());
case SKIPPED -> IngestResult.dropped(trigger.getId(), Reason.DISPATCH_SKIPPED);
@@ -139,6 +210,22 @@ public class TriggerEventIngestService {
};
}
+ /** Runs dispatch + bookkeeping on whatever thread invokes it (the
+ * caller in sync mode, a worker in async mode). Returns the
+ * outcome so sync callers can map it back to an IngestResult. */
+ private DispatchResult runDispatchAndPersist(TriggerEntity trigger, TriggerEventEnvelope envelope) {
+ DispatchResult outcome;
+ try {
+ outcome = dispatcher.dispatch(trigger, envelope.data());
+ } catch (Exception e) {
+ log.error("Trigger {} dispatch threw on event ingest: {}",
+ trigger.getId(), e.getMessage(), e);
+ outcome = DispatchResult.failed("dispatch threw: " + e.getMessage());
+ }
+ persistDispatchOutcome(trigger, outcome);
+ return outcome;
+ }
+
/**
* Update the trigger row's bookkeeping based on the dispatch outcome.
* Only FIRED bumps {@code fireCount} and {@code lastFiredAt} — SKIPPED
@@ -210,6 +297,28 @@ public class TriggerEventIngestService {
LocalDateTime.ofInstant(Instant.now(), ZoneOffset.systemDefault())));
}
+ /**
+ * Periodic sweep of expired {@code mate_trigger_event} dedup rows.
+ * Default cadence is every 5 minutes, tunable via
+ * {@code mateclaw.workflow.trigger.dedup-sweep-interval-ms}. The
+ * initial delay matches the cadence so a JVM that just started doesn't
+ * race {@code recordDedupRow} for the same window.
+ */
+ @Scheduled(
+ fixedDelayString = "${mateclaw.workflow.trigger.dedup-sweep-interval-ms:300000}",
+ initialDelayString = "${mateclaw.workflow.trigger.dedup-sweep-initial-delay-ms:300000}")
+ public void scheduledSweepExpired() {
+ try {
+ int dropped = sweepExpired();
+ if (dropped > 0) {
+ log.info("[TriggerIngest] swept {} expired dedup rows", dropped);
+ }
+ } catch (Exception e) {
+ // Best-effort — never let the sweep crash the scheduler thread.
+ log.warn("[TriggerIngest] dedup sweep failed: {}", e.getMessage());
+ }
+ }
+
public enum Reason {
PATTERN_MISMATCH, BOT_SELF, DUPLICATE, RATE_LIMITED, EXHAUSTED,
/** Dispatcher returned SKIPPED — pre-flight rejected (no published revision, etc.). */
diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/runtime/PayloadStore.java b/mateclaw-server/src/main/java/vip/mate/workflow/runtime/PayloadStore.java
index 31f6ed6e..e657b543 100644
--- a/mateclaw-server/src/main/java/vip/mate/workflow/runtime/PayloadStore.java
+++ b/mateclaw-server/src/main/java/vip/mate/workflow/runtime/PayloadStore.java
@@ -2,7 +2,9 @@ package vip.mate.workflow.runtime;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import vip.mate.workflow.model.WorkflowPayloadEntity;
import vip.mate.workflow.repository.WorkflowPayloadMapper;
@@ -15,6 +17,7 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HexFormat;
+import java.util.List;
import java.util.Objects;
import java.util.UUID;
@@ -38,6 +41,7 @@ import java.util.UUID;
* v1. The fs tier is what unblocks local dev / docker / private deploys
* that don't have an object store configured.
*/
+@Slf4j
@Service
public class PayloadStore {
@@ -50,17 +54,20 @@ public class PayloadStore {
private final long inlineMaxBytes;
private final long hardCapBytes;
private final Path fsRoot;
+ private final long retentionDays;
public PayloadStore(WorkflowPayloadMapper payloadMapper,
ObjectMapper objectMapper,
@Value("${mateclaw.workflow.payload.inline-max-bytes:262144}") long inlineMaxBytes,
@Value("${mateclaw.workflow.payload.hard-cap-bytes:52428800}") long hardCapBytes,
- @Value("${mateclaw.workflow.payload.fs.root:./data/workflow-payload}") String fsRoot) {
+ @Value("${mateclaw.workflow.payload.fs.root:./data/workflow-payload}") String fsRoot,
+ @Value("${mateclaw.workflow.payload.retention-days:30}") long retentionDays) {
this.payloadMapper = payloadMapper;
this.objectMapper = objectMapper;
this.inlineMaxBytes = inlineMaxBytes;
this.hardCapBytes = hardCapBytes;
this.fsRoot = Path.of(fsRoot).toAbsolutePath();
+ this.retentionDays = retentionDays;
}
/** Store a UTF-8 string payload and return its stable URI. */
@@ -172,6 +179,71 @@ public class PayloadStore {
}
}
+ /**
+ * Drop payload rows older than {@code retention-days}. Tombstones the
+ * filesystem files for fs-tier payloads in the same pass so the disk
+ * doesn't keep growing once the DB row is gone. Returns the number of
+ * rows actually deleted; primarily for tests + log lines.
+ *
+ *
v0 deletes by absolute age rather than walking the
+ * {@code mate_workflow_run} graph — runs that finish stay queryable
+ * for {@code retention-days} from the payload-write timestamp, which
+ * is "good enough" for an alpha. v1 can switch to run-state-driven
+ * GC ({@code state IN ('succeeded','failed') AND completed_at <
+ * threshold}) once the operator UI exposes a "preserve forever" flag
+ * for runs the customer wants kept.
+ */
+ public int sweepExpired() {
+ if (retentionDays <= 0) return 0;
+ LocalDateTime cutoff = LocalDateTime.now().minusDays(retentionDays);
+ List stale = payloadMapper.selectList(
+ new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper()
+ .lt(WorkflowPayloadEntity::getCreatedAt, cutoff));
+ if (stale.isEmpty()) return 0;
+ int deleted = 0;
+ for (WorkflowPayloadEntity row : stale) {
+ // Best-effort fs cleanup before the row goes — the row IS the
+ // foreign key the file is reachable through; if the row goes
+ // first the file becomes orphaned.
+ if (STORAGE_KIND_FS.equals(row.getStorageKind()) && row.getStorageRef() != null) {
+ try {
+ Files.deleteIfExists(fsRoot.resolve(row.getStorageRef()));
+ } catch (IOException e) {
+ log.warn("[PayloadStore] fs delete failed for {}: {}",
+ row.getStorageRef(), e.getMessage());
+ }
+ }
+ try {
+ payloadMapper.deleteById(row.getId());
+ deleted++;
+ } catch (Exception e) {
+ log.warn("[PayloadStore] db delete failed for payload {}: {}",
+ row.getPayloadUri(), e.getMessage());
+ }
+ }
+ return deleted;
+ }
+
+ /**
+ * Periodic sweep — runs once an hour by default. Tunable via
+ * {@code mateclaw.workflow.payload.sweep-interval-ms}. Skips a tick
+ * silently when retentionDays = 0 (operator opted out of GC).
+ */
+ @Scheduled(
+ fixedDelayString = "${mateclaw.workflow.payload.sweep-interval-ms:3600000}",
+ initialDelayString = "${mateclaw.workflow.payload.sweep-initial-delay-ms:600000}")
+ public void scheduledSweepExpired() {
+ try {
+ int dropped = sweepExpired();
+ if (dropped > 0) {
+ log.info("[PayloadStore] swept {} expired payload rows (retention={} days)",
+ dropped, retentionDays);
+ }
+ } catch (Exception e) {
+ log.warn("[PayloadStore] periodic sweep failed: {}", e.getMessage());
+ }
+ }
+
/** Wrapper exception for payload-store failures. */
public static class PayloadStoreException extends RuntimeException {
public PayloadStoreException(String message) { super(message); }
diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts
index e4db5ef2..0229fc38 100644
--- a/mateclaw-ui/src/api/index.ts
+++ b/mateclaw-ui/src/api/index.ts
@@ -911,6 +911,13 @@ export interface TriggerSummary {
fireCount: number
maxFires: number
lastFiredAt?: string
+ /** Stamp of the last dispatch attempt regardless of outcome (FIRED /
+ * SKIPPED / FAILED). Distinguishes "never attempted" from
+ * "attempted but the pre-flight skipped". */
+ lastDispatchedAt?: string
+ /** Most recent dispatch outcome message; null when the last attempt
+ * fired cleanly. */
+ lastError?: string
patternVersion: number
createTime: string
updateTime: string
diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts
index ed6e3fae..ad641899 100644
--- a/mateclaw-ui/src/i18n/locales/en-US.ts
+++ b/mateclaw-ui/src/i18n/locales/en-US.ts
@@ -2077,6 +2077,15 @@ export default {
},
targetWorkflowSelect: 'Pick a published workflow',
targetWorkflowEmpty: 'No published workflows yet.',
+ lastDispatchedAt: 'Last attempt',
+ patternTypeLabels: {
+ cron: 'Cron schedule',
+ channel_message: 'Channel message',
+ content_match: 'Content match',
+ agent_lifecycle: 'Agent lifecycle',
+ workflow_completion: 'Workflow completion',
+ webhook: 'Webhook',
+ },
patternHints: {
cron: 'Example: {"cron":"0 0 * * * *","timezone":"UTC"} — every cron change bumps pattern_version.',
channel_message: 'Optional channelType (e.g. feishu) and senderEquals narrow the match.',
diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts
index efa8f08e..e1ca59b4 100644
--- a/mateclaw-ui/src/i18n/locales/zh-CN.ts
+++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts
@@ -2089,6 +2089,15 @@ export default {
},
targetWorkflowSelect: '选择已发布的工作流',
targetWorkflowEmpty: '尚无已发布的工作流。',
+ lastDispatchedAt: '最近一次尝试',
+ patternTypeLabels: {
+ cron: '定时任务',
+ channel_message: '渠道消息',
+ content_match: '内容匹配',
+ agent_lifecycle: '智能体生命周期',
+ workflow_completion: '工作流完成',
+ webhook: 'Webhook',
+ },
patternHints: {
cron: '示例:{"cron":"0 0 * * * *","timezone":"Asia/Shanghai"} —— 每次修改 cron 都会让 pattern_version 自增。',
channel_message: '可填 channelType(如 feishu)和 senderEquals 进一步过滤。',
diff --git a/mateclaw-ui/src/views/Triggers.vue b/mateclaw-ui/src/views/Triggers.vue
index bdeadf9d..f99e1542 100644
--- a/mateclaw-ui/src/views/Triggers.vue
+++ b/mateclaw-ui/src/views/Triggers.vue
@@ -27,16 +27,28 @@
- | {{ row.name || t('triggers.unnamed') }} |
- {{ row.patternType }}
- {{ row.patternJson }}
+ {{ row.name || t('triggers.unnamed') }}
+
+ ⚠ {{ truncateError(row.lastError) }}
+
+ |
+
+ {{ patternTypeLabel(row.patternType) }}
+ {{ patternSummary(row) }}
|
{{ formatTarget(row) }} |
{{ t('triggers.rateUnit', { count: row.rateLimitPerMin }) }} |
{{ row.fireCount }} / {{ row.maxFires }} |
{{ row.patternVersion }} |
- {{ formatTime(row.lastFiredAt) }} |
+
+ {{ formatTime(row.lastFiredAt) }}
+
+ {{ formatTime(row.lastDispatchedAt) }}
+
+ |
|