Skip to content

cron: Replace external dep with internal implementation#151

Merged
andrewwormald merged 4 commits intomainfrom
andreww-removeCronSpecDep
Sep 18, 2025
Merged

cron: Replace external dep with internal implementation#151
andrewwormald merged 4 commits intomainfrom
andreww-removeCronSpecDep

Conversation

@andrewwormald
Copy link
Collaborator

@andrewwormald andrewwormald commented Sep 17, 2025

Removing the 3rd party cron spec as it's largely not needed as a 3rd party and is being offered as a first class feature. Unless the Schedule method is removed then this can exist with direct support and optimisations where needed.

Summary by CodeRabbit

  • New Features

  • Chores

    • Removed external cron dependency in favour of the internal scheduling implementation; schedule parsing and next-run semantics consolidated.
  • Tests

    • Added comprehensive tests and benchmarks covering parsing, validation, edge cases (rollovers, leap years) and next-run calculations.

@coderabbitai
Copy link

coderabbitai bot commented Sep 17, 2025

Walkthrough

  • Removed dependency on github.com/robfig/cron/v3 from go.mod.
  • Added internal/cron/cron.go implementing a public Schedule interface with Next(time.Time) (time.Time, bool) and a Parse(spec string) (Schedule, error) supporting @every durations, known @ descriptors and five-field crontab specs; includes parsing, validation, wildcard handling and a bounded minute-by-minute search for the next matching time.
  • Added internal/cron/cron_test.go with table-driven tests and benchmarks covering parsing, Next calculations, edge cases and error paths.
  • Updated schedule.go to import the new internal cron package, call cron.Parse(spec), handle the (time.Time, bool) return from Next and return an error when no next schedule is found; remaining scheduling logic unchanged.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

I nibble specs at dawn's first light,
@every hops and stars take flight.
Minutes, months and weekdays signed,
I search each minute, gentle, kind.
A rabbit's tweak — precise and bright. 🐇✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title succinctly and accurately summarises the primary change — replacing the external cron dependency with an internal implementation; it is concise, specific, and clear to a reviewer scanning PR history.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch andreww-removeCronSpecDep

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Please see the documentation for more information.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (4)
internal/cron/cron.go (2)

85-97: Allow Sunday as 7 for backwards compatibility.

Many cron parsers accept both 0 and 7 as Sunday. Your implementation rejects 7, which is likely a breaking change compared to robfig/cron. Map 7→0 during weekday parsing.

Apply this change in parseCrontab to use a weekday-specific parser:

-  weekday, err := parseField(fields[4], 0, 6)
+  weekday, err := parseWeekday(fields[4])
   if err != nil {
     return nil, fmt.Errorf("invalid weekday: %v", err)
   }

Add this helper (place near parseField):

func parseWeekday(field string) (int, error) {
  if field == "*" || field == "?" {
    return -1, nil
  }
  if field == "7" {
    return 0, nil // Sunday alias
  }
  v, err := strconv.Atoi(field)
  if err != nil {
    return 0, fmt.Errorf("invalid number: %s", field)
  }
  if v < 0 || v > 6 {
    return 0, fmt.Errorf("value %d out of range [0-6]", v)
  }
  return v, nil
}

Please confirm whether your public docs/users rely on 7=Sunday; if so, this change is necessary.


10-12: Add doc comments for exported API.

Add Go doc for Schedule and Parse to satisfy linters and clarify semantics (wildcards, DOM/DOW OR, Sunday=0/7, zero-time behaviour).

Also applies to: 14-26

internal/cron/cron_test.go (2)

306-311: Test name is misleading; spec matches only Mondays.

Rename for accuracy: it asserts Monday 09:00, not Mon–Fri.

-      name:     "Monday through Friday at 9 AM",
+      name:     "Monday at 9 AM",
       spec:     "0 9 * * 1",

368-387: Add negative/@every and unreachable-spec test cases.

Strengthen coverage for validations proposed in cron.go.

