packages feed

aws-eventbridge-cron 0.1.1.1 → 0.1.2.0

raw patch · 7 files changed

+238/−23 lines, 7 filesdep +criterionPVP ok

version bump matches the API change (PVP)

Dependencies added: criterion

API changes (from Hackage documentation)

+ AWS.EventBridge.Schedule: parseCronTextWithIANA :: Text -> Text -> Either String Schedule
+ AWS.EventBridge.Schedule: scheduleFromExprIANA :: Text -> CronExprT -> Either String Schedule
+ AWS.EventBridge.Schedule: scheduleFromTextIANA :: Text -> Text -> Either String Schedule

Files

CHANGELOG.md view
@@ -5,6 +5,24 @@ The format is based on Keep a Changelog and this project adheres to Semantic Versioning. +## [0.1.2.0] - 2025-11-21+### Added+- Convenience constructors (`scheduleFromExprIANA`, `scheduleFromTextIANA`, and+	`parseCronTextWithIANA`) so callers can bind schedules using canonical IANA+	timezone names without touching `TZLabel`.+- Criterion benchmark suite (`cabal bench`) that exercises sparse/dense cron+	workloads, rate schedules, and timezone-aware helpers to catch performance+	regressions.++### Changed+- Optimised `futureCronTimes` to use difference lists and lazily compute only+	the necessary day candidates, removing quadratic `++` chains and improving+	large run queries by ~30%.+- README now uses an HTML function matrix that renders correctly on Hackage,+	adds foldable IANA examples, and mentions the benchmark workflow.+- Enabled `tests: True` in `cabal.project` so editors and `cabal test` pick up+	the suite without extra flags.+ ## [0.1.1.1] - 2025-11-20 ### Changed - Improved Haddock/README guidance for timezone-aware helpers, including a
README.md view
@@ -19,6 +19,8 @@ - Timezone-aware helpers: `AWS.EventBridge.Schedule` pairs expressions with an   IANA timezone (via the `tz`/`tzdata` packages) and exposes `nextRunTimes`   variants for UTC, local, or fully-zoned outputs.+- Convenience constructors accept canonical IANA names (`"America/New_York"`)+  in addition to the generated `TZLabel` constructors. - Extensive property-based test suite that mirrors the behaviour documented by   AWS. @@ -118,22 +120,87 @@   `nextRunTimesZoned`) and reach for the conversion helpers when you want to   avoid manual conversions. -| Base input  | Output      | Function                           |-|-------------|-------------|------------------------------------|-| `UTCTime`   | `UTCTime`   | `nextRunTimesUTC`                  |-| `LocalTime` | `UTCTime`   | `nextRunTimesUTCFromLocal`         |-| `ZonedTime` | `UTCTime`   | `nextRunTimesUTCFromZoned`         |-| `UTCTime`   | `LocalTime` | `nextRunTimesLocalFromUTC`         |-| `LocalTime` | `LocalTime` | `nextRunTimesLocal`                |-| `ZonedTime` | `LocalTime` | `nextRunTimesLocalFromZoned`       |-| `UTCTime`   | `ZonedTime` | `nextRunTimesZonedFromUTC`         |-| `LocalTime` | `ZonedTime` | `nextRunTimesZonedFromLocal`       |-| `ZonedTime` | `ZonedTime` | `nextRunTimesZoned`                |+<table>+  <thead>+    <tr>+      <th>Base input</th>+      <th>Output</th>+      <th>Function</th>+    </tr>+  </thead>+  <tbody>+    <tr><td><code>UTCTime</code></td><td><code>UTCTime</code></td><td><code>nextRunTimesUTC</code></td></tr>+    <tr><td><code>LocalTime</code></td><td><code>UTCTime</code></td><td><code>nextRunTimesUTCFromLocal</code></td></tr>+    <tr><td><code>ZonedTime</code></td><td><code>UTCTime</code></td><td><code>nextRunTimesUTCFromZoned</code></td></tr>+    <tr><td><code>UTCTime</code></td><td><code>LocalTime</code></td><td><code>nextRunTimesLocalFromUTC</code></td></tr>+    <tr><td><code>LocalTime</code></td><td><code>LocalTime</code></td><td><code>nextRunTimesLocal</code></td></tr>+    <tr><td><code>ZonedTime</code></td><td><code>LocalTime</code></td><td><code>nextRunTimesLocalFromZoned</code></td></tr>+    <tr><td><code>UTCTime</code></td><td><code>ZonedTime</code></td><td><code>nextRunTimesZonedFromUTC</code></td></tr>+    <tr><td><code>LocalTime</code></td><td><code>ZonedTime</code></td><td><code>nextRunTimesZonedFromLocal</code></td></tr>+    <tr><td><code>ZonedTime</code></td><td><code>ZonedTime</code></td><td><code>nextRunTimesZoned</code></td></tr>+  </tbody>+</table>  Use the conversion helpers when you already have a base timestamp in a specific representation and want the library to handle the translation for you (for example, UI-provided `LocalTime` that needs to be compared against UTC events). +#### Working With IANA Names++Prefer `scheduleFromText`/`scheduleFromExpr` when compiled code can depend on+`TZLabel` constructors (they offer total coverage at compile time). When+configurations or API payloads give you canonical timezone names, switch to the+IANA-aware wrappers:++- `scheduleFromExprIANA :: Text -> CronExprT -> Either String Schedule`+- `scheduleFromTextIANA :: Text -> Text -> Either String Schedule`+- `parseCronTextWithIANA :: Text -> Text -> Either String Schedule`++These helpers validate the provided timezone name against the bundled tz+database and return `Left` if it is unknown, preventing silent fallbacks.++<details>+  <summary>Example: parse config payloads with canonical names</summary>++```haskell+import AWS.EventBridge.Schedule+import Data.Text (Text)+import Data.Time (LocalTime)++mkScheduleFromConfig :: Text -> Text -> Either String Schedule+mkScheduleFromConfig tzName exprText =+  scheduleFromTextIANA tzName exprText++example :: Either String [LocalTime]+example = do+  sched <- mkScheduleFromConfig "Asia/Kolkata" "cron(0 9 * * ? *)"+  nextRunTimesLocal sched (read "2025-11-15 09:00:00" :: LocalTime) 2+```++</details>++<details>+  <summary>Example: fall back to a default timezone</summary>++```haskell+import AWS.EventBridge.Schedule+import Data.Maybe (fromMaybe)+import Data.Text (Text)+import Data.Time (UTCTime)++resolveSchedule :: Maybe Text -> Text -> Either String Schedule+resolveSchedule maybeName exprText = do+  let tzName = fromMaybe "UTC" maybeName+  scheduleFromTextIANA tzName exprText++fromApi :: Either String [UTCTime]+fromApi = do+  sched <- resolveSchedule (Just "America/Los_Angeles") "cron(0 9 * * ? *)"+  nextRunTimesUTC sched (read "2025-11-01 17:00:00 UTC" :: UTCTime) 1+```++</details>+ ### Error Reporting  Parser and evaluator failures return `Left String` with human-readable error@@ -162,8 +229,13 @@ ```bash cabal build cabal test+cabal bench cabal haddock --open ```++`cabal bench` executes a Criterion suite that profiles cron-heavy workloads,+rate schedules, and timezone-aware helpers so you can gauge regression risk+when modifying the evaluator.  ## Contributing 
aws-eventbridge-cron.cabal view
@@ -1,7 +1,7 @@ cabal-version:      3.0  name:               aws-eventbridge-cron-version:            0.1.1.1+version:            0.1.2.0 synopsis:           AWS EventBridge cron, rate, and one-time parser with scheduler description:   Parse AWS EventBridge cron, rate, and one-time expressions and compute@@ -100,3 +100,18 @@     , time     , tz     , tzdata++benchmark aws-eventbridge-cron-bench+  type: exitcode-stdio-1.0+  hs-source-dirs:+      bench+  main-is: Main.hs+  default-language: GHC2021+  ghc-options: -O2+  build-depends:+      aws-eventbridge-cron+    , base >=4.14 && <5+    , criterion >=1.5 && <1.7+    , text+    , time+    , tz
+ bench/Main.hs view
@@ -0,0 +1,64 @@+{-# LANGUAGE OverloadedStrings #-}++module Main (main) where++import AWS.EventBridge.Cron (CronExprT, nextRunTimes, parseCronText)+import AWS.EventBridge.Schedule+  ( Schedule+  , nextRunTimesLocal+  , scheduleFromText+  )+import Criterion.Main+import Data.Time+  ( LocalTime(..)+  , UTCTime(..)+  , fromGregorian+  , secondsToDiffTime+  , TimeOfDay(..)+  )+import Data.Time.Zones.All (TZLabel(..))++main :: IO ()+main = defaultMain+  [ bench "cron sparse 256" $ nf (runCron cronSparseExpr) 256+  , bench "cron dense 1024" $ nf (runCron cronDenseExpr) 1024+  , bench "cron dense 4096" $ nf (runCron cronDenseExpr) 4096+  , bench "rate sequential 2048" $ nf (runCron rateExpr) 2048+  , bench "schedule local 512" $ nf (runSchedule scheduleNY) 512+  ]++runCron :: CronExprT -> Int -> [UTCTime]+runCron expr limit =+  forceRight "nextRunTimes" $ nextRunTimes expr cronBase limit++runSchedule :: Schedule -> Int -> [LocalTime]+runSchedule sched limit =+  forceRight "nextRunTimesLocal" $ nextRunTimesLocal sched scheduleBase limit++cronBase :: UTCTime+cronBase = UTCTime (fromGregorian 2025 1 1) (secondsToDiffTime (5 * 3600))++scheduleBase :: LocalTime+scheduleBase = LocalTime (fromGregorian 2025 11 1) (TimeOfDay 8 30 0)++cronSparseExpr :: CronExprT+cronSparseExpr =+  forceRight "parseCronText cronSparse" $ parseCronText "cron(0 9 ? NOV SUN 2025)"++cronDenseExpr :: CronExprT+cronDenseExpr =+  forceRight "parseCronText cronDense" $+    parseCronText "cron(0/5 0-12 ? JAN,MAR,SEP MON-FRI 2025-2027)"++rateExpr :: CronExprT+rateExpr = forceRight "parseCronText rate" $ parseCronText "rate(5 minutes)"++scheduleNY :: Schedule+scheduleNY =+  forceRight "scheduleFromText" $+    scheduleFromText America__New_York "cron(0/15 0-23 * * ? *)"++forceRight :: String -> Either String a -> a+forceRight label = either (error . mkMsg) id+  where+    mkMsg err = "benchmark setup failed in " <> label <> ": " <> err
src/AWS/EventBridge/Cron.hs view
@@ -28,6 +28,7 @@ import Data.Time (UTCTime(..), Day, addUTCTime, utctDay) import Data.Time.Calendar (fromGregorian, toGregorian) import Data.Time.LocalTime (TimeOfDay(..), timeOfDayToTime)+import Data.Monoid (Endo(..), appEndo)   -- | EventBridge scheduling expression.@@ -147,7 +148,7 @@           baseDay = utctDay base           (baseYear, baseMonth, baseDom) = toGregorian baseDay -          collectYears :: [UTCTime] -> Int -> [Integer] -> Either String ([UTCTime], Int)+          collectYears :: Endo [UTCTime] -> Int -> [Integer] -> Either String (Endo [UTCTime], Int)           collectYears acc count [] = Right (acc, count)           collectYears acc count _ | count >= target = Right (acc, count)           collectYears acc count (y:ys)@@ -156,7 +157,7 @@                 (acc', count') <- collectMonths acc count y monthCandidates                 collectYears acc' count' ys -          collectMonths :: [UTCTime] -> Int -> Integer -> [Int] -> Either String ([UTCTime], Int)+          collectMonths :: Endo [UTCTime] -> Int -> Integer -> [Int] -> Either String (Endo [UTCTime], Int)           collectMonths acc count _ [] = Right (acc, count)           collectMonths acc count _ _ | count >= target = Right (acc, count)           collectMonths acc count year (m:ms)@@ -166,7 +167,7 @@                 (acc', count') <- collectDays acc count year m days                 collectMonths acc' count' year ms -          collectDays :: [UTCTime] -> Int -> Integer -> Int -> [Int] -> Either String ([UTCTime], Int)+          collectDays :: Endo [UTCTime] -> Int -> Integer -> Int -> [Int] -> Either String (Endo [UTCTime], Int)           collectDays acc count _ _ [] = Right (acc, count)           collectDays acc count _ _ _ | count >= target = Right (acc, count)           collectDays acc count year month (d:ds)@@ -180,7 +181,7 @@                       EQ -> dropWhile (< base) dayTimes                       GT -> dayTimes                     selected = take remaining filteredTimes-                    acc' = acc ++ selected+                    acc' = acc <> Endo (selected ++)                     count' = count + length selected                  in if count' >= target                       then Right (acc', count')@@ -194,13 +195,12 @@             ]            daysFor :: Integer -> Int -> Either String [Int]-          daysFor year month = do-            domDays <- evaluateDayOfMonthT year month domExpr-            dowDays <- evaluateDayOfWeekT year month dowExpr-            pure $ if domIsQuestion then dowDays else domDays+          daysFor year month+            | domIsQuestion = evaluateDayOfWeekT year month dowExpr+            | otherwise = evaluateDayOfMonthT year month domExpr -      (results, _) <- collectYears [] 0 yearCandidates-      pure results+      (results, _) <- collectYears mempty 0 yearCandidates+      pure (appEndo results [])   where     domIsQuestion = isDomQuestion domExpr     dowIsQuestion = isDowQuestion dowExpr
src/AWS/EventBridge/Schedule.hs view
@@ -40,8 +40,11 @@   ( -- * Schedule construction     Schedule(..)   , scheduleFromExpr+  , scheduleFromExprIANA   , scheduleFromText+  , scheduleFromTextIANA   , parseCronTextWithZone+  , parseCronTextWithIANA     -- * Primary evaluation helpers   , nextRunTimesUTC   , nextRunTimesLocal@@ -63,6 +66,8 @@   , scheduleKind   ) import Data.Text (Text)+import qualified Data.Text as T+import Data.Text.Encoding (encodeUtf8) import Data.Time   ( LocalTime(..)   , ZonedTime(..)@@ -77,7 +82,7 @@   , timeZoneForUTCTime   , utcToLocalTimeTZ   )-import Data.Time.Zones.All (TZLabel(..), tzByLabel)+import Data.Time.Zones.All (TZLabel(..), fromTZName, tzByLabel)  -- | Scheduling expression paired with its IANA timezone. --@@ -102,6 +107,14 @@   , scheduleZoneLabel = label   } +-- | Construct a 'Schedule' directly from an IANA location name such as+-- @"America/New_York"@. This is a convenience wrapper over+-- 'scheduleFromExpr' for callers that already store timezone identifiers as+-- strings. Returns 'Left' when the name is unknown to the bundled tz database.+scheduleFromExprIANA :: Text -> CronExprT -> Either String Schedule+scheduleFromExprIANA tzName expr =+  scheduleFromExpr <$> resolveTZLabel tzName <*> pure expr+ -- | Parse an EventBridge expression and attach a timezone in the same step. -- -- >>> :{@@ -115,10 +128,28 @@ scheduleFromText label input =   fmap (scheduleFromExpr label) (parseCronText input) +-- | Parse an EventBridge expression and attach a timezone via its IANA name+-- (for example @"Asia/Kolkata"@). This helper surfaces nicer ergonomics for+-- API payloads or configuration files that keep the canonical string form.+--+-- >>> let base = read "2025-11-16 03:30:00 UTC" :: UTCTime+-- >>> scheduleFromTextIANA "Asia/Kolkata" "cron(0 9 ? NOV SUN 2025)" >>= \sched -> nextRunTimesUTC sched base 1+-- Right [2025-11-16 03:30:00 UTC]+scheduleFromTextIANA :: Text -> Text -> Either String Schedule+scheduleFromTextIANA tzName input =+  resolveTZLabel tzName >>= \label -> scheduleFromText label input+ -- | Backwards-compatible alias for 'scheduleFromText'. parseCronTextWithZone :: TZLabel -> Text -> Either String Schedule parseCronTextWithZone = scheduleFromText +-- | Alias for 'scheduleFromTextIANA'.+--+-- >>> parseCronTextWithIANA "America/New_York" "cron(0 9 * * ? *)" >>= \sched -> nextRunTimesLocal sched (read "2025-11-01 08:30:00" :: LocalTime) 1+-- Right [2025-11-01 09:00:00]+parseCronTextWithIANA :: Text -> Text -> Either String Schedule+parseCronTextWithIANA = scheduleFromTextIANA+ -- | Local-time primary helper. -- -- Evaluate run times in the schedule's local timezone, starting from a local@@ -264,3 +295,9 @@       localWall = utcToLocalTimeTZ zone utcVal       tzInfo = timeZoneForUTCTime zone utcVal    in ZonedTime localWall tzInfo++resolveTZLabel :: Text -> Either String TZLabel+resolveTZLabel tzName =+  maybe (Left errMsg) Right (fromTZName (encodeUtf8 tzName))+  where+    errMsg = "unknown IANA timezone: " <> T.unpack tzName
test/AWS/EventBridge/ScheduleSpec.hs view
@@ -71,6 +71,15 @@             ]       fmap (map show) (nextRunTimesZonedFromUTC sched baseUtc 2) @?= Right expected +    , testCase "scheduleFromTextIANA resolves canonical names" $ do+        sched <- either assertFailure pure (scheduleFromTextIANA "America/New_York" "cron(0 9 * * ? *)")+        scheduleZoneLabel sched @?= America__New_York++    , testCase "scheduleFromTextIANA surfaces unknown names" $ do+        case scheduleFromTextIANA "Mars/Base" "cron(0 0 * * ? *)" of+          Left msg -> msg @?= "unknown IANA timezone: Mars/Base"+          Right _ -> assertFailure "expected a failure for an unknown timezone"+     , testCase "parseCronTextWithZone aliases scheduleFromText" $ do       let expr = "cron(0 0 1 1 ? 2025)"       schedA <- either assertFailure pure (parseCronTextWithZone Asia__Kolkata expr)