Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
3cc19ef
Create BaseTriggerCapability
DylanTinianov Dec 15, 2025
ee41047
Update capabilities.go
DylanTinianov Dec 15, 2025
ea7bf83
Remove triggerID from AckEvent
DylanTinianov Dec 24, 2025
624fe53
Add event timeout to protos
DylanTinianov Dec 24, 2025
2569020
Merge branch 'main' of https://github.com/smartcontractkit/chainlink-…
DylanTinianov Dec 24, 2025
f718f3b
Include AckEvent
DylanTinianov Dec 24, 2025
edbfc46
Implement atomicTrigger AckEvent
DylanTinianov Dec 24, 2025
4a1ca5d
Update capabilities.go
DylanTinianov Dec 24, 2025
56b06ad
Update base_trigger.go
DylanTinianov Jan 2, 2026
a5a92ac
Add AckEvent to server template
DylanTinianov Jan 2, 2026
8df2510
Base trigger
DylanTinianov Jan 9, 2026
1c1f101
Setters
DylanTinianov Jan 9, 2026
547652e
Update base_trigger.go
DylanTinianov Jan 9, 2026
f2edaf1
Add event store
DylanTinianov Jan 9, 2026
41d9453
Update base_trigger.go
DylanTinianov Jan 9, 2026
3ba9e30
Update base_trigger.go
DylanTinianov Jan 9, 2026
187b515
Update tests
DylanTinianov Jan 13, 2026
0df9643
Merge branch 'main' of https://github.com/smartcontractkit/chainlink-…
DylanTinianov Jan 15, 2026
7f64147
Add AckEvent
DylanTinianov Jan 15, 2026
f7fd8ae
Add triggerId
DylanTinianov Jan 15, 2026
f282c46
Fix base
DylanTinianov Jan 19, 2026
b271378
Fix server gen
DylanTinianov Jan 19, 2026
b3bea6e
fix build
DylanTinianov Jan 19, 2026
996165c
Add AckEvent
DylanTinianov Jan 19, 2026
dea279a
Fix triggers
DylanTinianov Jan 19, 2026
80a6415
Merge branch 'main' of https://github.com/smartcontractkit/chainlink-…
DylanTinianov Jan 19, 2026
f77f2f6
Update runner_test.go
DylanTinianov Jan 19, 2026
941cc38
Add EventTimeout and AckEvent
DylanTinianov Jan 19, 2026
34a2672
Update base_test.go
DylanTinianov Jan 19, 2026
40d20eb
Merge branch 'main' into PLEX-1460-delivery-acks
DylanTinianov Jan 19, 2026
9a16150
update AckEvent
DylanTinianov Jan 21, 2026
99527d2
Merge branch 'PLEX-1460-delivery-acks' of https://github.com/smartcon…
DylanTinianov Jan 21, 2026
4307cbb
Merge branch 'main' into PLEX-1460-delivery-acks
DylanTinianov Jan 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 247 additions & 0 deletions pkg/capabilities/base_trigger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
package capabilities

import (
"context"
"sync"
"time"

"google.golang.org/protobuf/types/known/anypb"

"github.com/smartcontractkit/chainlink-common/pkg/logger"
)

type PendingEvent struct {
TriggerId string
WorkflowId string
EventId string
AnyTypeURL string // Payload type
Payload []byte
FirstAt time.Time
LastSentAt time.Time
Attempts int
}

type EventStore interface {
Insert(ctx context.Context, rec PendingEvent) error
Delete(ctx context.Context, triggerId, eventId, workflowId string) error
List(ctx context.Context) ([]PendingEvent, error)
}

type OutboundSend func(ctx context.Context, te TriggerEvent, workflowId string) error
type LostHook func(ctx context.Context, rec PendingEvent) // TODO: implement observability for lost

// key builds the composite lookup key used in pending
func key(triggerId, eventId, workflowId string) string {
return triggerId + "|" + eventId + "|" + workflowId
}

type BaseTriggerCapability struct {
/*
Keeps track of workflow registrations (similar to LLO streams trigger).
Handles retransmits based on T_retransmit and T_max.
Persists pending events in the DB to be resilient to node restarts.
*/
// TODO: We will want these to be configurable per chain
tRetransmit time.Duration // time window for an event being ACKd before we retransmit
tMax time.Duration // timeout before events are considered lost if not ACKd

store EventStore
send OutboundSend
lost LostHook
lggr logger.Logger

mu sync.Mutex
pending map[string]*PendingEvent // key(triggerID|eventID|workflowID)

ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}

func NewBaseTriggerCapability(
store EventStore,
send OutboundSend,
lost LostHook,
lggr logger.Logger,
tRetransmit, tMax time.Duration,
) *BaseTriggerCapability {
ctx, cancel := context.WithCancel(context.Background())
return &BaseTriggerCapability{
store: store,
send: send,
lost: lost,
lggr: lggr,
tRetransmit: tRetransmit,
tMax: tMax,
pending: make(map[string]*PendingEvent),
ctx: ctx,
cancel: cancel,
}
}