@@ func TestParse(t *testing.T) {
-  }
+    {
+      name:     "every negative duration rejected",
+      spec:     "@every -5m",
+      baseTime: baseTime,
+      want:     time.Time{},
+      wantErr:  true,
+    },
+    {
+      name:     "impossible date rejected (31 Feb)",
+      spec:     "0 0 31 2 *",
+      baseTime: baseTime,
+      want:     time.Time{},
+      wantErr:  true,
+    },
+  }

I can push a commit with these if you want me to open a quick PR against this branch.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4d17e3e and 1bcd90f.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (4)
  • go.mod (0 hunks)
  • internal/cron/cron.go (1 hunks)
  • internal/cron/cron_test.go (1 hunks)
  • schedule.go (2 hunks)
💤 Files with no reviewable changes (1)
  • go.mod
🧰 Additional context used
🧬 Code graph analysis (2)
internal/cron/cron_test.go (1)
internal/cron/cron.go (1)
  • Parse (14-26)
schedule.go (1)
internal/cron/cron.go (1)
  • Parse (14-26)
🔇 Additional comments (1)
internal/cron/cron.go (1)

159-163: Correct OR semantics for DOM vs DOW.

Using OR when both day and weekday are specified matches Vixie/robfig semantics. Nice.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
internal/cron/cron.go (2)

39-41: Good fix: disallow non‑positive @every durations.

This addresses the prior review about zero/negative durations.


63-101: Reject impossible day/month combinations at parse time.

Specs like “0 0 31 2 *” can never match; without validation, Next() scans 4 years then returns zero, risking upstream spin. Fail fast in Parse.

Apply this diff:

 	weekday, err := parseWeekday(fields[4])
 	if err != nil {
 		return nil, fmt.Errorf("invalid weekday: %v", err)
 	}
 
+	// Validate impossible month-day combinations (single-value fields only).
+	if month != -1 && day != -1 {
+		switch month {
+		case 4, 6, 9, 11:
+			if day > 30 {
+				return nil, fmt.Errorf("day %d out of range for month %d", day, month)
+			}
+		case 2:
+			if day > 29 {
+				return nil, fmt.Errorf("day %d out of range for February", day)
+			}
+			// day==29 is allowed; Next() will find the next leap year.
+		}
+	}
+
 	return &cronSchedule{
 		minute:  minute,
 		hour:    hour,
 		day:     day,
 		month:   month,
 		weekday: weekday,
 	}, nil
🧹 Nitpick comments (5)
internal/cron/cron.go (2)

10-12: Add a brief contract doc for Schedule.Next.

Document that Next must return a time strictly after the argument, uses the time’s Location, and (ideally) never returns the zero time.

Apply this diff:

 type Schedule interface {
-	Next(time.Time) time.Time
+	// Next returns the first time strictly after t that matches the schedule.
+	// Implementations should respect t.Location() and avoid returning the zero time.
+	Next(time.Time) time.Time
 }

166-185: Clarify DOM vs DOW semantics are OR.

matches() uses OR when both day and weekday are specified. This is fine, but differs from some cron variants. Document it in package comments to avoid surprises.

internal/cron/cron_test.go (3)

320-325: Fix misleading test name.

Spec “0 9 * * 1” is Monday only, not Monday–Friday. Rename for accuracy.

Apply this diff:

-			name:     "Monday through Friday at 9 AM",
+			name:     "Monday at 9 AM",
 			spec:     "0 9 * * 1",

8-382: Add tests for impossible day/month combos (now rejected).

Strengthen coverage to ensure Parse fails fast for unreachable specs.

Apply this diff (append to the tests slice):

@@
 		{
 			name:     "every microsecond (minimal duration)",
 			spec:     "@every 1us",
 			baseTime: baseTime,
 			want:     baseTime.Add(time.Microsecond),
 			wantErr:  false,
 		},
