summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/store/native_tasks.go15
-rw-r--r--internal/store/native_tasks_test.go31
2 files changed, 44 insertions, 2 deletions
diff --git a/internal/store/native_tasks.go b/internal/store/native_tasks.go
index 3bd87cb..90cf275 100644
--- a/internal/store/native_tasks.go
+++ b/internal/store/native_tasks.go
@@ -228,6 +228,10 @@ func (s *Store) UncompleteNativeTask(id string) error {
// same recurrence_series_id, completed=false, next_occurrence_override=""),
// with due_date set to old.NextOccurrenceOverride if present, else
// ComputeNextOccurrence(old.DueDate, ...). old itself is left untouched.
+// The INSERT is self-guarding: it atomically no-ops if a newer row already
+// exists in the series, so two concurrent triggers (the completion path and
+// the periodic due-date check) can never both create a successor for the
+// same predecessor.
func (s *Store) CreateNextIteration(old models.Task) error {
var nextDue *time.Time
switch {
@@ -244,9 +248,16 @@ func (s *Store) CreateNextIteration(old models.Task) error {
id, content, description, project_name, due_date, priority, labels,
recurrence_freq, recurrence_interval, recurrence_weekdays, recurrence_series_id, next_occurrence_override,
created_at, updated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ )
+ SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+ WHERE NOT EXISTS (
+ SELECT 1 FROM native_tasks
+ WHERE recurrence_series_id = ?
+ AND (due_date > ? OR (due_date = ? AND created_at > ?))
+ )
`, newTaskID(), old.Content, old.Description, old.ProjectName, nextDue, old.Priority, string(labelsJSON),
- old.RecurrenceFreq, old.RecurrenceInterval, formatWeekdays(old.RecurrenceWeekdays), old.RecurrenceSeriesID)
+ old.RecurrenceFreq, old.RecurrenceInterval, formatWeekdays(old.RecurrenceWeekdays), old.RecurrenceSeriesID,
+ old.RecurrenceSeriesID, old.DueDate, old.DueDate, old.CreatedAt)
return err
}
diff --git a/internal/store/native_tasks_test.go b/internal/store/native_tasks_test.go
index a299ac6..c362eb7 100644
--- a/internal/store/native_tasks_test.go
+++ b/internal/store/native_tasks_test.go
@@ -302,6 +302,37 @@ func TestCompleteNativeTask_AlreadySuperseded_DoesNotDoubleCreate(t *testing.T)
}
}
+func TestCreateNextIteration_ConcurrentGuard_SecondCallIsNoOp(t *testing.T) {
+ s := newNativeTasksTestStore(t)
+ if _, err := s.db.Exec(`
+ INSERT INTO native_tasks (id, content, due_date, recurrence_freq, recurrence_interval, recurrence_series_id)
+ VALUES ('rec-1', 'Water plants', '2026-07-13', 'weekly', 1, 'series-1')
+ `); err != nil {
+ t.Fatal(err)
+ }
+ old, err := s.GetNativeTaskByID("rec-1")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Simulate two concurrent triggers both computing from the same
+ // snapshot of rec-1 and both attempting to create its successor.
+ if err := s.CreateNextIteration(*old); err != nil {
+ t.Fatalf("first CreateNextIteration: %v", err)
+ }
+ if err := s.CreateNextIteration(*old); err != nil {
+ t.Fatalf("second CreateNextIteration (should be a safe no-op, not an error): %v", err)
+ }
+
+ var count int
+ if err := s.db.QueryRow(`SELECT COUNT(*) FROM native_tasks WHERE recurrence_series_id = 'series-1' AND id != 'rec-1'`).Scan(&count); err != nil {
+ t.Fatal(err)
+ }
+ if count != 1 {
+ t.Errorf("expected exactly 1 successor row after two CreateNextIteration calls from the same snapshot, got %d", count)
+ }
+}
+
func TestSetTaskRecurrence_FirstTimeGeneratesSeriesID(t *testing.T) {
s := newNativeTasksTestStore(t)