anonpenguin23 b2d35bbde1 feat(gateway): enable local wildcard triggers for pubsub
- wire PubSubDispatcher to host functions to support local wildcard
  triggers for WASM-published topics
- implement batch deduplication by topic to prevent redundant trigger
  invocations and bound fan-out
- propagate trigger depth through function invocations to maintain
  recursion limits during local dispatch
2026-05-25 09:34:01 +03:00

203 lines
7.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package hostfunctions
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/DeBrosOfficial/network/pkg/pubsub"
"github.com/DeBrosOfficial/network/pkg/serverless"
)
// PubSubPublish publishes a message to a topic.
//
// After a successful libp2p publish, also synchronously fires local
// wildcard triggers via the dispatcher (bugboard #93). Concrete-topic
// triggers are skipped here — they get delivered by the libp2p
// subscribe-loopback path and would double-invoke if fired locally too.
// See dispatcher.DispatchLocalPublish for the filter rationale.
//
// When no triggerDispatcher is wired (tests, or a future deployment
// without serverless triggers), this is just the plain libp2p publish
// — behavior unchanged from before #93.
func (h *HostFunctions) PubSubPublish(ctx context.Context, topic string, data []byte) error {
if h.pubsub == nil {
return &serverless.HostFunctionError{Function: "pubsub_publish", Cause: fmt.Errorf("pubsub not available")}
}
// The pubsub adapter handles namespacing internally
if err := h.pubsub.Publish(ctx, topic, data); err != nil {
return &serverless.HostFunctionError{Function: "pubsub_publish", Cause: err}
}
h.dispatchLocalWildcards(ctx, topic, data)
return nil
}
// dispatchLocalWildcards calls the trigger dispatcher's wildcard-only
// local-dispatch path for the given topic. Safe no-op when the
// dispatcher isn't wired or there's no namespace in the invocation
// context (e.g. when called from a non-serverless caller).
//
// Same-gateway publishes cover ~100% of namespace-gateway architecture
// (single gateway process per namespace). Cross-gateway wildcard
// delivery is plan-6/plan-10 territory and out of scope.
func (h *HostFunctions) dispatchLocalWildcards(ctx context.Context, topic string, data []byte) {
h.triggerDispatcherLock.RLock()
d := h.triggerDispatcher
h.triggerDispatcherLock.RUnlock()
if d == nil {
return
}
cur := h.currentInvocationContext(ctx)
if cur == nil || cur.Namespace == "" {
// No namespace = nothing to look up; skip silently.
return
}
// Pass the CURRENT invocation's depth so DispatchLocalPublish's
// own check (`if depth >= maxTriggerDepth { return }`) eventually
// trips after enough self-recursive WASM publishes (function on
// "events:*" publishes "events:done" → loops). Without this thread,
// every WASM publish reset depth to 0 and the local-recursion loop
// was only bounded by dispatchTimeout + the rate limiter — much
// weaker (security audit MEDIUM, bugboard #93 follow-up).
d.DispatchLocalPublish(ctx, cur.Namespace, topic, data, cur.TriggerDepth)
}
// pubSubBatchEntry mirrors the JSON shape accepted by PubSubPublishBatch.
type pubSubBatchEntry struct {
Topic string `json:"topic"`
DataB64 string `json:"data_base64"`
}
// PubSubPublishBatch publishes multiple messages in parallel.
//
// Input is JSON: [{"topic":"...","data_base64":"..."}, ...]
// Up to pubsub.MaxBatchSize entries per call.
//
// Default behavior is fail-fast (first publish error is returned). The
// host function does not currently expose a best-effort flag — WASM
// callers that need it should call this function multiple times in
// chunks they're willing to retry independently.
func (h *HostFunctions) PubSubPublishBatch(ctx context.Context, msgsJSON []byte) error {
if h.pubsub == nil {
return &serverless.HostFunctionError{Function: "pubsub_publish_batch", Cause: fmt.Errorf("pubsub not available")}
}
var entries []pubSubBatchEntry
if err := json.Unmarshal(msgsJSON, &entries); err != nil {
return &serverless.HostFunctionError{Function: "pubsub_publish_batch", Cause: fmt.Errorf("invalid json: %w", err)}
}
if len(entries) == 0 {
return nil
}
if len(entries) > pubsub.MaxBatchSize {
return &serverless.HostFunctionError{
Function: "pubsub_publish_batch",
Cause: fmt.Errorf("too many messages: max %d per batch", pubsub.MaxBatchSize),
}
}
msgs := make([]pubsub.TopicMessage, 0, len(entries))
for i, e := range entries {
if e.Topic == "" {
return &serverless.HostFunctionError{
Function: "pubsub_publish_batch",
Cause: fmt.Errorf("entry %d: empty topic", i),
}
}
data, err := base64.StdEncoding.DecodeString(e.DataB64)
if err != nil {
return &serverless.HostFunctionError{
Function: "pubsub_publish_batch",
Cause: fmt.Errorf("entry %d (topic %q): bad base64: %w", i, e.Topic, err),
}
}
msgs = append(msgs, pubsub.TopicMessage{Topic: e.Topic, Data: data})
}
if err := h.pubsub.PublishBatch(ctx, msgs, pubsub.PublishBatchOptions{}); err != nil {
return &serverless.HostFunctionError{Function: "pubsub_publish_batch", Cause: err}
}
// Fire local wildcard triggers per UNIQUE topic — same rationale as
// PubSubPublish above. Done after the batch succeeds so we don't
// fire phantom dispatches for messages that didn't actually publish.
for _, e := range dedupBatchByTopic(msgs) {
h.dispatchLocalWildcards(ctx, e.Topic, e.Data)
}
return nil
}
// dedupBatchByTopic collapses a batch to one entry per unique topic,
// keeping insertion order and most-recent-wins semantics on the data
// payload.
//
// A batch with 100 entries on the same topic should only run ONE
// trigger lookup + dispatch — otherwise the same wildcard-matching
// handler gets invoked 100 times for what is semantically one logical
// wakeup. Most-recent-wins matches what a downstream subscriber would
// see after libp2p coalescing in practice. Bounds the fan-out from
// len(batch) × N wildcard handlers to distinct-topics × N (security
// audit MEDIUM, bug #93 follow-up).
//
// Pure function so the batch-dedup logic pins exactly.
func dedupBatchByTopic(msgs []pubsub.TopicMessage) []pubsub.TopicMessage {
if len(msgs) <= 1 {
return msgs
}
lastByTopic := make(map[string][]byte, len(msgs))
order := make([]string, 0, len(msgs))
for _, m := range msgs {
if _, seen := lastByTopic[m.Topic]; !seen {
order = append(order, m.Topic)
}
lastByTopic[m.Topic] = m.Data
}
out := make([]pubsub.TopicMessage, 0, len(order))
for _, topic := range order {
out = append(out, pubsub.TopicMessage{Topic: topic, Data: lastByTopic[topic]})
}
return out
}
// WSSend sends data to a specific WebSocket client.
func (h *HostFunctions) WSSend(ctx context.Context, clientID string, data []byte) error {
if h.wsManager == nil {
return &serverless.HostFunctionError{Function: "ws_send", Cause: serverless.ErrWSNotAvailable}
}
// If no clientID provided, use the current invocation's client.
// Reads ctx-attached invCtx first (per-call, race-free for persistent
// WS) then falls back to the singleton — see invocation_context.go.
if clientID == "" {
if cur := h.currentInvocationContext(ctx); cur != nil && cur.WSClientID != "" {
clientID = cur.WSClientID
}
}
if clientID == "" {
return &serverless.HostFunctionError{Function: "ws_send", Cause: serverless.ErrWSNotAvailable}
}
if err := h.wsManager.Send(clientID, data); err != nil {
return &serverless.HostFunctionError{Function: "ws_send", Cause: err}
}
return nil
}
// WSBroadcast sends data to all WebSocket clients subscribed to a topic.
func (h *HostFunctions) WSBroadcast(ctx context.Context, topic string, data []byte) error {
if h.wsManager == nil {
return &serverless.HostFunctionError{Function: "ws_broadcast", Cause: serverless.ErrWSNotAvailable}
}
if err := h.wsManager.Broadcast(topic, data); err != nil {
return &serverless.HostFunctionError{Function: "ws_broadcast", Cause: err}
}
return nil
}