+		{
+			name:     "invalid: 31 Feb",
+			spec:     "0 0 31 2 *",
+			baseTime: baseTime,
+			want:     time.Time{},
+			wantErr:  true,
+		},
+		{
+			name:     "invalid: 31 April",
+			spec:     "0 0 31 4 *",
+			baseTime: baseTime,
+			want:     time.Time{},
+			wantErr:  true,
+		},

8-382: Consider a regression test to ensure Next() never returns zero after Parse success.

Guard against future changes that might reintroduce zero-time results.

You can add:

t.Run("no zero time after valid parse", func(t *testing.T) {
    s, err := Parse("0 0 1 1 *")
    if err != nil { t.Fatalf("parse failed: %v", err) }
    if s.Next(baseTime).IsZero() {
        t.Fatalf("Next returned zero time")
    }
})
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f37ddb5 and 489310b.

📒 Files selected for processing (2)
  • internal/cron/cron.go (1 hunks)
  • internal/cron/cron_test.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
internal/cron/cron_test.go (1)
internal/cron/cron.go (1)
  • Parse (14-26)

@sonarqubecloud
Copy link

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (2)
internal/cron/cron.go (1)

94-101: Validate impossible day-of-month/month combinations.

Expressions like "0 0 31 2 *" can never match. These run the 4-year scan and then return false. Proactively reject impossible combos to fail fast.

   month, err := parseField(fields[3], 1, 12)
   if err != nil {
     return nil, fmt.Errorf("invalid month: %v", err)
   }

   weekday, err := parseWeekday(fields[4])
   if err != nil {
     return nil, fmt.Errorf("invalid weekday: %v", err)
   }
 
+  // Validate impossible combinations when month and day are both specified.
+  if month != -1 && day != -1 {
+    switch month {
+    case 4, 6, 9, 11:
+      if day > 30 {
+        return nil, fmt.Errorf("day %d out of range for month %d", day, month)
+      }
+    case 2:
+      if day > 29 {
+        return nil, fmt.Errorf("day %d out of range for February", day)
+      }
+      // day==29 is allowed; Next() will find the next leap year.
+    }
+  }
+
   return &cronSchedule{
     minute:  minute,
     hour:    hour,
     day:     day,
     month:   month,
     weekday: weekday,
   }, nil
schedule.go (1)

53-57: Guard against unreachable schedules returning false.

Good that you're now handling the bool return from schedule.Next(). However, consider adding more context about why the schedule is unreachable (e.g., impossible date combinations).

The error handling is correct, but we should verify that the error message provides sufficient debugging information. Let's check if there are any tests that validate this error path:

#!/bin/bash
# Check for tests covering the schedule.Next() error path
rg -n "no next schedule found" --type go
🧹 Nitpick comments (4)
internal/cron/cron.go (3)

103-118: Consider supporting named weekday aliases.

The current implementation only supports numeric weekday values (0-6) and "7" as an alias for Sunday. Consider supporting standard cron weekday names (SUN, MON, TUE, etc.) for better compatibility.

 func parseWeekday(field string) (int, error) {
   if field == "*" || field == "?" {
     return -1, nil
   }
   if field == "7" {
     return 0, nil // Sunday alias
   }
+  
+  // Support weekday name aliases
+  weekdayNames := map[string]int{
+    "SUN": 0, "MON": 1, "TUE": 2, "WED": 3,
+    "THU": 4, "FRI": 5, "SAT": 6,
+  }
+  if val, ok := weekdayNames[strings.ToUpper(field)]; ok {
+    return val, nil
+  }
+  
   v, err := strconv.Atoi(field)
   if err != nil {
     return 0, fmt.Errorf("invalid number: %s", field)
   }
   if v < 0 || v > 6 {
     return 0, fmt.Errorf("value %d out of range [0-6]", v)
   }
   return v, nil
 }

120-135: Consider supporting ranges and lists in cron fields.

The current implementation only supports single values or wildcards. Standard cron syntax includes ranges (e.g., "1-5") and lists (e.g., "0,15,30,45"). Adding this support would improve compatibility with existing cron specifications.

