mirror of
https://github.com/hanzoai/tasks.git
synced 2026-10-04 02:04:03 +00:00
The engine took a PORT and the SDK an Options.HostPort, so neither end
could use a unix socket for traffic that never leaves the host. The
field name was not the only obstacle: luxfi/zap bound net.Listen("tcp",
":port") and reached a peer with net.DialTimeout("tcp", addr), so a path
was refused at the dialer with "missing port in address" and could not
be bound at all. That is fixed upstream (luxfi/zap Address + Network);
this pins the commit until it is tagged.
EmbedConfig.Address, Embedded.Address, ServeGated(addr) and
client.Options.Address each take ONE address, because a port is a detail
of one transport rather than a thing every listener has. A path binds or
dials a unix socket; anything else is host:port.
TestDialRequiresHostPort became TestDialRequiresAddress: Dial needs an
address, and asserts a socket path is refused for being absent rather
than for being portless. TestE2E_UnixSocket runs a real workflow over a
socket end to end, and TestE2E_UnixSocketRebind proves the socket is
unlinked on stop so a restart binds cleanly.
Co-authored-by: Hanzo Dev <[email protected]>
199 lines
5.6 KiB
Go
199 lines
5.6 KiB
Go
// Package workflow — wall-clock Timer implementation.
|
|
//
|
|
// Phase 1: Timers are backed by a real time.Timer on a per-timer
|
|
// goroutine. That goroutine settles the Future when the deadline
|
|
// fires or the scope cancels. Duration is wall-clock; Now() advances
|
|
// only deterministically (via AdvanceClock / AutoAdvance) so there is
|
|
// no attempt to align "workflow time" with "timer fire time" yet.
|
|
//
|
|
// Phase 2 (event-time replay, durable timers): the env will persist
|
|
// a pending-timer record in the history log, and replay will refire
|
|
// the same Future from the log at the same point. The public
|
|
// workflow.NewTimer API does not change.
|
|
//
|
|
// Wall-clock-only v1 is explicitly fine — the read-team §5.2 bug
|
|
// was the opposite: NewTimer collapsed every duration to zero, so a
|
|
// 24h dunning timer fired on the next dispatch loop tick. Wall-clock
|
|
// correctness eliminates that class of bug entirely.
|
|
|
|
package workflow
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/hanzoai/tasks/pkg/sdk/temporal"
|
|
)
|
|
|
|
// NewTimer schedules a durable timer that fires after d of workflow
|
|
// time and returns the Future that represents its completion. The
|
|
// Future's Get settles with nil error when the timer fires, or with
|
|
// temporal.Canceled if the ctx is canceled before d elapses.
|
|
//
|
|
// Mirrors go.temporal.io/sdk/workflow.NewTimer.
|
|
func NewTimer(ctx Context, d time.Duration) Future {
|
|
if ctx == nil {
|
|
// Return an already-failed future so callers don't crash.
|
|
f := NewFuture()
|
|
f.Settle(nil, errNilContext)
|
|
return f
|
|
}
|
|
if d < 0 {
|
|
d = 0
|
|
}
|
|
return ctx.env().NewTimer(d)
|
|
}
|
|
|
|
// NewWallClockTimer is the shared helper used by both StubEnv and
|
|
// workerEnv to produce a real, cancellable Future driven by a
|
|
// time.Timer. Exported so the worker package (a sibling) can reuse
|
|
// the same implementation without reaching into package internals.
|
|
//
|
|
// - d <= 0 settles the Future with (nil, nil) immediately.
|
|
// - Otherwise, a per-timer goroutine parks on the timer's channel
|
|
// alongside the supplied doneCh. If doneCh closes first, the Future
|
|
// settles with errFn() — typically the scope's cancel error.
|
|
//
|
|
// Cancellation semantics: a closed doneCh always wins over a fired
|
|
// timer in the rare race where both are observable simultaneously.
|
|
func NewWallClockTimer(d time.Duration, doneCh <-chan struct{}, errFn func() error) Future {
|
|
f := NewFuture()
|
|
if d <= 0 {
|
|
f.Settle(nil, nil)
|
|
return f
|
|
}
|
|
go func() {
|
|
t := time.NewTimer(d)
|
|
defer t.Stop()
|
|
select {
|
|
case <-doneCh:
|
|
err := errFn()
|
|
if err == nil {
|
|
err = temporal.NewCanceledError()
|
|
}
|
|
f.Settle(nil, err)
|
|
case <-t.C:
|
|
// Re-check cancellation: if both fired in the same window
|
|
// prefer cancellation so a cancel never "loses" to a timer.
|
|
select {
|
|
case <-doneCh:
|
|
err := errFn()
|
|
if err == nil {
|
|
err = temporal.NewCanceledError()
|
|
}
|
|
f.Settle(nil, err)
|
|
default:
|
|
f.Settle(nil, nil)
|
|
}
|
|
}
|
|
}()
|
|
return f
|
|
}
|
|
|
|
// SelectFanIn is the shared edge-triggered Select implementation.
|
|
// Exported for the worker env to reuse without duplication. User
|
|
// code should not call it directly — use workflow.NewSelector.
|
|
//
|
|
// It blocks until exactly one case in cases is ready, or until
|
|
// doneCh closes, and returns the ready case index (or -1 on
|
|
// cancellation / empty input).
|
|
//
|
|
// Implementation: each case parks on a readiness source —
|
|
// - Future cases: the Future.ReadyCh() (closed on Settle).
|
|
// - chanImpl cases: a single-buffer chan struct{} registered via
|
|
// RegisterReadyWaker; the channel fires once when the chan
|
|
// becomes readable (buffered value, parked unbuffered sender, or
|
|
// closed).
|
|
//
|
|
// A small goroutine per case translates that signal into an index
|
|
// send on wakeCh. The main routine receives once from wakeCh and
|
|
// returns. On wake we re-scan for the earliest-ready case to preserve
|
|
// deterministic ordering across unrelated simultaneous ready events.
|
|
//
|
|
// No polling. No time.Ticker. No 1 ms spin.
|
|
func SelectFanIn(cases []SelectCase, doneCh <-chan struct{}) int {
|
|
if len(cases) == 0 {
|
|
return -1
|
|
}
|
|
|
|
// Fast path: if any case is already ready, fire it without spinning
|
|
// up goroutines.
|
|
if idx := readyIndex(cases); idx >= 0 {
|
|
return idx
|
|
}
|
|
|
|
wakeCh := make(chan int, len(cases)+1)
|
|
stopCh := make(chan struct{})
|
|
defer close(stopCh)
|
|
|
|
for i, c := range cases {
|
|
i, c := i, c
|
|
switch {
|
|
case c.Future != nil:
|
|
ready := c.Future.ReadyCh()
|
|
go func() {
|
|
select {
|
|
case <-ready:
|
|
select {
|
|
case wakeCh <- i:
|
|
case <-stopCh:
|
|
}
|
|
case <-stopCh:
|
|
}
|
|
}()
|
|
case c.Channel != nil:
|
|
if ch, ok := c.Channel.(*chanImpl); ok {
|
|
w := make(chan struct{}, 1)
|
|
ch.RegisterReadyWaker(w)
|
|
go func() {
|
|
select {
|
|
case <-w:
|
|
select {
|
|
case wakeCh <- i:
|
|
case <-stopCh:
|
|
}
|
|
case <-stopCh:
|
|
}
|
|
}()
|
|
} else {
|
|
// Unknown channel shape: fall back to a tiny poll
|
|
// (should not occur — all channels flow through chanImpl).
|
|
go func() {
|
|
ticker := time.NewTicker(50 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-stopCh:
|
|
return
|
|
case <-ticker.C:
|
|
if c.Channel != nil {
|
|
if hv, ok := c.Channel.(interface{ HasValue() bool }); ok && hv.HasValue() {
|
|
select {
|
|
case wakeCh <- i:
|
|
case <-stopCh:
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
}
|
|
|
|
select {
|
|
case idx := <-wakeCh:
|
|
if idx < 0 || idx >= len(cases) {
|
|
return -1
|
|
}
|
|
// Prefer the earliest-ready case for deterministic ordering
|
|
// when multiple cases became ready very close together.
|
|
if better := readyIndex(cases); better >= 0 && better < idx {
|
|
return better
|
|
}
|
|
return idx
|
|
case <-doneCh:
|
|
return -1
|
|
}
|
|
}
|