func (b *BaseTriggerCapability) Start(ctx context.Context) error {
b.ctx, b.cancel = context.WithCancel(ctx)

recs, err := b.store.List(ctx)
if err != nil {
return err
}

// Initialize in-memory persistence
b.pending = make(map[string]*PendingEvent)
for i := range recs {
r := recs[i]
b.pending[key(r.TriggerId, r.WorkflowId, r.EventId)] = &r
}

b.wg.Add(1)
go func() {
defer b.wg.Done()
b.retransmitLoop()
}()

for _, r := range recs {
_ = b.trySend(ctx, r.TriggerId, r.WorkflowId, r.EventId)
}
return nil
}

func (b *BaseTriggerCapability) Stop() {
b.cancel()
b.wg.Wait()
}

func (b *BaseTriggerCapability) DeliverEvent(
ctx context.Context,
te TriggerEvent,
workflowIds []string,
) error {
for _, workflowId := range workflowIds {
rec := PendingEvent{
TriggerId: te.TriggerType,
WorkflowId: workflowId,
EventId: te.ID,
AnyTypeURL: te.Payload.GetTypeUrl(),
Payload: te.Payload.GetValue(),
FirstAt: time.Now(),
}

if err := b.store.Insert(ctx, rec); err != nil {
return err
}

b.mu.Lock()
b.pending[key(te.TriggerType, workflowId, te.ID)] = &rec
b.mu.Unlock()

_ = b.trySend(ctx, te.TriggerType, workflowId, te.ID)
}
return nil // only when the event is successfully persisted and ready to be reliably delivered
}

func (b *BaseTriggerCapability) AckEvent(
ctx context.Context,
triggerId, eventId, workflowId string,
) error {
k := key(triggerId, eventId, workflowId)

b.mu.Lock()
delete(b.pending, k)
b.mu.Unlock()

return b.store.Delete(ctx, triggerId, eventId, workflowId)
}

func (b *BaseTriggerCapability) retransmitLoop() {
ticker := time.NewTicker(b.tRetransmit / 2)
defer ticker.Stop()

for {
select {
case <-b.ctx.Done():
return
case <-ticker.C:
b.scanPending()
}
}
}

func (b *BaseTriggerCapability) scanPending() {
now := time.Now()

b.mu.Lock()
toResend := make([]PendingEvent, 0, len(b.pending))
toLost := make([]PendingEvent, 0)
for k, rec := range b.pending {
// LOST: exceeded max time without ACK
if now.Sub(rec.FirstAt) >= b.tMax {
toLost = append(toLost, *rec)
delete(b.pending, k)
continue
}

// RESEND: hasn't been sent recently enough
if rec.LastSentAt.IsZero() || now.Sub(rec.LastSentAt) >= b.tRetransmit {
toResend = append(toResend, PendingEvent{
TriggerId: rec.TriggerId,
WorkflowId: rec.WorkflowId,
EventId: rec.EventId,
})
}
}
b.mu.Unlock()

for _, rec := range toLost {
b.lost(b.ctx, rec)

err := b.store.Delete(b.ctx, rec.TriggerId, rec.WorkflowId, rec.EventId)
if err != nil {
b.lggr.Errorw("failed to delete event from store")
}
}

for _, k := range toResend {
_ = b.trySend(b.ctx, k.TriggerId, k.WorkflowId, k.EventId)
}
}

// trySend attempts a delivery for the given (triggerId, workflowId, eventId).
// It updates Attempts and LastSentAt on every attempt. Success is determined
// by a later AckEvent; this method does NOT remove the record from memory/DB.
func (b *BaseTriggerCapability) trySend(ctx context.Context, triggerId, workflowId, eventId string) error {
k := key(triggerId, workflowId, eventId)

b.mu.Lock()
rec, ok := b.pending[k]
if !ok || rec == nil {
b.mu.Unlock()
return nil
}
rec.Attempts++
rec.LastSentAt = time.Now()

anyPayload := &anypb.Any{
TypeUrl: rec.AnyTypeURL,
Value: append([]byte(nil), rec.Payload...),
}

te := TriggerEvent{
TriggerType: triggerId,
ID: eventId,
Payload: anyPayload,
}
b.mu.Unlock()

if err := b.send(ctx, te, workflowId); err != nil {
if b.lggr != nil {
b.lggr.Errorf("trySend failed: trigger=%s workflow=%s event=%s attempt=%d err=%v",
triggerId, workflowId, eventId, rec.Attempts, err)
}
return err
}
if b.lggr != nil {
b.lggr.Debugf("trySend dispatched: trigger=%s workflow=%s event=%s attempt=%d",
triggerId, workflowId, eventId, rec.Attempts)
}
return nil
}
Loading
Loading