Example enhancement to support ranges and lists:

func parseField(field string, min, max int) ([]int, error) {
    if field == "*" || field == "?" {
        return nil, nil // nil indicates wildcard
    }
    
    var values []int
    for _, part := range strings.Split(field, ",") {
        if strings.Contains(part, "-") {
            // Parse range
            rangeParts := strings.Split(part, "-")
            if len(rangeParts) != 2 {
                return nil, fmt.Errorf("invalid range: %s", part)
            }
            start, err := strconv.Atoi(rangeParts[0])
            if err != nil {
                return nil, fmt.Errorf("invalid range start: %s", rangeParts[0])
            }
            end, err := strconv.Atoi(rangeParts[1])
            if err != nil {
                return nil, fmt.Errorf("invalid range end: %s", rangeParts[1])
            }
            if start < min || end > max || start > end {
                return nil, fmt.Errorf("range %d-%d out of bounds [%d-%d]", start, end, min, max)
            }
            for i := start; i <= end; i++ {
                values = append(values, i)
            }
        } else {
            // Parse single value
            val, err := strconv.Atoi(part)
            if err != nil {
                return nil, fmt.Errorf("invalid number: %s", part)
            }
            if val < min || val > max {
                return nil, fmt.Errorf("value %d out of range [%d-%d]", val, min, max)
            }
            values = append(values, val)
        }
    }
    return values, nil
}

This would require updating the cronSchedule struct and matches method to handle multiple values per field.


153-164: Consider optimising the Next() search algorithm.

The current implementation performs a linear minute-by-minute search up to 4 years. For schedules with large gaps (e.g., yearly), this could check over 2 million iterations. Consider a more efficient approach that jumps to likely candidates.

For example, if the schedule is monthly (day=1, month=-1), you could jump directly to the 1st of each month rather than checking every minute:

func (c *cronSchedule) Next(t time.Time) (time.Time, bool) {
    next := t.Add(time.Minute).Truncate(time.Minute)
    
    // Fast path for common patterns
    if c.minute != -1 && c.hour != -1 && c.day != -1 && c.month == -1 && c.weekday == -1 {
        // Daily at specific time - jump to next occurrence
        candidate := time.Date(next.Year(), next.Month(), next.Day(), c.hour, c.minute, 0, 0, next.Location())
        if candidate.After(t) && c.matches(candidate) {
            return candidate, true
        }
        candidate = candidate.AddDate(0, 0, 1)
        if c.matches(candidate) {
            return candidate, true
        }
    }
    
    // Fallback to minute-by-minute search
    for i := 0; i < 4*365*24*60; i++ {
        if c.matches(next) {
            return next, true
        }
        next = next.Add(time.Minute)
    }
    
    return time.Time{}, false
}
schedule.go (1)

103-115: Consider adding protection against negative durations in waitUntil.

While the new Next() implementation returns false for unreachable schedules, there's still a risk if until is in the past (e.g., if system clock changes or there's a race condition). Consider adding a guard.

 func waitUntil(ctx context.Context, clock clock.Clock, until time.Time) error {
   timeDiffAsDuration := until.Sub(clock.Now())
+  
+  // If the target time is in the past, return immediately
+  if timeDiffAsDuration <= 0 {
+    return nil
+  }

   t := clock.NewTimer(timeDiffAsDuration)
   defer t.Stop()

   select {
   case <-ctx.Done():
     return ctx.Err()
   case <-t.C():
     return nil
   }
 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 489310b and 80ddd91.

📒 Files selected for processing (3)
  • internal/cron/cron.go (1 hunks)
  • internal/cron/cron_test.go (1 hunks)
  • schedule.go (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/cron/cron_test.go
🧰 Additional context used
🧬 Code graph analysis (1)
schedule.go (1)
internal/cron/cron.go (1)
  • Parse (14-26)

@andrewwormald andrewwormald merged commit 12ec56b into main Sep 18, 2025
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

Comments