Mobile notifications for iOS (#210)
* mobile: wire notification registration and listener
* implement backend components for push notifications
* refactor: agentic comment cleanup
* docs: use proper module name for particle processor
* set required env variables for push notifications
* bump version
* fix: always upsert push token on mobile start
* Revert "fix: always upsert push token on mobile start"
This reverts commit 90ff18a788.
* send push notifications regardless of online status
This commit was merged in pull request #210.
This commit is contained in:
@@ -23,22 +23,22 @@ const (
|
||||
pubsubPrefix = "pusher:events:"
|
||||
)
|
||||
|
||||
// redisEvent is published/received via Redis Pub/Sub for cross-pod communication.
|
||||
// Wire format for cross-pod Pub/Sub.
|
||||
type redisEvent struct {
|
||||
Type string `json:"type"` // "join", "leave", "message"
|
||||
HumanID string `json:"humanId,omitempty"` // who triggered the event
|
||||
PodID string `json:"podId,omitempty"` // originating pod
|
||||
Payload json.RawMessage `json:"payload,omitempty"` // for message events
|
||||
Payload json.RawMessage `json:"payload,omitempty"` // message events only
|
||||
}
|
||||
|
||||
// RedisBridge handles cross-pod coordination via Redis Pub/Sub and presence tracking.
|
||||
// RedisBridge handles cross-pod coordination via Redis Pub/Sub and presence
|
||||
// tracking.
|
||||
type RedisBridge struct {
|
||||
client *redis.Client
|
||||
podID string
|
||||
hub *Hub // set after hub is created
|
||||
hub *Hub // wired post-construction; see SetHub
|
||||
}
|
||||
|
||||
// NewRedisBridge creates a new Redis bridge for cross-pod coordination.
|
||||
func NewRedisBridge(client *redis.Client, podID string) *RedisBridge {
|
||||
return &RedisBridge{
|
||||
client: client,
|
||||
@@ -46,20 +46,20 @@ func NewRedisBridge(client *redis.Client, podID string) *RedisBridge {
|
||||
}
|
||||
}
|
||||
|
||||
// SetHub sets the hub reference. Called during initialization.
|
||||
// SetHub resolves the circular dependency between Hub and RedisBridge.
|
||||
func (rb *RedisBridge) SetHub(hub *Hub) {
|
||||
rb.hub = hub
|
||||
}
|
||||
|
||||
// --- Presence management (called by hub goroutine) ---
|
||||
// --- Presence management ---
|
||||
|
||||
// Subscribe adds a connection to a channel in Redis.
|
||||
// Returns the current presence set for the channel.
|
||||
// Subscribe records the connection in Redis and returns the channel's
|
||||
// current deduplicated presence set.
|
||||
func (rb *RedisBridge) Subscribe(ctx context.Context, channelID, connID, humanID string) ([]string, error) {
|
||||
key := channelConnsKey(channelID)
|
||||
field := rb.connField(connID)
|
||||
|
||||
// Check if humanID was already present before adding
|
||||
// Snapshot before the insert so multi-tab joins don't double-emit.
|
||||
existingMembers, err := rb.client.HVals(ctx, key).Result()
|
||||
if err != nil && err != redis.Nil {
|
||||
return nil, fmt.Errorf("failed to get channel members: %w", err)
|
||||
@@ -67,12 +67,10 @@ func (rb *RedisBridge) Subscribe(ctx context.Context, channelID, connID, humanID
|
||||
|
||||
wasPresent := containsString(existingMembers, humanID)
|
||||
|
||||
// Add this connection
|
||||
if err := rb.client.HSet(ctx, key, field, humanID).Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to add connection to channel: %w", err)
|
||||
}
|
||||
|
||||
// Publish join event if this is a new humanID in the channel
|
||||
if !wasPresent {
|
||||
rb.publishEvent(ctx, channelID, redisEvent{
|
||||
Type: TypeJoin,
|
||||
@@ -81,7 +79,6 @@ func (rb *RedisBridge) Subscribe(ctx context.Context, channelID, connID, humanID
|
||||
})
|
||||
}
|
||||
|
||||
// Return deduplicated presence set
|
||||
allMembers, err := rb.client.HVals(ctx, key).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get channel members: %w", err)
|
||||
@@ -89,7 +86,6 @@ func (rb *RedisBridge) Subscribe(ctx context.Context, channelID, connID, humanID
|
||||
return deduplicateStrings(allMembers), nil
|
||||
}
|
||||
|
||||
// Unsubscribe removes a connection from a channel in Redis.
|
||||
func (rb *RedisBridge) Unsubscribe(ctx context.Context, channelID, connID, humanID string) error {
|
||||
key := channelConnsKey(channelID)
|
||||
field := rb.connField(connID)
|
||||
@@ -98,7 +94,7 @@ func (rb *RedisBridge) Unsubscribe(ctx context.Context, channelID, connID, human
|
||||
return fmt.Errorf("failed to remove connection from channel: %w", err)
|
||||
}
|
||||
|
||||
// Check if this humanID is still present via other connections
|
||||
// Only emit leave once this humanID has no tabs left in the channel.
|
||||
remainingMembers, err := rb.client.HVals(ctx, key).Result()
|
||||
if err != nil && err != redis.Nil {
|
||||
return fmt.Errorf("failed to get remaining members: %w", err)
|
||||
@@ -112,7 +108,6 @@ func (rb *RedisBridge) Unsubscribe(ctx context.Context, channelID, connID, human
|
||||
})
|
||||
}
|
||||
|
||||
// Clean up empty channel hash
|
||||
if len(remainingMembers) == 0 {
|
||||
rb.client.Del(ctx, key)
|
||||
}
|
||||
@@ -120,7 +115,6 @@ func (rb *RedisBridge) Unsubscribe(ctx context.Context, channelID, connID, human
|
||||
return nil
|
||||
}
|
||||
|
||||
// Broadcast publishes a message event to all pods.
|
||||
func (rb *RedisBridge) Broadcast(ctx context.Context, channelID, humanID string, payload json.RawMessage) {
|
||||
rb.publishEvent(ctx, channelID, redisEvent{
|
||||
Type: TypeMessage,
|
||||
@@ -130,7 +124,6 @@ func (rb *RedisBridge) Broadcast(ctx context.Context, channelID, humanID string,
|
||||
})
|
||||
}
|
||||
|
||||
// GetPresence returns the deduplicated humanIDs for the given channels.
|
||||
func (rb *RedisBridge) GetPresence(ctx context.Context, channelIDs []string) (map[string][]string, error) {
|
||||
result := make(map[string][]string, len(channelIDs))
|
||||
for _, chID := range channelIDs {
|
||||
@@ -143,8 +136,7 @@ func (rb *RedisBridge) GetPresence(ctx context.Context, channelIDs []string) (ma
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetAllConnectedHumanIDs scans all channel connection hashes in Redis and returns
|
||||
// the deduplicated set of all humanIDs that have at least one active connection.
|
||||
// Returns every humanID with at least one active connection cluster-wide.
|
||||
func (rb *RedisBridge) GetAllConnectedHumanIDs(ctx context.Context) ([]string, error) {
|
||||
allHumanIDs := make(map[string]bool)
|
||||
var cursor uint64
|
||||
@@ -178,10 +170,9 @@ func (rb *RedisBridge) GetAllConnectedHumanIDs(ctx context.Context) ([]string, e
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// --- Pub/Sub listener (runs in its own goroutine) ---
|
||||
// --- Pub/Sub listener ---
|
||||
|
||||
// Listen subscribes to Redis Pub/Sub and forwards events to the local hub.
|
||||
// Blocks until the context is cancelled.
|
||||
// Listen forwards Redis Pub/Sub events to the local hub; blocks until ctx is cancelled.
|
||||
func (rb *RedisBridge) Listen(ctx context.Context) {
|
||||
pubsub := rb.client.PSubscribe(ctx, pubsubPrefix+"*")
|
||||
defer pubsub.Close()
|
||||
@@ -201,7 +192,7 @@ func (rb *RedisBridge) Listen(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (rb *RedisBridge) handlePubSubMessage(msg *redis.Message) {
|
||||
// Extract channel ID from topic: "pusher:events:{channelID}"
|
||||
// Topic: "pusher:events:{channelID}".
|
||||
channelID := strings.TrimPrefix(msg.Channel, pubsubPrefix)
|
||||
if channelID == "" {
|
||||
return
|
||||
@@ -213,7 +204,7 @@ func (rb *RedisBridge) handlePubSubMessage(msg *redis.Message) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip events originating from this pod — the local hub already handled them
|
||||
// Same-pod events were already handled by the local hub.
|
||||
if event.PodID == rb.podID {
|
||||
return
|
||||
}
|
||||
@@ -222,20 +213,18 @@ func (rb *RedisBridge) handlePubSubMessage(msg *redis.Message) {
|
||||
return
|
||||
}
|
||||
|
||||
// Forward to local hub for delivery to local WebSocket connections
|
||||
rb.hub.remoteEventCh <- &remoteEvent{
|
||||
channelID: channelID,
|
||||
event: event,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Heartbeat + cleanup (runs in its own goroutine) ---
|
||||
// --- Heartbeat + cleanup ---
|
||||
|
||||
// Heartbeat maintains this pod's liveness key and cleans up stale pods.
|
||||
// Heartbeat refreshes this pod's liveness key and reaps stale pods on a tick.
|
||||
func (rb *RedisBridge) Heartbeat(ctx context.Context) {
|
||||
podKey := podKeyPrefix + rb.podID
|
||||
|
||||
// Initial heartbeat
|
||||
rb.client.Set(ctx, podKey, "alive", podHeartbeatTTL)
|
||||
|
||||
heartbeatTicker := time.NewTicker(podHeartbeatInterval)
|
||||
@@ -246,7 +235,7 @@ func (rb *RedisBridge) Heartbeat(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// On shutdown, remove our pod key and clean up our connections
|
||||
// On shutdown, drop our pod key and reclaim our connection slots.
|
||||
rb.client.Del(context.Background(), podKey)
|
||||
rb.cleanupPod(context.Background(), rb.podID)
|
||||
return
|
||||
@@ -259,7 +248,8 @@ func (rb *RedisBridge) Heartbeat(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (rb *RedisBridge) cleanupStalePods(ctx context.Context) {
|
||||
// Scan all channel conn hashes for pod IDs, then check if those pods are still alive
|
||||
// Collect every pod referenced in channel-conn hashes, then drop those
|
||||
// whose liveness key has expired.
|
||||
var cursor uint64
|
||||
knownPods := make(map[string]bool)
|
||||
alivePods := make(map[string]bool)
|
||||
@@ -290,7 +280,6 @@ func (rb *RedisBridge) cleanupStalePods(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check which pods are still alive
|
||||
for podID := range knownPods {
|
||||
exists, err := rb.client.Exists(ctx, podKeyPrefix+podID).Result()
|
||||
if err != nil {
|
||||
@@ -301,7 +290,6 @@ func (rb *RedisBridge) cleanupStalePods(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up dead pods
|
||||
for podID := range knownPods {
|
||||
if !alivePods[podID] {
|
||||
slog.Info("cleaning up stale pod", "podId", podID)
|
||||
@@ -328,7 +316,6 @@ func (rb *RedisBridge) cleanupPod(ctx context.Context, podID string) {
|
||||
for field, humanID := range fields {
|
||||
if extractPodID(field) == podID {
|
||||
rb.client.HDel(ctx, key, field)
|
||||
// Check if this humanID is now gone from the channel
|
||||
remaining, _ := rb.client.HVals(ctx, key).Result()
|
||||
if !containsString(remaining, humanID) {
|
||||
rb.publishEvent(ctx, channelID, redisEvent{
|
||||
@@ -369,15 +356,15 @@ func channelConnsKey(channelID string) string {
|
||||
return channelConnsPrefix + channelID + channelConnsSuffix
|
||||
}
|
||||
|
||||
// "pusher:ch:{channelID}:conns" → channelID
|
||||
func extractChannelID(redisKey string) string {
|
||||
// "pusher:ch:{channelID}:conns" → channelID
|
||||
s := strings.TrimPrefix(redisKey, channelConnsPrefix)
|
||||
s = strings.TrimSuffix(s, channelConnsSuffix)
|
||||
return s
|
||||
}
|
||||
|
||||
// "{podID}:{connID}" → podID
|
||||
func extractPodID(field string) string {
|
||||
// "{podID}:{connID}" → podID
|
||||
parts := strings.SplitN(field, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[0]
|
||||
|
||||
Reference in New Issue
Block a user