Merge branch 'main' into feat/markdown-output-format-web-fetch
This commit is contained in:
commit
13d4801601
13 changed files with 566 additions and 293 deletions
|
|
@ -267,14 +267,11 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil
|
return nil
|
||||||
default:
|
case msg, ok := <-al.bus.InboundChan():
|
||||||
msg, ok := al.bus.ConsumeInbound(ctx)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process message
|
// Process message
|
||||||
func() {
|
|
||||||
// TODO: Re-enable media cleanup after inbound media is properly consumed by the agent.
|
// TODO: Re-enable media cleanup after inbound media is properly consumed by the agent.
|
||||||
// Currently disabled because files are deleted before the LLM can access their content.
|
// Currently disabled because files are deleted before the LLM can access their content.
|
||||||
// defer func() {
|
// defer func() {
|
||||||
|
|
@ -327,7 +324,8 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
default:
|
||||||
|
time.Sleep(time.Microsecond * 200)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -997,20 +997,33 @@ func TestHandleReasoning(t *testing.T) {
|
||||||
al, msgBus := newLoop(t)
|
al, msgBus := newLoop(t)
|
||||||
al.handleReasoning(context.Background(), "reasoning", "telegram", "")
|
al.handleReasoning(context.Background(), "reasoning", "telegram", "")
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if msg, ok := msgBus.SubscribeOutbound(ctx); ok {
|
for {
|
||||||
|
select {
|
||||||
|
case msg, ok := <-msgBus.OutboundChan():
|
||||||
|
if !ok {
|
||||||
t.Fatalf("expected no outbound message, got %+v", msg)
|
t.Fatalf("expected no outbound message, got %+v", msg)
|
||||||
}
|
}
|
||||||
|
if msg.Content == "reasoning" {
|
||||||
|
t.Fatalf("expected no message for empty chatID, got %+v", msg)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Log("expected an outbound message, got none within timeout")
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
// Continue to check for message
|
||||||
|
time.Sleep(5 * time.Millisecond) // Avoid busy loop
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("publishes one message for non telegram", func(t *testing.T) {
|
t.Run("publishes one message for non telegram", func(t *testing.T) {
|
||||||
al, msgBus := newLoop(t)
|
al, msgBus := newLoop(t)
|
||||||
al.handleReasoning(context.Background(), "hello reasoning", "slack", "channel-1")
|
al.handleReasoning(context.Background(), "hello reasoning", "slack", "channel-1")
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
msg, ok := <-msgBus.OutboundChan()
|
||||||
defer cancel()
|
|
||||||
msg, ok := msgBus.SubscribeOutbound(ctx)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected an outbound message")
|
t.Fatal("expected an outbound message")
|
||||||
}
|
}
|
||||||
|
|
@ -1024,9 +1037,14 @@ func TestHandleReasoning(t *testing.T) {
|
||||||
reasoning := "hello telegram reasoning"
|
reasoning := "hello telegram reasoning"
|
||||||
al.handleReasoning(context.Background(), reasoning, "telegram", "tg-chat")
|
al.handleReasoning(context.Background(), reasoning, "telegram", "tg-chat")
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
msg, ok := msgBus.SubscribeOutbound(ctx)
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("expected an outbound message, got none within timeout")
|
||||||
|
return
|
||||||
|
case msg, ok := <-msgBus.OutboundChan():
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected outbound message")
|
t.Fatal("expected outbound message")
|
||||||
}
|
}
|
||||||
|
|
@ -1040,19 +1058,31 @@ func TestHandleReasoning(t *testing.T) {
|
||||||
if msg.Content != reasoning {
|
if msg.Content != reasoning {
|
||||||
t.Fatalf("content mismatch: got %q want %q", msg.Content, reasoning)
|
t.Fatalf("content mismatch: got %q want %q", msg.Content, reasoning)
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
t.Run("expired ctx", func(t *testing.T) {
|
t.Run("expired ctx", func(t *testing.T) {
|
||||||
al, msgBus := newLoop(t)
|
al, msgBus := newLoop(t)
|
||||||
reasoning := "hello telegram reasoning"
|
reasoning := "hello telegram reasoning"
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
al.handleReasoning(ctx, reasoning, "telegram", "tg-chat")
|
|
||||||
|
|
||||||
ctx, cancel = context.WithTimeout(context.Background(), 200*time.Millisecond)
|
al.handleReasoning(context.Background(), reasoning, "telegram", "tg-chat")
|
||||||
defer cancel()
|
|
||||||
msg, ok := msgBus.SubscribeOutbound(ctx)
|
consumeCtx, consumeCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
if ok {
|
defer consumeCancel()
|
||||||
t.Fatalf("expected no outbound message, got %+v", msg)
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case msg, ok := <-msgBus.OutboundChan():
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected no outbound message, but received: %+v", msg)
|
||||||
|
}
|
||||||
|
t.Logf("Received unexpected outbound message: %+v", msg)
|
||||||
|
return
|
||||||
|
case <-consumeCtx.Done():
|
||||||
|
t.Fatalf("failed: no message received within timeout")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1092,21 +1122,24 @@ func TestHandleReasoning(t *testing.T) {
|
||||||
|
|
||||||
// Drain the bus and verify the reasoning message was NOT published
|
// Drain the bus and verify the reasoning message was NOT published
|
||||||
// (it should have been dropped due to timeout).
|
// (it should have been dropped due to timeout).
|
||||||
drainCtx, drainCancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
timeer := time.After(1 * time.Second)
|
||||||
defer drainCancel()
|
|
||||||
foundReasoning := false
|
|
||||||
for {
|
for {
|
||||||
msg, ok := msgBus.SubscribeOutbound(drainCtx)
|
select {
|
||||||
|
case <-timeer:
|
||||||
|
t.Logf(
|
||||||
|
"no reasoning message received after draining bus for 1s, as expected,length=%d",
|
||||||
|
len(msgBus.OutboundChan()),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
case msg, ok := <-msgBus.OutboundChan():
|
||||||
if !ok {
|
if !ok {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if msg.Content == "should timeout" {
|
if msg.Content == "should timeout" {
|
||||||
foundReasoning = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if foundReasoning {
|
|
||||||
t.Fatal("expected reasoning message to be dropped when bus is full, but it was published")
|
t.Fatal("expected reasoning message to be dropped when bus is full, but it was published")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
141
pkg/bus/bus.go
141
pkg/bus/bus.go
|
|
@ -3,6 +3,7 @@ package bus
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
|
@ -17,8 +18,11 @@ type MessageBus struct {
|
||||||
inbound chan InboundMessage
|
inbound chan InboundMessage
|
||||||
outbound chan OutboundMessage
|
outbound chan OutboundMessage
|
||||||
outboundMedia chan OutboundMediaMessage
|
outboundMedia chan OutboundMediaMessage
|
||||||
|
|
||||||
|
closeOnce sync.Once
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
closed atomic.Bool
|
closed atomic.Bool
|
||||||
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMessageBus() *MessageBus {
|
func NewMessageBus() *MessageBus {
|
||||||
|
|
@ -30,128 +34,91 @@ func NewMessageBus() *MessageBus {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error {
|
func publish[T any](ctx context.Context, mb *MessageBus, ch chan T, msg T) error {
|
||||||
|
// check bus closed before acquiring wg, to avoid unnecessary wg.Add and potential deadlock
|
||||||
if mb.closed.Load() {
|
if mb.closed.Load() {
|
||||||
return ErrBusClosed
|
return ErrBusClosed
|
||||||
}
|
}
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return err
|
// check again,before sending message, to avoid sending to closed channel
|
||||||
}
|
|
||||||
select {
|
select {
|
||||||
case mb.inbound <- msg:
|
|
||||||
return nil
|
|
||||||
case <-mb.done:
|
|
||||||
return ErrBusClosed
|
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
|
case <-mb.done:
|
||||||
|
return ErrBusClosed
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
mb.wg.Add(1)
|
||||||
|
defer mb.wg.Done()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case ch <- msg:
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-mb.done:
|
||||||
|
return ErrBusClosed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool) {
|
func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error {
|
||||||
select {
|
return publish(ctx, mb, mb.inbound, msg)
|
||||||
case msg, ok := <-mb.inbound:
|
|
||||||
return msg, ok
|
|
||||||
case <-mb.done:
|
|
||||||
return InboundMessage{}, false
|
|
||||||
case <-ctx.Done():
|
|
||||||
return InboundMessage{}, false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (mb *MessageBus) InboundChan() <-chan InboundMessage {
|
||||||
|
return mb.inbound
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error {
|
func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error {
|
||||||
if mb.closed.Load() {
|
return publish(ctx, mb, mb.outbound, msg)
|
||||||
return ErrBusClosed
|
|
||||||
}
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case mb.outbound <- msg:
|
|
||||||
return nil
|
|
||||||
case <-mb.done:
|
|
||||||
return ErrBusClosed
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, bool) {
|
func (mb *MessageBus) OutboundChan() <-chan OutboundMessage {
|
||||||
select {
|
return mb.outbound
|
||||||
case msg, ok := <-mb.outbound:
|
|
||||||
return msg, ok
|
|
||||||
case <-mb.done:
|
|
||||||
return OutboundMessage{}, false
|
|
||||||
case <-ctx.Done():
|
|
||||||
return OutboundMessage{}, false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error {
|
func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error {
|
||||||
if mb.closed.Load() {
|
return publish(ctx, mb, mb.outboundMedia, msg)
|
||||||
return ErrBusClosed
|
|
||||||
}
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case mb.outboundMedia <- msg:
|
|
||||||
return nil
|
|
||||||
case <-mb.done:
|
|
||||||
return ErrBusClosed
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) SubscribeOutboundMedia(ctx context.Context) (OutboundMediaMessage, bool) {
|
func (mb *MessageBus) OutboundMediaChan() <-chan OutboundMediaMessage {
|
||||||
select {
|
return mb.outboundMedia
|
||||||
case msg, ok := <-mb.outboundMedia:
|
|
||||||
return msg, ok
|
|
||||||
case <-mb.done:
|
|
||||||
return OutboundMediaMessage{}, false
|
|
||||||
case <-ctx.Done():
|
|
||||||
return OutboundMediaMessage{}, false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) Close() {
|
func (mb *MessageBus) Close() {
|
||||||
if mb.closed.CompareAndSwap(false, true) {
|
mb.closeOnce.Do(func() {
|
||||||
|
// notify all blocked publishers to exit
|
||||||
close(mb.done)
|
close(mb.done)
|
||||||
|
|
||||||
// Drain buffered channels so messages aren't silently lost.
|
// because every publisher will check mb.closed before acquiring wg
|
||||||
// Channels are NOT closed to avoid send-on-closed panics from concurrent publishers.
|
// so we can be sure that new publishers will not be added new messages after this point
|
||||||
|
mb.closed.Store(true)
|
||||||
|
|
||||||
|
// wait for all ongoing Publish calls to finish, ensuring all messages have been sent to channels or exited
|
||||||
|
mb.wg.Wait()
|
||||||
|
|
||||||
|
// close channels safely
|
||||||
|
close(mb.inbound)
|
||||||
|
close(mb.outbound)
|
||||||
|
close(mb.outboundMedia)
|
||||||
|
|
||||||
|
// clean up any remaining messages in channels
|
||||||
drained := 0
|
drained := 0
|
||||||
for {
|
for range mb.inbound {
|
||||||
select {
|
|
||||||
case <-mb.inbound:
|
|
||||||
drained++
|
drained++
|
||||||
default:
|
|
||||||
goto doneInbound
|
|
||||||
}
|
}
|
||||||
}
|
for range mb.outbound {
|
||||||
doneInbound:
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-mb.outbound:
|
|
||||||
drained++
|
drained++
|
||||||
default:
|
|
||||||
goto doneOutbound
|
|
||||||
}
|
}
|
||||||
}
|
for range mb.outboundMedia {
|
||||||
doneOutbound:
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-mb.outboundMedia:
|
|
||||||
drained++
|
drained++
|
||||||
default:
|
|
||||||
goto doneMedia
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
doneMedia:
|
|
||||||
if drained > 0 {
|
if drained > 0 {
|
||||||
logger.DebugCF("bus", "Drained buffered messages during close", map[string]any{
|
logger.DebugCF("bus", "Drained buffered messages during close", map[string]any{
|
||||||
"count": drained,
|
"count": drained,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ func TestPublishConsume(t *testing.T) {
|
||||||
t.Fatalf("PublishInbound failed: %v", err)
|
t.Fatalf("PublishInbound failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, ok := mb.ConsumeInbound(ctx)
|
got, ok := <-mb.InboundChan()
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("ConsumeInbound returned ok=false")
|
t.Fatal("ConsumeInbound returned ok=false")
|
||||||
}
|
}
|
||||||
|
|
@ -52,7 +52,7 @@ func TestPublishOutboundSubscribe(t *testing.T) {
|
||||||
t.Fatalf("PublishOutbound failed: %v", err)
|
t.Fatalf("PublishOutbound failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, ok := mb.SubscribeOutbound(ctx)
|
got, ok := <-mb.OutboundChan()
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("SubscribeOutbound returned ok=false")
|
t.Fatal("SubscribeOutbound returned ok=false")
|
||||||
}
|
}
|
||||||
|
|
@ -108,27 +108,48 @@ func TestPublishOutbound_BusClosed(t *testing.T) {
|
||||||
|
|
||||||
func TestConsumeInbound_ContextCancel(t *testing.T) {
|
func TestConsumeInbound_ContextCancel(t *testing.T) {
|
||||||
mb := NewMessageBus()
|
mb := NewMessageBus()
|
||||||
|
|
||||||
defer mb.Close()
|
defer mb.Close()
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
for i := range defaultBusBufferSize {
|
||||||
cancel()
|
if err := mb.PublishInbound(context.Background(), InboundMessage{Content: "fill"}); err != nil {
|
||||||
|
t.Fatalf("fill failed at %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_, ok := mb.ConsumeInbound(ctx)
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
if ok {
|
defer cancel()
|
||||||
|
mb.PublishInbound(ctx, InboundMessage{Content: "ContextCancel"})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Log("context canceled, as expected")
|
||||||
|
|
||||||
|
case msg, ok := <-mb.InboundChan():
|
||||||
|
if !ok {
|
||||||
t.Fatal("expected ok=false when context is canceled")
|
t.Fatal("expected ok=false when context is canceled")
|
||||||
}
|
}
|
||||||
|
if msg.Content == "ContextCancel" {
|
||||||
|
t.Fatalf("expected content 'ContextCancel', got %q", msg.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConsumeInbound_BusClosed(t *testing.T) {
|
func TestConsumeInbound_BusClosed(t *testing.T) {
|
||||||
mb := NewMessageBus()
|
mb := NewMessageBus()
|
||||||
|
|
||||||
|
timer := time.AfterFunc(100*time.Millisecond, func() {
|
||||||
mb.Close()
|
mb.Close()
|
||||||
|
})
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
select {
|
||||||
defer cancel()
|
case <-timer.C:
|
||||||
|
t.Log("context canceled, as expected")
|
||||||
|
|
||||||
_, ok := mb.ConsumeInbound(ctx)
|
case _, ok := <-mb.InboundChan():
|
||||||
if ok {
|
if ok {
|
||||||
t.Fatal("expected ok=false when bus is closed")
|
t.Fatal("expected ok=false when context is canceled")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,10 +157,7 @@ func TestSubscribeOutbound_BusClosed(t *testing.T) {
|
||||||
mb := NewMessageBus()
|
mb := NewMessageBus()
|
||||||
mb.Close()
|
mb.Close()
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
_, ok := <-mb.OutboundChan()
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
_, ok := mb.SubscribeOutbound(ctx)
|
|
||||||
if ok {
|
if ok {
|
||||||
t.Fatal("expected ok=false when bus is closed")
|
t.Fatal("expected ok=false when bus is closed")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -585,7 +585,7 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork
|
||||||
func dispatchLoop[M any](
|
func dispatchLoop[M any](
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
m *Manager,
|
m *Manager,
|
||||||
subscribe func(context.Context) (M, bool),
|
ch <-chan M,
|
||||||
getChannel func(M) string,
|
getChannel func(M) string,
|
||||||
enqueue func(context.Context, *channelWorker, M) bool,
|
enqueue func(context.Context, *channelWorker, M) bool,
|
||||||
startMsg, stopMsg, unknownMsg, noWorkerMsg string,
|
startMsg, stopMsg, unknownMsg, noWorkerMsg string,
|
||||||
|
|
@ -593,7 +593,12 @@ func dispatchLoop[M any](
|
||||||
logger.InfoC("channels", startMsg)
|
logger.InfoC("channels", startMsg)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
msg, ok := subscribe(ctx)
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
logger.InfoC("channels", stopMsg)
|
||||||
|
return
|
||||||
|
|
||||||
|
case msg, ok := <-ch:
|
||||||
if !ok {
|
if !ok {
|
||||||
logger.InfoC("channels", stopMsg)
|
logger.InfoC("channels", stopMsg)
|
||||||
return
|
return
|
||||||
|
|
@ -625,11 +630,12 @@ func dispatchLoop[M any](
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) dispatchOutbound(ctx context.Context) {
|
func (m *Manager) dispatchOutbound(ctx context.Context) {
|
||||||
dispatchLoop(
|
dispatchLoop(
|
||||||
ctx, m,
|
ctx, m,
|
||||||
m.bus.SubscribeOutbound,
|
m.bus.OutboundChan(),
|
||||||
func(msg bus.OutboundMessage) string { return msg.Channel },
|
func(msg bus.OutboundMessage) string { return msg.Channel },
|
||||||
func(ctx context.Context, w *channelWorker, msg bus.OutboundMessage) bool {
|
func(ctx context.Context, w *channelWorker, msg bus.OutboundMessage) bool {
|
||||||
select {
|
select {
|
||||||
|
|
@ -649,7 +655,7 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
|
||||||
func (m *Manager) dispatchOutboundMedia(ctx context.Context) {
|
func (m *Manager) dispatchOutboundMedia(ctx context.Context) {
|
||||||
dispatchLoop(
|
dispatchLoop(
|
||||||
ctx, m,
|
ctx, m,
|
||||||
m.bus.SubscribeOutboundMedia,
|
m.bus.OutboundMediaChan(),
|
||||||
func(msg bus.OutboundMediaMessage) string { return msg.Channel },
|
func(msg bus.OutboundMediaMessage) string { return msg.Channel },
|
||||||
func(ctx context.Context, w *channelWorker, msg bus.OutboundMediaMessage) bool {
|
func(ctx context.Context, w *channelWorker, msg bus.OutboundMediaMessage) bool {
|
||||||
select {
|
select {
|
||||||
|
|
|
||||||
|
|
@ -34,11 +34,19 @@ func TestHandleC2CMessage_IncludesAccountIDMetadata(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
inbound, ok := messageBus.ConsumeInbound(ctx)
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("timeout waiting for inbound message")
|
||||||
|
return
|
||||||
|
case inbound, ok := <-messageBus.InboundChan():
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected inbound message")
|
t.Fatal("expected inbound message")
|
||||||
}
|
}
|
||||||
if inbound.Metadata["account_id"] != "7750283E123456" {
|
if inbound.Metadata["account_id"] != "7750283E123456" {
|
||||||
t.Fatalf("account_id metadata = %q, want %q", inbound.Metadata["account_id"], "7750283E123456")
|
t.Fatalf("account_id metadata = %q, want %q", inbound.Metadata["account_id"], "7750283E123456")
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package telegram
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mymmrac/telego"
|
"github.com/mymmrac/telego"
|
||||||
|
|
||||||
|
|
@ -36,10 +35,7 @@ func TestHandleMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
|
||||||
t.Fatalf("handleMessage error: %v", err)
|
t.Fatalf("handleMessage error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
inbound, ok := <-messageBus.InboundChan()
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
inbound, ok := messageBus.ConsumeInbound(ctx)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected inbound message to be forwarded")
|
t.Fatal("expected inbound message to be forwarded")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,10 +108,15 @@ func TestHandleMessage_GroupMentionOnly_BotCommandEntity(t *testing.T) {
|
||||||
t.Fatalf("handleMessage error: %v", err)
|
t.Fatalf("handleMessage error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Microsecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
select {
|
||||||
inbound, ok := messageBus.ConsumeInbound(ctx)
|
case <-ctx.Done():
|
||||||
|
if tc.wantForwarded {
|
||||||
|
t.Fatal("timeout waiting for message to be forwarded")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case inbound, ok := <-messageBus.InboundChan():
|
||||||
if tc.wantForwarded {
|
if tc.wantForwarded {
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected inbound message to be forwarded")
|
t.Fatal("expected inbound message to be forwarded")
|
||||||
|
|
@ -121,9 +126,6 @@ func TestHandleMessage_GroupMentionOnly_BotCommandEntity(t *testing.T) {
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok {
|
|
||||||
t.Fatalf("expected message to be filtered, got content=%q", inbound.Content)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mymmrac/telego"
|
"github.com/mymmrac/telego"
|
||||||
ta "github.com/mymmrac/telego/telegoapi"
|
ta "github.com/mymmrac/telego/telegoapi"
|
||||||
|
|
@ -355,10 +354,7 @@ func TestHandleMessage_ForumTopic_SetsMetadata(t *testing.T) {
|
||||||
err := ch.handleMessage(context.Background(), msg)
|
err := ch.handleMessage(context.Background(), msg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
inbound, ok := <-messageBus.InboundChan()
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
inbound, ok := messageBus.ConsumeInbound(ctx)
|
|
||||||
require.True(t, ok, "expected inbound message")
|
require.True(t, ok, "expected inbound message")
|
||||||
|
|
||||||
// Composite chatID should include thread ID
|
// Composite chatID should include thread ID
|
||||||
|
|
@ -397,10 +393,7 @@ func TestHandleMessage_NoForum_NoThreadMetadata(t *testing.T) {
|
||||||
err := ch.handleMessage(context.Background(), msg)
|
err := ch.handleMessage(context.Background(), msg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
inbound, ok := <-messageBus.InboundChan()
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
inbound, ok := messageBus.ConsumeInbound(ctx)
|
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
|
|
||||||
// Plain chatID without thread suffix
|
// Plain chatID without thread suffix
|
||||||
|
|
@ -443,10 +436,7 @@ func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) {
|
||||||
err := ch.handleMessage(context.Background(), msg)
|
err := ch.handleMessage(context.Background(), msg)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
inbound, ok := <-messageBus.InboundChan()
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
inbound, ok := messageBus.ConsumeInbound(ctx)
|
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
|
|
||||||
// chatID should NOT include thread suffix for non-forum groups
|
// chatID should NOT include thread suffix for non-forum groups
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package whatsapp
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
|
@ -25,10 +24,7 @@ func TestHandleIncomingMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T
|
||||||
"content": "/help",
|
"content": "/help",
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
inbound, ok := <-messageBus.InboundChan()
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
inbound, ok := messageBus.ConsumeInbound(ctx)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected inbound message to be forwarded")
|
t.Fatal("expected inbound message to be forwarded")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,11 @@ func TestHandleIncoming_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
inbound, ok := messageBus.ConsumeInbound(ctx)
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("timeout waiting for message to be forwarded")
|
||||||
|
return
|
||||||
|
case inbound, ok := <-messageBus.InboundChan():
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected inbound message to be forwarded")
|
t.Fatal("expected inbound message to be forwarded")
|
||||||
}
|
}
|
||||||
|
|
@ -54,3 +58,4 @@ func TestHandleIncoming_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
|
||||||
t.Fatalf("content=%q", inbound.Content)
|
t.Fatalf("content=%q", inbound.Content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ type CronService struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
running bool
|
running bool
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
|
wakeChan chan struct{}
|
||||||
gronx *gronx.Gronx
|
gronx *gronx.Gronx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,6 +74,7 @@ func NewCronService(storePath string, onJob JobHandler) *CronService {
|
||||||
storePath: storePath,
|
storePath: storePath,
|
||||||
onJob: onJob,
|
onJob: onJob,
|
||||||
gronx: gronx.New(),
|
gronx: gronx.New(),
|
||||||
|
wakeChan: make(chan struct{}),
|
||||||
}
|
}
|
||||||
// Initialize and load store on creation
|
// Initialize and load store on creation
|
||||||
cs.loadStore()
|
cs.loadStore()
|
||||||
|
|
@ -97,6 +99,9 @@ func (cs *CronService) Start() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
cs.stopChan = make(chan struct{})
|
cs.stopChan = make(chan struct{})
|
||||||
|
if cs.wakeChan == nil {
|
||||||
|
cs.wakeChan = make(chan struct{})
|
||||||
|
}
|
||||||
cs.running = true
|
cs.running = true
|
||||||
go cs.runLoop(cs.stopChan)
|
go cs.runLoop(cs.stopChan)
|
||||||
|
|
||||||
|
|
@ -119,14 +124,47 @@ func (cs *CronService) Stop() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *CronService) runLoop(stopChan chan struct{}) {
|
func (cs *CronService) runLoop(stopChan chan struct{}) {
|
||||||
ticker := time.NewTicker(1 * time.Second)
|
timer := time.NewTimer(time.Hour)
|
||||||
defer ticker.Stop()
|
if !timer.Stop() {
|
||||||
|
<-timer.C
|
||||||
|
}
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
// every loop, recalculate the next wake time
|
||||||
|
cs.mu.RLock()
|
||||||
|
nextWake := cs.getNextWakeMS()
|
||||||
|
cs.mu.RUnlock()
|
||||||
|
|
||||||
|
var delay time.Duration
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
if nextWake == nil {
|
||||||
|
// no jobs, sleep for a long time (or until a new job is added)
|
||||||
|
delay = time.Hour
|
||||||
|
} else {
|
||||||
|
diff := *nextWake - now
|
||||||
|
if diff <= 0 {
|
||||||
|
delay = 0
|
||||||
|
} else {
|
||||||
|
delay = time.Duration(diff) * time.Millisecond
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timer.Reset(delay)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-stopChan:
|
case <-stopChan:
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-cs.wakeChan: // wake on new job or update
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
case <-timer.C:
|
||||||
cs.checkJobs()
|
cs.checkJobs()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -264,22 +302,19 @@ func (cs *CronService) executeJobByID(jobID string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *CronService) computeNextRun(schedule *CronSchedule, nowMS int64) *int64 {
|
func (cs *CronService) computeNextRun(schedule *CronSchedule, nowMS int64) *int64 {
|
||||||
if schedule.Kind == "at" {
|
switch schedule.Kind {
|
||||||
|
case "at":
|
||||||
if schedule.AtMS != nil && *schedule.AtMS > nowMS {
|
if schedule.AtMS != nil && *schedule.AtMS > nowMS {
|
||||||
return schedule.AtMS
|
return schedule.AtMS
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
case "every":
|
||||||
|
|
||||||
if schedule.Kind == "every" {
|
|
||||||
if schedule.EveryMS == nil || *schedule.EveryMS <= 0 {
|
if schedule.EveryMS == nil || *schedule.EveryMS <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
next := nowMS + *schedule.EveryMS
|
next := nowMS + *schedule.EveryMS
|
||||||
return &next
|
return &next
|
||||||
}
|
case "cron":
|
||||||
|
|
||||||
if schedule.Kind == "cron" {
|
|
||||||
if schedule.Expr == "" {
|
if schedule.Expr == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -294,9 +329,19 @@ func (cs *CronService) computeNextRun(schedule *CronSchedule, nowMS int64) *int6
|
||||||
|
|
||||||
nextMS := nextTime.UnixMilli()
|
nextMS := nextTime.UnixMilli()
|
||||||
return &nextMS
|
return &nextMS
|
||||||
|
default:
|
||||||
|
log.Printf("[cron] unknown schedule kind '%s'", schedule.Kind)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
// wake up the loop to re-evaluate next wake time immediately (e.g. after add/update/remove jobs)
|
||||||
|
func (cs *CronService) notify() {
|
||||||
|
select {
|
||||||
|
case cs.wakeChan <- struct{}{}:
|
||||||
|
default:
|
||||||
|
// if the channel is full, it means the loop will wake up soon anyway, so we can skip sending
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cs *CronService) recomputeNextRuns() {
|
func (cs *CronService) recomputeNextRuns() {
|
||||||
|
|
@ -400,6 +445,8 @@ func (cs *CronService) AddJob(
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cs.notify()
|
||||||
|
|
||||||
return &job, nil
|
return &job, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -411,6 +458,9 @@ func (cs *CronService) UpdateJob(job *CronJob) error {
|
||||||
if cs.store.Jobs[i].ID == job.ID {
|
if cs.store.Jobs[i].ID == job.ID {
|
||||||
cs.store.Jobs[i] = *job
|
cs.store.Jobs[i] = *job
|
||||||
cs.store.Jobs[i].UpdatedAtMS = time.Now().UnixMilli()
|
cs.store.Jobs[i].UpdatedAtMS = time.Now().UnixMilli()
|
||||||
|
|
||||||
|
cs.notify()
|
||||||
|
|
||||||
return cs.saveStoreUnsafe()
|
return cs.saveStoreUnsafe()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -441,6 +491,8 @@ func (cs *CronService) removeJobUnsafe(jobID string) bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cs.notify()
|
||||||
|
|
||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -463,6 +515,9 @@ func (cs *CronService) EnableJob(jobID string, enabled bool) *CronJob {
|
||||||
if err := cs.saveStoreUnsafe(); err != nil {
|
if err := cs.saveStoreUnsafe(); err != nil {
|
||||||
log.Printf("[cron] failed to save store after enable: %v", err)
|
log.Printf("[cron] failed to save store after enable: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cs.notify()
|
||||||
|
|
||||||
return job
|
return job
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
package cron
|
package cron
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSaveStore_FilePermissions(t *testing.T) {
|
func TestSaveStore_FilePermissions(t *testing.T) {
|
||||||
|
|
@ -36,3 +39,199 @@ func TestSaveStore_FilePermissions(t *testing.T) {
|
||||||
func int64Ptr(v int64) *int64 {
|
func int64Ptr(v int64) *int64 {
|
||||||
return &v
|
return &v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setupService(handler JobHandler) (*CronService, string) {
|
||||||
|
tmpFile := fmt.Sprintf("test_cron_%d.json", time.Now().UnixNano())
|
||||||
|
cs := NewCronService(tmpFile, handler)
|
||||||
|
return cs, tmpFile
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronService_CRUD(t *testing.T) {
|
||||||
|
cs, path := setupService(nil)
|
||||||
|
defer os.Remove(path)
|
||||||
|
|
||||||
|
// Test AddJob
|
||||||
|
at := time.Now().Add(time.Hour).UnixMilli()
|
||||||
|
job, err := cs.AddJob("Task1", CronSchedule{Kind: "at", AtMS: &at}, "msg", true, "ch", "to")
|
||||||
|
if err != nil || job.ID == "" {
|
||||||
|
t.Fatalf("AddJob failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test ListJobs
|
||||||
|
if len(cs.ListJobs(true)) != 1 {
|
||||||
|
t.Error("ListJobs should return 1 job")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test UpdateJob
|
||||||
|
job.Name = "UpdatedName"
|
||||||
|
err = cs.UpdateJob(job)
|
||||||
|
if err != nil || cs.store.Jobs[0].Name != "UpdatedName" {
|
||||||
|
t.Error("UpdateJob failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test EnableJob
|
||||||
|
cs.EnableJob(job.ID, false)
|
||||||
|
if cs.store.Jobs[0].Enabled != false || cs.store.Jobs[0].State.NextRunAtMS != nil {
|
||||||
|
t.Error("EnableJob(false) failed to clear state")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test RemoveJob
|
||||||
|
removed := cs.RemoveJob(job.ID)
|
||||||
|
if !removed || len(cs.store.Jobs) != 0 {
|
||||||
|
t.Error("RemoveJob failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Test Cron Expression Calculation Logic
|
||||||
|
func TestCronService_ComputeNextRun(t *testing.T) {
|
||||||
|
cs, path := setupService(nil)
|
||||||
|
defer os.Remove(path)
|
||||||
|
|
||||||
|
now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC).UnixMilli()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
schedule CronSchedule
|
||||||
|
wantNil bool
|
||||||
|
}{
|
||||||
|
{"Valid Cron", CronSchedule{Kind: "cron", Expr: "0 * * * *"}, false},
|
||||||
|
{"Invalid Cron", CronSchedule{Kind: "cron", Expr: "invalid"}, true},
|
||||||
|
{"Every MS", CronSchedule{Kind: "every", EveryMS: int64Ptr(5000)}, false},
|
||||||
|
{"At Future", CronSchedule{Kind: "at", AtMS: int64Ptr(now + 1000)}, false},
|
||||||
|
{"At Past", CronSchedule{Kind: "at", AtMS: int64Ptr(now - 1000)}, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := cs.computeNextRun(&tt.schedule, now)
|
||||||
|
if (got == nil) != tt.wantNil {
|
||||||
|
t.Errorf("%s: got %v, wantNil %v", tt.name, got, tt.wantNil)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Test Execution Flow
|
||||||
|
func TestCronService_ExecutionFlow(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
executedJobs := make(map[string]bool)
|
||||||
|
|
||||||
|
handler := func(job *CronJob) (string, error) {
|
||||||
|
mu.Lock()
|
||||||
|
executedJobs[job.ID] = true
|
||||||
|
mu.Unlock()
|
||||||
|
return "ok", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cs, path := setupService(handler)
|
||||||
|
defer os.Remove(path)
|
||||||
|
|
||||||
|
// Start the service
|
||||||
|
if err := cs.Start(); err != nil {
|
||||||
|
t.Fatalf("Start failed: %v", err)
|
||||||
|
}
|
||||||
|
defer cs.Stop()
|
||||||
|
|
||||||
|
// Add a job then runs 100ms from now
|
||||||
|
target := time.Now().Add(100 * time.Millisecond).UnixMilli()
|
||||||
|
job, _ := cs.AddJob("FastJob", CronSchedule{Kind: "at", AtMS: &target}, "", false, "", "")
|
||||||
|
|
||||||
|
// Check for job execution with a timeout
|
||||||
|
success := false
|
||||||
|
for range 20 {
|
||||||
|
mu.Lock()
|
||||||
|
if executedJobs[job.ID] {
|
||||||
|
success = true
|
||||||
|
mu.Unlock()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !success {
|
||||||
|
t.Error("Job was not executed in time")
|
||||||
|
}
|
||||||
|
|
||||||
|
// check that the job is removed after execution (DeleteAfterRun = true)
|
||||||
|
status := cs.Status()
|
||||||
|
if status["jobs"].(int) != 0 {
|
||||||
|
t.Errorf("Job should be deleted after run, got count: %v", status["jobs"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronService_PersistenceIntegrity(t *testing.T) {
|
||||||
|
tmpFile := "persist_test.json"
|
||||||
|
defer os.Remove(tmpFile)
|
||||||
|
|
||||||
|
// write a job and persist
|
||||||
|
cs1 := NewCronService(tmpFile, nil)
|
||||||
|
at := int64(2000000000000)
|
||||||
|
cs1.AddJob("PersistMe", CronSchedule{Kind: "at", AtMS: &at}, "payload", true, "ch1", "")
|
||||||
|
|
||||||
|
// check file exists
|
||||||
|
if _, err := os.Stat(tmpFile); os.IsNotExist(err) {
|
||||||
|
t.Fatal("Store file was not created")
|
||||||
|
}
|
||||||
|
|
||||||
|
// reload and check data integrity
|
||||||
|
cs2 := NewCronService(tmpFile, nil)
|
||||||
|
if err := cs2.Load(); err != nil {
|
||||||
|
t.Fatalf("Failed to load store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs := cs2.ListJobs(true)
|
||||||
|
if len(jobs) != 1 || jobs[0].Name != "PersistMe" {
|
||||||
|
t.Errorf("Data corruption after reload. Got: %+v", jobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// test loading invalid JSON
|
||||||
|
os.WriteFile(tmpFile, []byte("{invalid json}"), 0o644)
|
||||||
|
cs3 := NewCronService(tmpFile, nil)
|
||||||
|
err := cs3.loadStore()
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Should return error when loading invalid JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronService_ConcurrentAccess(t *testing.T) {
|
||||||
|
cs, path := setupService(nil)
|
||||||
|
defer os.Remove(path)
|
||||||
|
|
||||||
|
cs.Start()
|
||||||
|
defer cs.Stop()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
workers := 10
|
||||||
|
iterations := 50
|
||||||
|
|
||||||
|
wg.Add(workers * 2)
|
||||||
|
|
||||||
|
// add jobs concurrently
|
||||||
|
for i := range workers {
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := range iterations {
|
||||||
|
at := time.Now().Add(time.Hour).UnixMilli()
|
||||||
|
cs.AddJob(fmt.Sprintf("Job-%d-%d", id, j), CronSchedule{Kind: "at", AtMS: &at}, "", false, "", "")
|
||||||
|
time.Sleep(100 * time.Microsecond)
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// read and update jobs concurrently
|
||||||
|
for range workers {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := range iterations {
|
||||||
|
jobs := cs.ListJobs(true)
|
||||||
|
if len(jobs) > 0 {
|
||||||
|
cs.EnableJob(jobs[0].ID, j%2 == 0)
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Microsecond)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue