Repository: inngest/inngest
Pull request #2459: Add singletons support
Author: login: KiKoS0
Created: 2025-05-23T17:42:42Z | Merged: 2025-05-29T08:56:00Z

## Description

Add an initial version of singletons:
- Singleton config (some of which should be moved `inngestgo`)
- Altering scheduling to ignore events when a singleton instance is already running.




## Type of change (choose one)
- [ ] Chore (refactors, upgrades, etc.)
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] Security fix (non-breaking change that fixes a potential vulnerability)
- [ ] Docs
- [x] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected)

## Checklist
- [ ] I've linked any associated issues to this PR.
- [ ] I've tested my own changes.

*[Check our Pull Request Guidelines](https://github.com/inngest/inngest/blob/main/docs/PULL_REQUEST_GUIDELINES.md)*



Diff:
diff --git a/pkg/devserver/devserver.go b/pkg/devserver/devserver.go
index f2bf5710cf..da88691a72 100644
--- a/pkg/devserver/devserver.go
+++ b/pkg/devserver/devserver.go
@@ -45,6 +45,7 @@ import (
 	"github.com/inngest/inngest/pkg/execution/ratelimit"
 	"github.com/inngest/inngest/pkg/execution/realtime"
 	"github.com/inngest/inngest/pkg/execution/runner"
+	"github.com/inngest/inngest/pkg/execution/singleton"
 	"github.com/inngest/inngest/pkg/execution/state"
 	"github.com/inngest/inngest/pkg/execution/state/redis_state"
 	sv2 "github.com/inngest/inngest/pkg/execution/state/v2"
@@ -316,6 +317,8 @@ func start(ctx context.Context, opts StartOpts) error {
 	batcher := batch.NewRedisBatchManager(shardedClient.Batch(), rq, batch.WithLogger(l))
 	debouncer := debounce.NewRedisDebouncer(unshardedClient.Debounce(), queueShard, rq)
 
+	sn := singleton.New(ctx, queueShard.RedisClient)
+
 	conditionalTracer := itrace.NewConditionalTracer(itrace.ConnectTracer(), itrace.AlwaysTrace)
 
 	connectPubSubRedis := createConnectPubSubRedis()
@@ -412,6 +415,7 @@ func start(ctx context.Context, opts StartOpts) error {
 		executor.WithInvokeFailHandler(getInvokeFailHandler(ctx, pb, opts.Config.EventStream.Service.Concrete.TopicName())),
 		executor.WithSendingEventHandler(getSendingEventHandler(ctx, pb, opts.Config.EventStream.Service.Concrete.TopicName())),
 		executor.WithDebouncer(debouncer),
+		executor.WithSingletonManager(sn),
 		executor.WithBatcher(batcher),
 		executor.WithAssignedQueueShard(queueShard),
 		executor.WithShardSelector(shardSelector),
diff --git a/pkg/enums/singleton_mode.go b/pkg/enums/singleton_mode.go
new file mode 100644
index 0000000000..5916c1771b
--- /dev/null
+++ b/pkg/enums/singleton_mode.go
@@ -0,0 +1,13 @@
+//go:generate go run github.com/dmarkham/enumer -trimprefix=SingletonMode -type=SingletonMode -transform=snake -json -text -gqlgen
+
+package enums
+
+type SingletonMode int
+
+const (
+	// SingletonModeSkip skips the new run if another singleton instance is already in progress.
+	SingletonModeSkip SingletonMode = iota
+
+	// SingletonModeCancel cancels the currently running singleton instance and starts the new one.
+	SingletonModeCancel
+)
diff --git a/pkg/enums/singletonmode_enumer.go b/pkg/enums/singletonmode_enumer.go
new file mode 100644
index 0000000000..f5a5c4cfc4
--- /dev/null
+++ b/pkg/enums/singletonmode_enumer.go
@@ -0,0 +1,127 @@
+// Code generated by "enumer -trimprefix=SingletonMode -type=SingletonMode -transform=snake -json -text -gqlgen"; DO NOT EDIT.
+
+package enums
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"strconv"
+	"strings"
+)
+
+const _SingletonModeName = "skipcancel"
+
+var _SingletonModeIndex = [...]uint8{0, 4, 10}
+
+const _SingletonModeLowerName = "skipcancel"
+
+func (i SingletonMode) String() string {
+	if i < 0 || i >= SingletonMode(len(_SingletonModeIndex)-1) {
+		return fmt.Sprintf("SingletonMode(%d)", i)
+	}
+	return _SingletonModeName[_SingletonModeIndex[i]:_SingletonModeIndex[i+1]]
+}
+
+// An "invalid array index" compiler error signifies that the constant values have changed.
+// Re-run the stringer command to generate them again.
+func _SingletonModeNoOp() {
+	var x [1]struct{}
+	_ = x[SingletonModeSkip-(0)]
+	_ = x[SingletonModeCancel-(1)]
+}
+
+var _SingletonModeValues = []SingletonMode{SingletonModeSkip, SingletonModeCancel}
+
+var _SingletonModeNameToValueMap = map[string]SingletonMode{
+	_SingletonModeName[0:4]:       SingletonModeSkip,
+	_SingletonModeLowerName[0:4]:  SingletonModeSkip,
+	_SingletonModeName[4:10]:      SingletonModeCancel,
+	_SingletonModeLowerName[4:10]: SingletonModeCancel,
+}
+
+var _SingletonModeNames = []string{
+	_SingletonModeName[0:4],
+	_SingletonModeName[4:10],
+}
+
+// SingletonModeString retrieves an enum value from the enum constants string name.
+// Throws an error if the param is not part of the enum.
+func SingletonModeString(s string) (SingletonMode, error) {
+	if val, ok := _SingletonModeNameToValueMap[s]; ok {
+		return val, nil
+	}
+
+	if val, ok := _SingletonModeNameToValueMap[strings.ToLower(s)]; ok {
+		return val, nil
+	}
+	return 0, fmt.Errorf("%s does not belong to SingletonMode values", s)
+}
+
+// SingletonModeValues returns all values of the enum
+func SingletonModeValues() []SingletonMode {
+	return _SingletonModeValues
+}
+
+// SingletonModeStrings returns a slice of all String values of the enum
+func SingletonModeStrings() []string {
+	strs := make([]string, len(_SingletonModeNames))
+	copy(strs, _SingletonModeNames)
+	return strs
+}
+
+// IsASingletonMode returns "true" if the value is listed in the enum definition. "false" otherwise
+func (i SingletonMode) IsASingletonMode() bool {
+	for _, v := range _SingletonModeValues {
+		if i == v {
+			return true
+		}
+	}
+	return false
+}
+
+// MarshalJSON implements the json.Marshaler interface for SingletonMode
+func (i SingletonMode) MarshalJSON() ([]byte, error) {
+	return json.Marshal(i.String())
+}
+
+// UnmarshalJSON implements the json.Unmarshaler interface for SingletonMode
+func (i *SingletonMode) UnmarshalJSON(data []byte) error {
+	var s string
+	if err := json.Unmarshal(data, &s); err != nil {
+		return fmt.Errorf("SingletonMode should be a string, got %s", data)
+	}
+
+	var err error
+	*i, err = SingletonModeString(s)
+	return err
+}
+
+// MarshalText implements the encoding.TextMarshaler interface for SingletonMode
+func (i SingletonMode) MarshalText() ([]byte, error) {
+	return []byte(i.String()), nil
+}
+
+// UnmarshalText implements the encoding.TextUnmarshaler interface for SingletonMode
+func (i *SingletonMode) UnmarshalText(text []byte) error {
+	var err error
+	*i, err = SingletonModeString(string(text))
+	return err
+}
+
+// MarshalGQL implements the graphql.Marshaler interface for SingletonMode
+func (i SingletonMode) MarshalGQL(w io.Writer) {
+	fmt.Fprint(w, strconv.Quote(i.String()))
+}
+
+// UnmarshalGQL implements the graphql.Unmarshaler interface for SingletonMode
+func (i *SingletonMode) UnmarshalGQL(value interface{}) error {
+	str, ok := value.(string)
+	if !ok {
+		return fmt.Errorf("SingletonMode should be a string, got %T", value)
+	}
+
+	var err error
+	*i, err = SingletonModeString(str)
+	return err
+}
diff --git a/pkg/enums/skipreason.go b/pkg/enums/skipreason.go
index 7d07cb2a0b..db5737556c 100644
--- a/pkg/enums/skipreason.go
+++ b/pkg/enums/skipreason.go
@@ -10,4 +10,8 @@ const (
 
 	// SkipReasonFunctionPaused indicates that the function was paused.
 	SkipReasonFunctionPaused
+
+	// SkipReasonSingleton indicates that the run was skipped because another
+	// run of the same singleton function was already in progress.
+	SkipReasonSingleton
 )
diff --git a/pkg/enums/skipreason_enumer.go b/pkg/enums/skipreason_enumer.go
index 1fa98d67cf..18f28d8b7d 100644
--- a/pkg/enums/skipreason_enumer.go
+++ b/pkg/enums/skipreason_enumer.go
@@ -10,11 +10,11 @@ import (
 	"strings"
 )
 
-const _SkipReasonName = "NoneFunctionPaused"
+const _SkipReasonName = "NoneFunctionPausedSingleton"
 
-var _SkipReasonIndex = [...]uint8{0, 4, 18}
+var _SkipReasonIndex = [...]uint8{0, 4, 18, 27}
 
-const _SkipReasonLowerName = "nonefunctionpaused"
+const _SkipReasonLowerName = "nonefunctionpausedsingleton"
 
 func (i SkipReason) String() string {
 	if i < 0 || i >= SkipReason(len(_SkipReasonIndex)-1) {
@@ -29,20 +29,24 @@ func _SkipReasonNoOp() {
 	var x [1]struct{}
 	_ = x[SkipReasonNone-(0)]
 	_ = x[SkipReasonFunctionPaused-(1)]
+	_ = x[SkipReasonSingleton-(2)]
 }
 
-var _SkipReasonValues = []SkipReason{SkipReasonNone, SkipReasonFunctionPaused}
+var _SkipReasonValues = []SkipReason{SkipReasonNone, SkipReasonFunctionPaused, SkipReasonSingleton}
 
 var _SkipReasonNameToValueMap = map[string]SkipReason{
-	_SkipReasonName[0:4]:       SkipReasonNone,
-	_SkipReasonLowerName[0:4]:  SkipReasonNone,
-	_SkipReasonName[4:18]:      SkipReasonFunctionPaused,
-	_SkipReasonLowerName[4:18]: SkipReasonFunctionPaused,
+	_SkipReasonName[0:4]:        SkipReasonNone,
+	_SkipReasonLowerName[0:4]:   SkipReasonNone,
+	_SkipReasonName[4:18]:       SkipReasonFunctionPaused,
+	_SkipReasonLowerName[4:18]:  SkipReasonFunctionPaused,
+	_SkipReasonName[18:27]:      SkipReasonSingleton,
+	_SkipReasonLowerName[18:27]: SkipReasonSingleton,
 }
 
 var _SkipReasonNames = []string{
 	_SkipReasonName[0:4],
 	_SkipReasonName[4:18],
+	_SkipReasonName[18:27],
 }
 
 // SkipReasonString retrieves an enum value from the enum constants string name.
diff --git a/pkg/execution/executor/executor.go b/pkg/execution/executor/executor.go
index 7f9c5b91db..ef1ad2323c 100644
--- a/pkg/execution/executor/executor.go
+++ b/pkg/execution/executor/executor.go
@@ -27,6 +27,7 @@ import (
 	"github.com/inngest/inngest/pkg/execution/driver/httpdriver"
 	"github.com/inngest/inngest/pkg/execution/queue"
 	"github.com/inngest/inngest/pkg/execution/realtime"
+	"github.com/inngest/inngest/pkg/execution/singleton"
 	"github.com/inngest/inngest/pkg/execution/state"
 	"github.com/inngest/inngest/pkg/execution/state/redis_state"
 	sv2 "github.com/inngest/inngest/pkg/execution/state/v2"
@@ -220,6 +221,12 @@ func WithDebouncer(d debounce.Debouncer) ExecutorOpt {
 	}
 }
 
+func WithSingletonManager(sn singleton.Singleton) ExecutorOpt {
+	return func(e execution.Executor) error {
+		e.(*executor).singletonMgr = sn
+		return nil
+	}
+}
 func WithBatcher(b batch.BatchManager) ExecutorOpt {
 	return func(e execution.Executor) error {
 		e.(*executor).batcher = b
@@ -303,6 +310,7 @@ type executor struct {
 	queue               queue.Queue
 	debouncer           debounce.Debouncer
 	batcher             batch.BatchManager
+	singletonMgr        singleton.Singleton
 	fl                  state.FunctionLoader
 	evalFactory         func(ctx context.Context, expr string) (expressions.Evaluator, error)
 	runtimeDrivers      map[string]driver.Driver
@@ -500,14 +508,7 @@ func (e *executor) Schedule(ctx context.Context, req execution.ScheduleRequest)
 	// If this is paused, immediately end just before creating state.
 	isPaused := req.FunctionPausedAt != nil && req.FunctionPausedAt.Before(time.Now())
 	if isPaused {
-		for _, e := range e.lifecycles {
-			go e.OnFunctionSkipped(context.WithoutCancel(ctx), metadata, execution.SkipState{
-				CronSchedule: req.Events[0].GetEvent().CronSchedule(),
-				Reason:       enums.SkipReasonFunctionPaused,
-				Events:       evts,
-			})
-		}
-		return nil, ErrFunctionSkipped
+		return e.handleFunctionSkipped(ctx, req, metadata, evts, enums.SkipReasonFunctionPaused)
 	}
 
 	mapped := make([]map[string]any, len(req.Events))
@@ -525,6 +526,40 @@ func (e *executor) Schedule(ctx context.Context, req execution.ScheduleRequest)
 	//
 	throttle := queue.GetThrottleConfig(ctx, req.Function.ID, req.Function.Throttle, evtMap)
 
+	//
+	// Create singleton information and try to handle it prior to creating state.
+	//
+	var singletonConfig *queue.Singleton
+	data := req.Events[0].GetEvent().Map()
+
+	// Ignores Cancel mode for now
+	if req.Function.Singleton != nil && req.Function.Singleton.Mode == enums.SingletonModeSkip {
+		singletonKey, err := singleton.SingletonKey(ctx, req.Function.ID, *req.Function.Singleton, data)
+		switch {
+		case err == nil:
+			// Attempt to early handle function singletons, function runs could still fail
+			// to enqueue later on when it atomically tries to acquire a function mutex.
+			alreadyRunning, err := e.singletonMgr.Singleton(ctx, singletonKey, *req.Function.Singleton)
+
+			if err != nil {
+				return nil, err
+			}
+
+			if alreadyRunning {
+				// TODO: Handle cancellation mode
+
+				// Immediately end before creating state
+				return e.handleFunctionSkipped(ctx, req, metadata, evts, enums.SkipReasonSingleton)
+			}
+
+			singletonConfig = &queue.Singleton{Key: singletonKey}
+		case errors.Is(err, singleton.ErrNotASingleton):
+			// We no-op, and we run the function normally not as a singleton
+		default:
+			return nil, err
+		}
+	}
+
 	//
 	// Create the run state.
 	//
@@ -660,13 +695,28 @@ func (e *executor) Schedule(ctx context.Context, req execution.ScheduleRequest)
 		Payload: queue.PayloadEdge{
 			Edge: inngest.SourceEdge,
 		},
-		Throttle: throttle,
+		Throttle:  throttle,
+		Singleton: singletonConfig,
 	}
 	err = e.queue.Enqueue(ctx, item, at, queue.EnqueueOpts{})
-	if err == redis_state.ErrQueueItemExists {
+
+	switch err {
+	case nil:
+		// no-op
+	case redis_state.ErrQueueItemExists:
 		return nil, state.ErrIdentifierExists
-	}
-	if err != nil {
+
+	case redis_state.ErrQueueItemSingletonExists:
+		_, err := e.smv2.Delete(ctx, sv2.IDFromV1(st.Identifier()))
+		if err != nil {
+			logger.StdlibLogger(ctx).Error(
+				"error deleting function state",
+				"error", err,
+			)
+		}
+		return e.handleFunctionSkipped(ctx, req, metadata, evts, enums.SkipReasonSingleton)
+
+	default:
 		return nil, fmt.Errorf("error enqueueing source edge '%v': %w", queueKey, err)
 	}
 
@@ -677,6 +727,17 @@ func (e *executor) Schedule(ctx context.Context, req execution.ScheduleRequest)
 	return &metadata, nil
 }
 
+func (e *executor) handleFunctionSkipped(ctx context.Context, req execution.ScheduleRequest, metadata sv2.Metadata, evts []json.RawMessage, reason enums.SkipReason) (*sv2.Metadata, error) {
+	for _, e := range e.lifecycles {
+		go e.OnFunctionSkipped(context.WithoutCancel(ctx), metadata, execution.SkipState{
+			CronSchedule: req.Events[0].GetEvent().CronSchedule(),
+			Reason:       reason,
+			Events:       evts,
+		})
+	}
+	return nil, ErrFunctionSkipped
+}
+
 type runInstance struct {
 	md         sv2.Metadata
 	f          inngest.Function
diff --git a/pkg/execution/queue/item.go b/pkg/execution/queue/item.go
index ff952634d8..9156eff089 100644
--- a/pkg/execution/queue/item.go
+++ b/pkg/execution/queue/item.go
@@ -4,12 +4,13 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"strconv"
+	"time"
+
 	"github.com/inngest/inngest/pkg/enums"
 	sv2 "github.com/inngest/inngest/pkg/execution/state/v2"
 	"github.com/inngest/inngest/pkg/expressions"
 	"github.com/inngest/inngest/pkg/util"
-	"strconv"
-	"time"
 
 	"github.com/cespare/xxhash/v2"
 	"github.com/google/uuid"
@@ -189,6 +190,9 @@ type Item struct {
 	// Throttle represents GCRA rate limiting for the queue item, which is applied when
 	// attempting to lease the item from the queue.
 	Throttle *Throttle `json:"throttle,omitempty"`
+	// Singleton represents a singleton key for the queue item, which is used to
+	// not allow multiple start items to be scheduled at the same time.
+	Singleton *Singleton `json:"singleton,omitempty"`
 	// CustomConcurrencyKeys stores custom concurrency keys for this function run.  This
 	// allows us to use custom concurrency keys for each job when processing steps for
 	// the function, with cached expression results.
@@ -222,6 +226,16 @@ type Throttle struct {
 	KeyExpressionHash string `json:"-"`
 }
 
+type Singleton struct {
+	// Key is the unique singleton key that's used to group queue items when
+	// processing singleton items.
+	Key string `json:"k"`
+
+	// Mode defines the behavior when a new singleton run is queued while another is active.
+	// It determines whether to skip the new run or cancel the current one and replace it.
+	Mode enums.SingletonMode `json:"m"`
+}
+
 // SpanID generates a spanID based on the combination the jobID and attempt
 func (i Item) SpanID() (*trace.SpanID, error) {
 	if i.JobID == nil {
@@ -303,6 +317,7 @@ func (i *Item) UnmarshalJSON(b []byte) error {
 		QueueName             *string                   `json:"qn,omitempty"`
 		RunInfo               *RunInfo                  `json:"runinfo,omitempty"`
 		Throttle              *Throttle                 `json:"throttle"`
+		Singleton             *Singleton                `json:"singleton"`
 		CustomConcurrencyKeys []state.CustomConcurrency `json:"cck,omitempty"`
 		PriorityFactor        *int64                    `json:"pf,omitempty"`
 	}
@@ -320,6 +335,7 @@ func (i *Item) UnmarshalJSON(b []byte) error {
 	i.MaxAttempts = temp.MaxAttempts
 	i.Metadata = temp.Metadata
 	i.Throttle = temp.Throttle
+	i.Singleton = temp.Singleton
 	i.RunInfo = temp.RunInfo
 	i.CustomConcurrencyKeys = temp.CustomConcurrencyKeys
 	i.PriorityFactor = temp.PriorityFactor
diff --git a/pkg/execution/singleton/redis.go b/pkg/execution/singleton/redis.go
new file mode 100644
index 0000000000..412ec65a9a
--- /dev/null
+++ b/pkg/execution/singleton/redis.go
@@ -0,0 +1,32 @@
+package singleton
+
+import (
+	"context"
+	"github.com/inngest/inngest/pkg/execution/queue"
+	"github.com/inngest/inngest/pkg/execution/state/redis_state"
+	"github.com/inngest/inngest/pkg/inngest"
+)
+
+func New(ctx context.Context, r *redis_state.QueueClient) Singleton {
+	return &redisStore{r: r}
+}
+
+type redisStore struct {
+	r *redis_state.QueueClient
+}
+
+func (r *redisStore) Singleton(ctx context.Context, key string, s inngest.Singleton) (bool, error) {
+	return singleton(ctx, r, key, s)
+}
+
+func (r *redisStore) Exists(ctx context.Context, key string) (bool, error) {
+	key = r.r.KeyGenerator().SingletonKey(&queue.Singleton{Key: key})
+
+	client := r.r.Client()
+	// result will be either 0 or 1
+	result, err := r.r.Client().Do(ctx, client.B().Exists().Key(key).Build()).AsInt64()
+	if err != nil {
+		return false, err
+	}
+	return result > 0, nil
+}
diff --git a/pkg/execution/singleton/singleton.go b/pkg/execution/singleton/singleton.go
new file mode 100644
index 0000000000..b726a58b5e
--- /dev/null
+++ b/pkg/execution/singleton/singleton.go
@@ -0,0 +1,55 @@
+package singleton
+
+import (
+	"context"
+	"fmt"
+	"log"
+
+	"github.com/google/uuid"
+	"github.com/inngest/inngest/pkg/expressions"
+	"github.com/inngest/inngest/pkg/inngest"
+	"github.com/inngest/inngest/pkg/util"
+)
+
+var (
+	ErrEvaluatingSingletonExpression = fmt.Errorf("singleton expression evaluation failed")
+	ErrNotASingleton                 = fmt.Errorf("singleton expression resolved to false")
+)
+
+type Singleton interface {
+	Singleton(ctx context.Context, key string, c inngest.Singleton) (bool, error)
+}
+
+// SingletonKey returns the singleton key given a function ID, singleton config,
+// and incoming event data.
+func SingletonKey(ctx context.Context, id uuid.UUID, c inngest.Singleton, evt map[string]any) (string, error) {
+	if c.Key == nil {
+		return id.String(), nil
+	}
+	eval, err := expressions.NewExpressionEvaluator(ctx, *c.Key)
+	if err != nil {
+		return "", ErrEvaluatingSingletonExpression
+	}
+	res, _, err := eval.Evaluate(ctx, expressions.NewData(map[string]any{"event": evt}))
+	if err != nil {
+		return "", ErrEvaluatingSingletonExpression
+	}
+	if v, ok := res.(bool); ok && !v {
+		return "", ErrNotASingleton
+	}
+
+	return hash(res, id), nil
+}
+
+func hash(res any, id uuid.UUID) string {
+	sum := util.XXHash(res)
+	return fmt.Sprintf("%s-%s", id, sum)
+}
+
+func singleton(ctx context.Context, store SingletonStore, key string, s inngest.Singleton) (bool, error) {
+	result, err := store.Exists(ctx, key)
+	if err != nil {
+		log.Fatal(err)
+	}
+	return result, err
+}
diff --git a/pkg/execution/singleton/store.go b/pkg/execution/singleton/store.go
new file mode 100644
index 0000000000..4ad35e5494
--- /dev/null
+++ b/pkg/execution/singleton/store.go
@@ -0,0 +1,9 @@
+package singleton
+
+import (
+	"context"
+)
+
+type SingletonStore interface {
+	Exists(ctx context.Context, key string) (bool, error)
+}
diff --git a/pkg/execution/state/redis_state/key_generator.go b/pkg/execution/state/redis_state/key_generator.go
index 496f69a1d9..fdcccfea7d 100644
--- a/pkg/execution/state/redis_state/key_generator.go
+++ b/pkg/execution/state/redis_state/key_generator.go
@@ -240,6 +240,11 @@ type QueueKeyGenerator interface {
 	ThrottleKey(t *osqueue.Throttle) string
 	// RunIndex returns the index for storing job IDs associated with run IDs.
 	RunIndex(runID ulid.ULID) string
+	// SingletonKey returns the singleton key for a given queue item.
+	SingletonKey(s *osqueue.Singleton) string
+	// SingletonRunKey returns the singleton run id key that stores the singleton key for a given run.
+	SingletonRunKey(r string) string
+
 	// FnMetadata returns the key for a function's metadata.
 	// This is a JSON object; see queue.FnMetadata.
 	FnMetadata(fnID uuid.UUID) string
@@ -295,6 +300,18 @@ func (u queueKeyGenerator) ThrottleKey(t *osqueue.Throttle) string {
 	return fmt.Sprintf("{%s}:throttle:%s", u.queueDefaultKey, t.Key)
 }
 
+func (u queueKeyGenerator) SingletonKey(s *osqueue.Singleton) string {
+	if s == nil || s.Key == "" {
+		return fmt.Sprintf("{%s}:singleton:-", u.queueDefaultKey)
+	}
+
+	return fmt.Sprintf("{%s}:singleton:%s", u.queueDefaultKey, s.Key)
+}
+
+func (u queueKeyGenerator) SingletonRunKey(runID string) string {
+	return fmt.Sprintf("{%s}:singleton-run:%s", u.queueDefaultKey, runID)
+}
+
 func (u queueKeyGenerator) PartitionMeta(id string) string {
 	return fmt.Sprintf("{%s}:partition:meta:%s", u.queueDefaultKey, id)
 }
diff --git a/pkg/execution/state/redis_state/lua/queue/dequeue.lua b/pkg/execution/state/redis_state/lua/queue/dequeue.lua
index ae56084eda..53c3352091 100644
--- a/pkg/execution/state/redis_state/lua/queue/dequeue.lua
+++ b/pkg/execution/state/redis_state/lua/queue/dequeue.lua
@@ -37,8 +37,10 @@ local keyIndexActivePartitionRuns  = KEYS[23]
 
 local keyIdempotency           = KEYS[24]
 
-local keyItemIndexA            = KEYS[25]   -- custom item index 1
-local keyItemIndexB            = KEYS[26]  -- custom item index 2
+local singletonRunKey          = KEYS[25]
+
+local keyItemIndexA            = KEYS[26]   -- custom item index 1
+local keyItemIndexB            = KEYS[27]  -- custom item index 2
 
 local queueID        = ARGV[1]
 local partitionID    = ARGV[2]
@@ -194,4 +196,19 @@ if backlogScore ~= nil and backlogScore ~= false and backlogScore > 0 then
   updateBacklogPointer(keyGlobalShadowPartitionSet, keyGlobalAccountShadowPartitionSet, keyAccountShadowPartitionSet, keyShadowPartitionSet, keyBacklogSet, accountID, partitionID, backlogID)
 end
 
+
+-- Remove singleton lock
+local singletonKey = redis.call("GET", singletonRunKey)
+
+if singletonKey ~= nil and singletonKey ~= false and keyItemIndexA ~= "" and keyItemIndexA ~= false and keyItemIndexA ~= nil then
+  local queueItemsCount = redis.call("ZCOUNT", keyItemIndexA, "-inf", "+inf")
+  local singletonRunID = redis.call("GET", singletonKey)
+
+  if tonumber(queueItemsCount) == 0 and singletonRunID == runID then
+    -- We just dequeued the last step
+    redis.call("DEL", singletonKey)
+    redis.call("DEL", singletonRunKey)
+  end
+end
+
 return 0
diff --git a/pkg/execution/state/redis_state/lua/queue/enqueue.lua b/pkg/execution/state/redis_state/lua/queue/enqueue.lua
index 4ce572e8b3..09fb351f47 100644
--- a/pkg/execution/state/redis_state/lua/queue/enqueue.lua
+++ b/pkg/execution/state/redis_state/lua/queue/enqueue.lua
@@ -28,8 +28,11 @@ local keyPartitionNormalizeSet           = KEYS[17]
 local keyAccountNormalizeSet             = KEYS[18]
 local keyGlobalNormalizeSet              = KEYS[19]
 
-local keyItemIndexA           	= KEYS[20]          -- custom item index 1
-local keyItemIndexB           	= KEYS[21]          -- custom item index 2
+local singletonRunKey           	  = KEYS[20]
+local singletonKey           	  = KEYS[21]
+
+local keyItemIndexA           	= KEYS[22]          -- custom item index 1
+local keyItemIndexB           	= KEYS[23]          -- custom item index 2
 
 local queueItem           		= ARGV[1]           -- {id, lease id, attempt, max attempt, data, etc...}
 local queueID             		= ARGV[2]           -- id
@@ -40,13 +43,14 @@ local fnMetadata          		= ARGV[6]          -- function meta: {paused}
 local partitionItem      		  = ARGV[7]
 local partitionID        		  = ARGV[8]
 local accountID           		= ARGV[9]
+local runID                     = ARGV[10]
 
 -- Key queues v2
-local enqueueToBacklog				= tonumber(ARGV[10])
-local shadowPartitionItem     = ARGV[11]
-local backlogItem             = ARGV[12]
-local backlogID               = ARGV[13]
-local normalizeFromBacklogID  = ARGV[14]
+local enqueueToBacklog				= tonumber(ARGV[11])
+local shadowPartitionItem     = ARGV[12]
+local backlogItem             = ARGV[13]
+local backlogID               = ARGV[14]
+local normalizeFromBacklogID  = ARGV[15]
 
 -- $include(update_pointer_score.lua)
 -- $include(ends_with.lua)
@@ -69,6 +73,17 @@ if redis.call("HSETNX", queueKey, queueID, queueItem) == 0 and not is_normalize
   return 1
 end
 
+-- Check if the item is a singleton and if an existing item already exists
+if exists_without_ending(singletonKey, ":singleton:-") then 
+  if redis.call("EXISTS", singletonKey) ~= 0 then
+    return 2
+  end
+
+  -- Set the singleton key to the item ID
+  redis.call("SET", singletonRunKey, singletonKey)
+  redis.call("SET", singletonKey, runID)
+end
+
 if enqueueToBacklog == 1 then
 	enqueue_to_backlog(keyBacklogSet, backlogID, backlogItem, partitionID, shadowPartitionItem, partitionItem, keyPartitionMap, keyBacklogMeta, keyGlobalShadowPartitionSet, keyShadowPartitionMeta, keyShadowPartitionSet, keyGlobalAccountShadowPartitionSet, keyAccountShadowPartitionSet, queueScore, queueID, partitionTime, nowMS, accountID)
 else
diff --git a/pkg/execution/state/redis_state/queue.go b/pkg/execution/state/redis_state/queue.go
index b4e2b489f3..13da6a824a 100644
--- a/pkg/execution/state/redis_state/queue.go
+++ b/pkg/execution/state/redis_state/queue.go
@@ -137,6 +137,7 @@ var (
 	ErrQueueItemLeaseMismatch        = fmt.Errorf("item lease does not match")
 	ErrQueueItemNotLeased            = fmt.Errorf("queue item is not leased")
 	ErrQueuePeekMaxExceedsLimits     = fmt.Errorf("peek exceeded the maximum limit of %d", AbsoluteQueuePeekMax)
+	ErrQueueItemSingletonExists      = fmt.Errorf("singleton item already exists")
 	ErrPriorityTooLow                = fmt.Errorf("priority is too low")
 	ErrPriorityTooHigh               = fmt.Errorf("priority is too high")
 	ErrPartitionNotFound             = fmt.Errorf("partition not found")
@@ -1336,6 +1337,10 @@ func (q *queue) EnqueueItem(ctx context.Context, shard QueueShard, i osqueue.Que
 		kg.PartitionNormalizeSet(shadowPartition.PartitionID),
 		kg.AccountNormalizeSet(i.Data.Identifier.AccountID),
 		kg.GlobalAccountNormalizeSet(),
+
+		// Singletons
+		kg.SingletonRunKey(i.Data.Identifier.RunID.String()),
+		kg.SingletonKey(i.Data.Singleton),
 	}
 	// Append indexes
 	for _, idx := range q.itemIndexer(ctx, i, shard.RedisClient.kg) {
@@ -1364,6 +1369,7 @@ func (q *queue) EnqueueItem(ctx context.Context, shard QueueShard, i osqueue.Que
 		defaultPartition,
 		defaultPartition.ID,
 		i.Data.Identifier.AccountID.String(),
+		i.Data.Identifier.RunID.String(),
 
 		enqueueToBacklogsVal,
 		shadowPartition,
@@ -1399,6 +1405,8 @@ func (q *queue) EnqueueItem(ctx context.Context, shard QueueShard, i osqueue.Que
 		return i, nil
 	case 1:
 		return i, ErrQueueItemExists
+	case 2:
+		return i, ErrQueueItemSingletonExists
 	default:
 		return i, fmt.Errorf("unknown response enqueueing item: %v (%T)", status, status)
 	}
@@ -2505,6 +2513,9 @@ func (q *queue) Dequeue(ctx context.Context, queueShard QueueShard, i osqueue.Qu
 		kg.ActivePartitionRunsIndex(partition.PartitionID),
 
 		kg.Idempotency(i.ID),
+
+		// Singleton
+		kg.SingletonRunKey(i.Data.Identifier.RunID.String()),
 	}
 	// Append indexes
 	for _, idx := range q.itemIndexer(ctx, i, queueShard.RedisClient.kg) {
diff --git a/pkg/execution/state/redis_state/queue_test.go b/pkg/execution/state/redis_state/queue_test.go
index 25c5ef9198..923020fc6b 100644
--- a/pkg/execution/state/redis_state/queue_test.go
+++ b/pkg/execution/state/redis_state/queue_test.go
@@ -7051,6 +7051,85 @@ func TestQueueDequeueUpdateAccounting(t *testing.T) {
 	})
 }
 
+func TestQueueEnqueueItemSingleton(t *testing.T) {
+	r := miniredis.RunT(t)
+	rc, err := rueidis.NewClient(rueidis.ClientOption{
+		InitAddress:  []string{r.Addr()},
+		DisableCache: true,
+	})
+	require.NoError(t, err)
+	defer rc.Close()
+
+	q := NewQueue(QueueShard{Kind: string(enums.QueueShardKindRedis), RedisClient: NewQueueClient(rc, QueueDefaultKey), Name: consts.DefaultQueueShardName})
+	ctx := context.Background()
+
+	start := time.Now().Truncate(time.Second)
+
+	t.Run("It enqueues an item only once until the last item in a function run is dequeued", func(t *testing.T) {
+		key := "example"
+		runId := ulid.MustNew(ulid.Now(), rand.Reader)
+		qi1 := osqueue.QueueItem{
+			Data: osqueue.Item{
+				Kind: osqueue.KindStart,
+				Identifier: state.Identifier{
+					RunID: runId,
+				},
+				Singleton: &osqueue.Singleton{
+					Key: key,
+				},
+			},
+		}
+
+		qi2 := osqueue.QueueItem{
+			Data: osqueue.Item{
+				Kind: osqueue.KindEdge,
+				Identifier: state.Identifier{
+					RunID: runId,
+				},
+			},
+		}
+
+		item1, err := q.EnqueueItem(ctx, q.primaryQueueShard, qi1, start, osqueue.EnqueueOpts{})
+
+		require.NoError(t, err)
+		require.NotEqual(t, qi1.ID, item1.ID)
+		found := getQueueItem(t, r, item1.ID)
+		require.Equal(t, item1, found)
+
+		// Ensure we can't enqueue a start item again having the same singleton key
+		_, err = q.EnqueueItem(ctx, q.primaryQueueShard, qi1, start, osqueue.EnqueueOpts{})
+		require.Equal(t, ErrQueueItemSingletonExists, err)
+
+		// Ensure we can enqueue other queue items
+		item2, err := q.EnqueueItem(ctx, q.primaryQueueShard, qi2, start, osqueue.EnqueueOpts{})
+
+		require.NoError(t, err)
+		require.NotEqual(t, qi2.ID, item2.ID)
+		found2 := getQueueItem(t, r, item2.ID)
+		require.Equal(t, item2, found2)
+
+		// Dequeue the first item
+		err = q.Dequeue(ctx, q.primaryQueueShard, item1)
+		require.NoError(t, err)
+
+		// Enqueuing a start item should still not be possible
+		_, err = q.EnqueueItem(ctx, q.primaryQueueShard, qi1, start, osqueue.EnqueueOpts{})
+		require.Equal(t, ErrQueueItemSingletonExists, err)
+
+		// Dequeue the last item
+		err = q.Dequeue(ctx, q.primaryQueueShard, item2)
+		require.NoError(t, err)
+
+		// Ensure we can enqueue a new start item with the singleton key after the last dequeue.
+		item1, err = q.EnqueueItem(ctx, q.primaryQueueShard, qi1, start, osqueue.EnqueueOpts{})
+
+		require.NoError(t, err)
+		require.NotEqual(t, qi1.ID, item1.ID)
+		newQueueItem := getQueueItem(t, r, item1.ID)
+		require.NotEqual(t, found.ID, newQueueItem.ID)
+	})
+}
+
 func score(t *testing.T, r *miniredis.Miniredis, key string, member string) float64 {
 	require.True(t, r.Exists(key), r.Keys())
 
diff --git a/pkg/inngest/function.go b/pkg/inngest/function.go
index 2381edaecf..049e37376f 100644
--- a/pkg/inngest/function.go
+++ b/pkg/inngest/function.go
@@ -17,6 +17,7 @@ import (
 	"github.com/gosimple/slug"
 	multierror "github.com/hashicorp/go-multierror"
 	"github.com/inngest/inngest/pkg/consts"
+	"github.com/inngest/inngest/pkg/enums"
 	"github.com/inngest/inngest/pkg/expressions"
 	"github.com/inngest/inngest/pkg/syscode"
 	"github.com/xhit/go-str2duration/v2"
@@ -87,6 +88,12 @@ type Function struct {
 	// Cancel specifies cancellation signals for the function
 	Cancel []Cancel `json:"cancel,omitempty"`
 
+	// Singleton ensures that only one instance of the function runs at a time for a given key.
+	// If another invocation is received while an instance is running, the behavior depends on
+	// the mode property: `skip` will drop the new invocation, `cancel` will cancel the
+	// running instance and start the new one.
+	Singleton *Singleton `json:"singleton,omitempty"`
+
 	// Actions represents the actions to take for this function.  If empty, this assumes
 	// that we have a single action specified in the current directory using
 	Steps []Step `json:"steps,omitempty"`
@@ -333,6 +340,14 @@ func (f Function) Validate(ctx context.Context) error {
 				Message: "Batching and debouncing are mutually exclusive",
 			})
 		}
+
+		if f.Singleton != nil {
+			err = multierror.Append(err, syscode.Error{
+				Code:    syscode.CodeComboUnsupported,
+				Message: "Batching and singletons are mutually exclusive",
+			})
+		}
+
 	}
 
 	for _, step := range f.Steps {
@@ -491,3 +506,14 @@ func RandomID() (string, error) {
 	petname.NonDeterministicMode()
 	return fmt.Sprintf("%s-%s", petname.Generate(2, "-"), hex.EncodeToString(byt)), nil
 }
+
+type Singleton struct {
+	// Key is an optional string used to scope singleton execution based on event data.
+	// For example, to ensure only one function runs at a time per user, you can set the key to
+	// "{{ event.user.id }}". This guarantees that only one instance runs for each unique key.
+	Key *string `json:"key,omitempty"`
+
+	// Mode determines how to handle a new run when another singleton instance is already active.
+	// Use `skip` to skip the new run, or `cancel` to stop the current instance and run the new one.
+	Mode enums.SingletonMode `json:"mode"`
+}
diff --git a/pkg/sdk/function.go b/pkg/sdk/function.go
index f33a293217..dbd68776be 100644
--- a/pkg/sdk/function.go
+++ b/pkg/sdk/function.go
@@ -57,6 +57,11 @@ type SDKFunction struct {
 	// Cancel specifies cancellation signals for the function
 	Cancel []inngest.Cancel `json:"cancel,omitempty"`
 
+	// Singleton represents a lock to ensure that only one instance of the function
+	// runs at a time for a given key. Additional runs will either be skipped or replace
+	// the current one, depending on the mode.
+	Singleton *inngest.Singleton `json:"singleton,omitempty"`
+
 	Steps map[string]SDKStep `json:"steps"`
 }
 
@@ -72,6 +77,7 @@ func (s SDKFunction) Function() (*inngest.Function, error) {
 		Cancel:      s.Cancel,
 		Debounce:    s.Debounce,
 		Timeouts:    s.Timeouts,
+		Singleton:   s.Singleton,
 	}
 	// Ensure we set the slug here if s.ID is nil.  This defaults to using
 	// the slugged version of the function name.
diff --git a/tests/golang/singleton_test.go b/tests/golang/singleton_test.go
new file mode 100644
index 0000000000..6ba7ad77c4
--- /dev/null
+++ b/tests/golang/singleton_test.go
@@ -0,0 +1,131 @@
+package golang
+
+import (
+	"context"
+	"fmt"
+	"github.com/inngest/inngestgo"
+	"github.com/stretchr/testify/require"
+	"sync/atomic"
+	"testing"
+	"time"
+)
+
+func TestSingletonFunction(t *testing.T) {
+	inngestClient, server, registerFuncs := NewSDKHandler(t, "singleton")
+	defer server.Close()
+
+	var (
+		inProgress, total int32
+
+		numEvents  = 50
+		fnDuration = 10
+	)
+
+	trigger := "test/singleton-fn"
+
+	_, err := inngestgo.CreateFunction(
+		inngestClient,
+		inngestgo.FunctionOpts{
+			ID:        "fn-singleton",
+			Singleton: &inngestgo.Singleton{Key: inngestgo.StrPtr("event.data.user.id")},
+		},
+		inngestgo.EventTrigger(trigger, nil),
+		func(ctx context.Context, input inngestgo.Input[any]) (any, error) {
+			fmt.Println("Running func", *input.Event.ID, input.Event.Data)
+			atomic.AddInt32(&total, 1)
+
+			next := atomic.AddInt32(&inProgress, 1)
+
+			// We should never exceed more than one function running
+			require.Less(t, next, int32(2))
+
+			<-time.After(time.Duration(fnDuration) * time.Second)
+
+			atomic.AddInt32(&inProgress, -1)
+			return true, nil
+		},
+	)
+	require.NoError(t, err)
+	registerFuncs()
+
+	for i := 0; i < numEvents; i++ {
+		go func() {
+			_, err := inngestClient.Send(context.Background(), inngestgo.Event{
+				Name: trigger,
+				Data: map[string]any{
+					"user": map[string]any{"id": 42},
+				},
+			})
+			require.NoError(t, err)
+		}()
+	}
+
+	// Eventually the fn starts.
+	require.Eventually(t, func() bool {
+		return atomic.LoadInt32(&inProgress) == 1
+	}, 2*time.Second, 50*time.Millisecond)
+
+	for i := 0; i < fnDuration; i++ {
+		<-time.After(time.Second)
+		require.LessOrEqual(t, atomic.LoadInt32(&inProgress), int32(1))
+	}
+
+	// The function executed only once, the other two events were ignored
+	require.EqualValues(t, 1, atomic.LoadInt32(&total))
+}
+
+func TestSingletonFunctionWithKeyResolvingToFalse(t *testing.T) {
+	inngestClient, server, registerFuncs := NewSDKHandler(t, "singleton")
+	defer server.Close()
+
+	var (
+		inProgress, total int32
+
+		numEvents  = 2
+		fnDuration = 5
+	)
+
+	trigger := "test/singleton-fn"
+
+	_, err := inngestgo.CreateFunction(
+		inngestClient,
+		inngestgo.FunctionOpts{
+			ID:        "fn-singleton",
+			Singleton: &inngestgo.Singleton{Key: inngestgo.StrPtr("event.data.decision")},
+		},
+		inngestgo.EventTrigger(trigger, nil),
+		func(ctx context.Context, input inngestgo.Input[any]) (any, error) {
+			fmt.Println("Running func", *input.Event.ID, input.Event.Data)
+			atomic.AddInt32(&total, 1)
+
+			atomic.AddInt32(&inProgress, 1)
+
+			<-time.After(time.Duration(fnDuration) * time.Second)
+
+			atomic.AddInt32(&inProgress, -1)
+			return true, nil
+		},
+	)
+	require.NoError(t, err)
+	registerFuncs()
+
+	for i := 0; i < numEvents; i++ {
+		go func() {
+			_, err := inngestClient.Send(context.Background(), inngestgo.Event{
+				Name: trigger,
+				Data: map[string]any{
+					"decision": false,
+				},
+			})
+			require.NoError(t, err)
+		}()
+	}
+
+	// Eventually both fn start because they are not running as a singleton
+	require.Eventually(t, func() bool {
+		return atomic.LoadInt32(&inProgress) == 2
+	}, 2*time.Second, 50*time.Millisecond)
+
+	// The function executed twice
+	require.EqualValues(t, 2, atomic.LoadInt32(&total))
+}
diff --git a/vendor/github.com/inngest/inngestgo/expose_internal.go b/vendor/github.com/inngest/inngestgo/expose_internal.go
index caf0ab9c32..4769d7dc8e 100644
--- a/vendor/github.com/inngest/inngestgo/expose_internal.go
+++ b/vendor/github.com/inngest/inngestgo/expose_internal.go
@@ -10,6 +10,7 @@ type Debounce = fn.Debounce
 type Throttle = fn.Throttle
 type RateLimit = fn.RateLimit
 type Timeouts = fn.Timeouts
+type Singleton = fn.Singleton
 
 type Input[T any] = fn.Input[T]
 type InputCtx = fn.InputCtx
diff --git a/vendor/github.com/inngest/inngestgo/handler.go b/vendor/github.com/inngest/inngestgo/handler.go
index f7ec18da3d..82e5de0b0c 100644
--- a/vendor/github.com/inngest/inngestgo/handler.go
+++ b/vendor/github.com/inngest/inngestgo/handler.go
@@ -738,6 +738,7 @@ func createFunctionConfigs(
 			Cancel:      fn.Config().Cancel,
 			Timeouts:    fn.Config().GetTimeouts(),
 			Throttle:    (*inngest.Throttle)(fn.Config().Throttle),
+			Singleton:   (*inngest.Singleton)(fn.Config().Singleton),
 			Steps: map[string]sdk.SDKStep{
 				"step": {
 					ID:      "step",
diff --git a/vendor/github.com/inngest/inngestgo/internal/fn/fn.go b/vendor/github.com/inngest/inngestgo/internal/fn/fn.go
index 9a1dc2da47..38d26860a3 100644
--- a/vendor/github.com/inngest/inngestgo/internal/fn/fn.go
+++ b/vendor/github.com/inngest/inngestgo/internal/fn/fn.go
@@ -5,6 +5,7 @@ import (
 	"time"
 
 	"github.com/inngest/inngest/pkg/inngest"
+	"github.com/inngest/inngest/pkg/enums"
 )
 
 // ServableFunction defines a function which can be called by a handler's Serve method.
@@ -58,6 +59,11 @@ type FunctionOpts struct {
 	RateLimit *RateLimit
 	// BatchEvents represents batching
 	BatchEvents *inngest.EventBatchConfig
+	// Singleton represents a mechanism to ensure that only one instance of a function
+	// runs at a time for a given key. Additional invocations with the same key will either
+	// be skipped or cause the current instance to be canceled and replaced, depending on
+	// the specified mode.
+	Singleton *Singleton
 }
 
 func (f FunctionOpts) Validate() error {
@@ -178,3 +184,16 @@ func (t Timeouts) Convert() *inngest.Timeouts {
 		Finish: finish,
 	}
 }
+
+// Singleton represents a singleton function.
+type Singleton struct {
+	// Key is an optional string used to scope the singleton based on event data.
+	// For example, to singleton incoming notifications per user, you could use
+	// a key like "event.user.id". This ensures that only one instance of the
+	// function runs at a time for each unique user.
+	Key *string `json:"key,omitempty"`
+
+	// Mode determines how to handle a new run when another singleton instance is already active.
+	// Use `skip` to skip the new run, or `cancel` to stop the current instance and run the new one.
+	Mode enums.SingletonMode `json:"mode"`
+}


Reviews:
user: login: KiKoS0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T17:53:47Z
user: login: BrunoScheufler
body: Looks great! Added some suggestions
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-27T11:10:26Z
user: login: KiKoS0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T11:51:53Z
user: login: KiKoS0
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T11:58:28Z
user: login: darwin67
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-28T05:26:07Z
user: login: darwin67
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-28T05:27:13Z
user: login: darwin67
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-28T05:28:07Z
user: login: darwin67
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-28T05:29:27Z
user: login: darwin67
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-28T05:35:02Z
user: login: darwin67
body: added some comments but overall looks good.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-28T05:44:11Z

Review comments:
diff_hunk: @@ -69,6 +73,18 @@ if redis.call("HSETNX", queueKey, queueID, queueItem) == 0 and not is_normalize
   return 1
 end
 
+-- Check if the item is a singleton and if an existing item already exists
+if exists_without_ending(singletonKey, ":singleton:-") then 
+  if redis.call("EXISTS", singletonKey) ~= 0 then
+    return 2
+  end
+
+  -- Set the singleton key to the item ID
+  -- TODO: Remove the expiration when dequeuing the item removes the singleton key
+  redis.call("SETEX", singletonRunKey, 20, singletonKey)
+  redis.call("SETEX", singletonKey, 20, runID)
path: pkg/execution/state/redis_state/lua/queue/enqueue.lua
user: login: KiKoS0
body: If we set the keys before enqueuing non-atomically, there's a small window between setting the keys and enqueuing the item during which a failure or crash can occur. In such cases, the singleton key could be held indefinitely.
created_at: 2025-05-23T17:53:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -528,6 +529,39 @@ func (e *executor) Schedule(ctx context.Context, req execution.ScheduleRequest)
 	//
 	throttle := queue.GetThrottleConfig(ctx, req.Function.ID, req.Function.Throttle, evtMap)
 
+	//
+	// Create singleton information and try to handle it prior to creating state.
+	//
+	var singletonConfig *queue.Singleton = nil
path: pkg/execution/executor/executor.go
user: login: BrunoScheufler
body: ```suggestion
	var singletonConfig *queue.Singleton
```

You can just omit this, as nil is the zero value for a pointer :)
created_at: 2025-05-27T10:49:29Z
author_association: MEMBER
diff_hunk: @@ -194,4 +196,19 @@ if backlogScore ~= nil and backlogScore ~= false and backlogScore > 0 then
   updateBacklogPointer(keyGlobalShadowPartitionSet, keyGlobalAccountShadowPartitionSet, keyAccountShadowPartitionSet, keyShadowPartitionSet, keyBacklogSet, accountID, partitionID, backlogID)
 end
 
+
+-- Remove singleton lock
+local singletonKey = redis.call("GET", singletonRunKey)
+
+if singletonKey ~= nil and singletonKey ~= false and keyItemIndexA ~= "" and keyItemIndexA ~= false and keyItemIndexA ~= nil then
path: pkg/execution/state/redis_state/lua/queue/dequeue.lua
user: login: BrunoScheufler
body: I wonder if, at that point, we should make the indexes less generic and just refer to the first index as the "run items" index. That way, we can't accidentally change the order of index keys and break singletons.
created_at: 2025-05-27T10:54:54Z
author_association: MEMBER
diff_hunk: @@ -194,4 +196,19 @@ if backlogScore ~= nil and backlogScore ~= false and backlogScore > 0 then
   updateBacklogPointer(keyGlobalShadowPartitionSet, keyGlobalAccountShadowPartitionSet, keyAccountShadowPartitionSet, keyShadowPartitionSet, keyBacklogSet, accountID, partitionID, backlogID)
 end
 
+
+-- Remove singleton lock
+local singletonKey = redis.call("GET", singletonRunKey)
+
+if singletonKey ~= nil and singletonKey ~= false and keyItemIndexA ~= "" and keyItemIndexA ~= false and keyItemIndexA ~= nil then
path: pkg/execution/state/redis_state/lua/queue/dequeue.lua
user: login: BrunoScheufler
body: Also just to verify: This index will consider all queue items, even sleeps and waitForEvent timeouts. Is this intended by the spec? Should singletons "lock" for the entire duration the run isn't _complete_ (which is what the current logic does) vs. only locking new runs from starting while the current run is _in progress_ (function run level concurrency of 0)?
created_at: 2025-05-27T10:56:38Z
author_association: MEMBER
diff_hunk: @@ -194,4 +196,19 @@ if backlogScore ~= nil and backlogScore ~= false and backlogScore > 0 then
   updateBacklogPointer(keyGlobalShadowPartitionSet, keyGlobalAccountShadowPartitionSet, keyAccountShadowPartitionSet, keyShadowPartitionSet, keyBacklogSet, accountID, partitionID, backlogID)
 end
 
+
+-- Remove singleton lock
+local singletonKey = redis.call("GET", singletonRunKey)
+
+if singletonKey ~= nil and singletonKey ~= false and keyItemIndexA ~= "" and keyItemIndexA ~= false and keyItemIndexA ~= nil then
path: pkg/execution/state/redis_state/lua/queue/dequeue.lua
user: login: KiKoS0
body: I agree after all different queue items types will either have run index as the first option index or nothing.

https://github.com/inngest/inngest/blob/d54a160c888060d1989e3054200974d7f79fda61/pkg/execution/state/redis_state/queue_indexes.go#L31-L55
created_at: 2025-05-27T11:51:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -194,4 +196,19 @@ if backlogScore ~= nil and backlogScore ~= false and backlogScore > 0 then
   updateBacklogPointer(keyGlobalShadowPartitionSet, keyGlobalAccountShadowPartitionSet, keyAccountShadowPartitionSet, keyShadowPartitionSet, keyBacklogSet, accountID, partitionID, backlogID)
 end
 
+
+-- Remove singleton lock
+local singletonKey = redis.call("GET", singletonRunKey)
+
+if singletonKey ~= nil and singletonKey ~= false and keyItemIndexA ~= "" and keyItemIndexA ~= false and keyItemIndexA ~= nil then
path: pkg/execution/state/redis_state/lua/queue/dequeue.lua
user: login: KiKoS0
body: That's correct, I added to the spec that controlling whether paused or sleeping functions count as in-progress is out of scope and we may add in a future iteration as discussed on Slack. 
created_at: 2025-05-27T11:58:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,13 @@
+//go:generate go run github.com/dmarkham/enumer -trimprefix=SingletonMode -type=SingletonMode -json -text -gqlgen
path: pkg/enums/singleton_mode.go
user: login: darwin67
body: you likely want to add the `snake_case` option here so strings are always handled in lower cases. otherwise parsing from string to enum requires them to be capitalized iirc
created_at: 2025-05-28T05:26:07Z
author_association: COLLABORATOR
diff_hunk: @@ -660,12 +694,25 @@ func (e *executor) Schedule(ctx context.Context, req execution.ScheduleRequest)
 		Payload: queue.PayloadEdge{
 			Edge: inngest.SourceEdge,
 		},
-		Throttle: throttle,
+		Throttle:  throttle,
+		Singleton: singletonConfig,
 	}
 	err = e.queue.Enqueue(ctx, item, at, queue.EnqueueOpts{})
 	if err == redis_state.ErrQueueItemExists {
 		return nil, state.ErrIdentifierExists
 	}
+	if err == redis_state.ErrQueueItemSingletonExists {
+		_, delErr := e.smv2.Delete(ctx, sv2.IDFromV1(st.Identifier()))
+
+		if delErr != nil {
+			logger.StdlibLogger(ctx).Error(
+				"error deleting function state",
+				"error", err,
+			)
+		}
+
+		return e.handleFunctionSkipped(ctx, req, metadata, evts, enums.SkipReasonSingleton)
+	}
path: pkg/execution/executor/executor.go
user: login: darwin67
body: it's accumulating a bunch of `if` statements here now.
let's swap it out to a `switch` instead.
created_at: 2025-05-28T05:27:12Z
author_association: COLLABORATOR
diff_hunk: @@ -222,6 +226,16 @@ type Throttle struct {
 	KeyExpressionHash string `json:"-"`
 }
 
+type Singleton struct {
+	// Key is the unique singleton key that's used to group queue items when
+	// processing singleton items.
+	Key string `json:"k"`
+
+	// Mode defines the behavior when a new singleton run is queued while another is active.
+	// It determines whether to ignore the new run or cancel the current one and replace it.
+	//Mode enums.SingletonMode `json:"m"`
path: pkg/execution/queue/item.go
user: login: darwin67
body: you should still enable this, and just ignore and do nothing for cancel mode for now.
created_at: 2025-05-28T05:28:07Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,32 @@
+package singleton
+
+import (
+	"context"
+	"github.com/inngest/inngest/pkg/execution/queue"
+	"github.com/inngest/inngest/pkg/execution/state/redis_state"
+	"github.com/inngest/inngest/pkg/inngest"
+)
+
+func New(ctx context.Context, r *redis_state.QueueClient) Singleton {
+	return &rueidisStore{r: r}
+}
+
+type rueidisStore struct {
path: pkg/execution/singleton/rueidis.go
user: login: darwin67
body: nit: 
don't need to name it after the client library, and just `redis` is fine.
for someone new and unclear what this is, it can be confusing.
created_at: 2025-05-28T05:29:27Z
author_association: COLLABORATOR
diff_hunk: @@ -194,4 +196,19 @@ if backlogScore ~= nil and backlogScore ~= false and backlogScore > 0 then
   updateBacklogPointer(keyGlobalShadowPartitionSet, keyGlobalAccountShadowPartitionSet, keyAccountShadowPartitionSet, keyShadowPartitionSet, keyBacklogSet, accountID, partitionID, backlogID)
 end
 
+
+-- Remove singleton lock
+local singletonKey = redis.call("GET", singletonRunKey)
+
+if singletonKey ~= nil and singletonKey ~= false and keyItemIndexA ~= "" and keyItemIndexA ~= false and keyItemIndexA ~= nil then
path: pkg/execution/state/redis_state/lua/queue/dequeue.lua
user: login: darwin67
body: from a run perspective, as long as the run is not finished, it should be considered active, which means sleep and pauses should still be considered active.

the point of this feature is that users want to either ignore or cancel something if there's already a run in progress. that includes if the run is currently sleeping or waiting for something.

it'll be a surprise if a function that is currently paused was ignored and later resume, which will result in multiple active runs.
created_at: 2025-05-28T05:35:02Z
author_association: COLLABORATOR

PR comments:
user: login: vercel[bot]
created_at: 2025-05-23T17:42:48Z
body: [vc]: #Ga4Fnzd8qaQEEJ3Vfn2cXB7vNeePJo2XO9egk7Sp+jc=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ1aSIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiIifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2lubmdlc3QvdWkvRXN6NFU4eG9ZRUZMc3lMRlh1bnZDVnpjS3l0ayIsIm5leHRDb21taXRTdGF0dXMiOiJJR05PUkVEIiwicm9vdERpcmVjdG9yeSI6InVpL2FwcHMvZGFzaGJvYXJkIiwicHJldmlld1VybCI6InVpLWdpdC1yaWFkaC1zaW5nbGV0b25zLWlubmdlc3QudmVyY2VsLmFwcCJ9XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)


<details><summary>1 Skipped Deployment</summary>

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **ui** | ⬜️ Ignored ([Inspect](https://vercel.com/inngest/ui/Esz4U8xoYEFLsyLFXunvCVzcKytk)) | [Visit Preview](https://ui-git-riadh-singletons-inngest.vercel.app) |  | May 28, 2025 8:34pm |
</details>


author_association: NONE
user: login: darwin67
created_at: 2025-05-28T05:50:32Z
body: I don't see a correlated PR in https://github.com/inngest/inngestgo/pulls to add the singleton configurations.
Make sure to create one there as well, or your changes in `vendor` will be blown away when someone runs `go mod tidy && go mod vendor` next time.
author_association: COLLABORATOR