Skip to content

Commit eb1ead0

Browse files
committed
triggers loop
1 parent f5f144f commit eb1ead0

21 files changed

Lines changed: 7410 additions & 8 deletions

CHANGELOG.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,17 @@
22

33
All notable changes to this project will be documented in this file.
44

5-
## [30.4] - 2026-05-18
5+
## [31.0] - 2026-05-19
66

7-
- **Fix**: Workspace database pool no longer falsely evicted when the caller's HTTP request context expires mid-Ping during broadcast load. Previously, a single caller-context cancellation could close the cached pool, causing every other in-flight worker (segment queue, webhook delivery, email send) to fail with `sql: database is closed` on its next operation. The pool health check now uses an isolated, sub-second context.
7+
### Database Schema Changes
8+
9+
- Migration v31.0 updates the `queue_contact_for_segment_recomputation` trigger function on every workspace database to short-circuit when the inserted `contact_timeline` row is itself a segment membership event (`kind IN ('segment.joined', 'segment.left')`).
10+
11+
### Fixes
12+
13+
- **Fix**: `queue_contact_for_segment_recomputation` trigger no longer re-enqueues contacts when the inserted `contact_timeline` event is itself a segment membership change (`segment.joined`/`segment.left`). Removes a self-loop where every membership write re-queued the same contact.
14+
- **Fix**: Recurring tasks dispatched via HTTP now write `timeout_after` in UTC. The column is `TIMESTAMP WITHOUT TIME ZONE` and the scheduler compares it against `time.Now().UTC()`; on non-UTC hosts the local-time value caused the task to appear "still running" for the host's UTC offset. Same fix applied to the broadcast-pause `next_run_after`.
15+
- **Fix**: `GetWorkspaceConnection`'s pool health check now uses an isolated context for `pool.PingContext` instead of the caller's. A caller-context cancellation no longer triggers pool eviction.
816

917
## [30.3] - 2026-05-14
1018

config/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import (
1515
"github.com/spf13/viper"
1616
)
1717

18-
const VERSION = "30.4"
18+
const VERSION = "31.0"
1919

2020
type Config struct {
2121
Server ServerConfig

internal/database/init.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,18 @@ func InitializeWorkspaceDatabase(db *sql.DB) error {
706706
`CREATE OR REPLACE FUNCTION queue_contact_for_segment_recomputation()
707707
RETURNS TRIGGER AS $$
708708
BEGIN
709+
-- Skip re-queue when the timeline event is itself a segment
710+
-- membership change. The queue worker writes contact_segments,
711+
-- which fires track_contact_segment_changes (inserts a
712+
-- contact_timeline row with kind='segment.joined'/'segment.left'),
713+
-- which would re-enter this function and re-queue the same
714+
-- contact for re-processing — a self-loop that also contends
715+
-- with concurrent open-tracking writes on the contact_segment_queue
716+
-- (email) row lock and prevents batches from completing.
717+
IF NEW.kind IN ('segment.joined', 'segment.left') THEN
718+
RETURN NEW;
719+
END IF;
720+
709721
-- Queue the contact for segment recomputation
710722
INSERT INTO contact_segment_queue (email, queued_at)
711723
VALUES (NEW.email, CURRENT_TIMESTAMP)

internal/http/task_handler.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,8 +237,13 @@ func (h *TaskHandler) ExecuteTask(w http.ResponseWriter, r *http.Request) {
237237
return
238238
}
239239

240-
// Calculate timeout based on task's MaxRuntime
241-
timeoutAt := time.Now().Add(time.Duration(task.MaxRuntime) * time.Second)
240+
// Calculate timeout based on task's MaxRuntime. UTC is mandatory: the
241+
// tasks.timeout_after column is TIMESTAMP WITHOUT TIME ZONE, so a local
242+
// time.Now() on a non-UTC server would write a literal that, when later
243+
// compared against time.Now().UTC() in GetNextBatch, leaves the task
244+
// "still running" for the duration of the server's UTC offset (e.g. +2h
245+
// in CEST) and the recurring task is never re-picked within that window.
246+
timeoutAt := time.Now().UTC().Add(time.Duration(task.MaxRuntime) * time.Second)
242247

243248
if err := h.taskService.ExecuteTask(r.Context(), executeRequest.WorkspaceID, executeRequest.ID, timeoutAt); err != nil {
244249
// Handle different error types with appropriate status codes

internal/migrations/manager_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -541,7 +541,7 @@ func TestManager_RunMigrations_AdditionalCoverage(t *testing.T) {
541541

542542
// Mock GetCurrentDBVersion to return the latest migrated version (up to date)
543543
mock.ExpectQuery("SELECT value FROM settings WHERE key = 'db_version'").
544-
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("30"))
544+
WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("31"))
545545

546546
err = manager.RunMigrations(context.Background(), cfg, db)
547547

internal/migrations/v31.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package migrations
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/Notifuse/notifuse/config"
8+
"github.com/Notifuse/notifuse/internal/domain"
9+
)
10+
11+
// V31Migration breaks the segment-recomputation self-loop.
12+
//
13+
// The previous queue_contact_for_segment_recomputation() unconditionally
14+
// upserted into contact_segment_queue whenever any row was inserted into
15+
// contact_timeline. That included rows produced by track_contact_segment_changes
16+
// (kind='segment.joined' / 'segment.left'), which fire as the queue worker
17+
// itself writes contact_segments. The effect was twofold:
18+
//
19+
// 1. Every membership write re-queued the same contact for re-processing
20+
// after the 15s debounce — pure wasted work.
21+
// 2. The worker's INSERT contact_segments transaction ended up contending
22+
// with concurrent /opens and delivery-webhook writers on the same
23+
// (email) row lock in contact_segment_queue. Under broadcast load this
24+
// stalled the worker for >10s per batch and caused the queue to
25+
// accumulate without draining.
26+
//
27+
// The function now short-circuits when the timeline event is itself a
28+
// segment membership change, leaving every other event path unchanged.
29+
type V31Migration struct{}
30+
31+
func (m *V31Migration) GetMajorVersion() float64 {
32+
return 31.0
33+
}
34+
35+
func (m *V31Migration) HasSystemUpdate() bool {
36+
return false
37+
}
38+
39+
func (m *V31Migration) HasWorkspaceUpdate() bool {
40+
return true
41+
}
42+
43+
func (m *V31Migration) ShouldRestartServer() bool {
44+
return false
45+
}
46+
47+
func (m *V31Migration) UpdateSystem(ctx context.Context, cfg *config.Config, db DBExecutor) error {
48+
return nil
49+
}
50+
51+
func (m *V31Migration) UpdateWorkspace(ctx context.Context, cfg *config.Config, workspace *domain.Workspace, db DBExecutor) error {
52+
_, err := db.ExecContext(ctx, `
53+
CREATE OR REPLACE FUNCTION queue_contact_for_segment_recomputation()
54+
RETURNS TRIGGER AS $$
55+
BEGIN
56+
-- Skip re-queue when the timeline event is itself a segment
57+
-- membership change. The queue worker writes contact_segments,
58+
-- which fires track_contact_segment_changes (inserts a
59+
-- contact_timeline row with kind='segment.joined'/'segment.left'),
60+
-- which would re-enter this function and re-queue the same
61+
-- contact for re-processing — a self-loop that also contends
62+
-- with concurrent open-tracking writes on the contact_segment_queue
63+
-- (email) row lock and prevents batches from completing.
64+
IF NEW.kind IN ('segment.joined', 'segment.left') THEN
65+
RETURN NEW;
66+
END IF;
67+
68+
INSERT INTO contact_segment_queue (email, queued_at)
69+
VALUES (NEW.email, CURRENT_TIMESTAMP)
70+
ON CONFLICT (email) DO UPDATE SET queued_at = EXCLUDED.queued_at;
71+
RETURN NEW;
72+
END;
73+
$$ LANGUAGE plpgsql;
74+
`)
75+
if err != nil {
76+
return fmt.Errorf("failed to update queue_contact_for_segment_recomputation function for workspace %s: %w", workspace.ID, err)
77+
}
78+
79+
return nil
80+
}
81+
82+
func init() {
83+
Register(&V31Migration{})
84+
}

internal/migrations/v31_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package migrations
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/DATA-DOG/go-sqlmock"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/Notifuse/notifuse/config"
12+
"github.com/Notifuse/notifuse/internal/domain"
13+
)
14+
15+
func TestV31Migration_GetMajorVersion(t *testing.T) {
16+
m := &V31Migration{}
17+
assert.Equal(t, 31.0, m.GetMajorVersion())
18+
}
19+
20+
func TestV31Migration_HasSystemUpdate(t *testing.T) {
21+
m := &V31Migration{}
22+
assert.False(t, m.HasSystemUpdate())
23+
}
24+
25+
func TestV31Migration_HasWorkspaceUpdate(t *testing.T) {
26+
m := &V31Migration{}
27+
assert.True(t, m.HasWorkspaceUpdate())
28+
}
29+
30+
func TestV31Migration_ShouldRestartServer(t *testing.T) {
31+
m := &V31Migration{}
32+
assert.False(t, m.ShouldRestartServer())
33+
}
34+
35+
func TestV31Migration_UpdateSystem_NoOp(t *testing.T) {
36+
m := &V31Migration{}
37+
// System-side has nothing to do — just verify it returns nil cleanly.
38+
assert.NoError(t, m.UpdateSystem(context.Background(), &config.Config{}, nil))
39+
}
40+
41+
func TestV31Migration_UpdateWorkspace_Success(t *testing.T) {
42+
db, mock, err := sqlmock.New()
43+
require.NoError(t, err)
44+
defer db.Close()
45+
46+
mock.ExpectExec(`CREATE OR REPLACE FUNCTION queue_contact_for_segment_recomputation`).
47+
WillReturnResult(sqlmock.NewResult(0, 0))
48+
49+
m := &V31Migration{}
50+
err = m.UpdateWorkspace(context.Background(), &config.Config{},
51+
&domain.Workspace{ID: "ws_test"}, db)
52+
assert.NoError(t, err)
53+
assert.NoError(t, mock.ExpectationsWereMet())
54+
}
55+
56+
func TestV31Migration_UpdateWorkspace_Error(t *testing.T) {
57+
db, mock, err := sqlmock.New()
58+
require.NoError(t, err)
59+
defer db.Close()
60+
61+
mock.ExpectExec(`CREATE OR REPLACE FUNCTION queue_contact_for_segment_recomputation`).
62+
WillReturnError(assert.AnError)
63+
64+
m := &V31Migration{}
65+
err = m.UpdateWorkspace(context.Background(), &config.Config{},
66+
&domain.Workspace{ID: "ws_test"}, db)
67+
require.Error(t, err)
68+
assert.Contains(t, err.Error(), "failed to update queue_contact_for_segment_recomputation")
69+
assert.Contains(t, err.Error(), "ws_test")
70+
}
71+
72+
func TestV31Migration_Registered(t *testing.T) {
73+
// Verify init() registered the migration so the runner picks it up.
74+
for _, m := range GetRegisteredMigrations() {
75+
if m.GetMajorVersion() == 31.0 {
76+
return
77+
}
78+
}
79+
t.Fatal("V31Migration not registered")
80+
}

internal/service/task_service.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -990,8 +990,9 @@ func (s *TaskService) handleBroadcastPaused(ctx context.Context, payload domain.
990990
return
991991
}
992992

993-
// Pause the task
994-
nextRunAfter := time.Now().Add(24 * time.Hour) // Pause for 24 hours
993+
// Pause the task. UTC is mandatory because tasks.next_run_after is
994+
// TIMESTAMP WITHOUT TIME ZONE — see task_handler.go timeoutAt comment.
995+
nextRunAfter := time.Now().UTC().Add(24 * time.Hour) // Pause for 24 hours
995996
tracing.AddAttribute(ctx, "next_run_after", nextRunAfter.Format(time.RFC3339))
996997

997998
if err := s.repo.MarkAsPaused(ctx, payload.WorkspaceID, task.ID, nextRunAfter, task.Progress, task.State); err != nil {

0 commit comments

Comments
 (0)