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 +18/−0
- README.md +83/−11
- aws-eventbridge-cron.cabal +16/−1
- bench/Main.hs +64/−0
- src/AWS/EventBridge/Cron.hs +10/−10
- src/AWS/EventBridge/Schedule.hs +38/−1
- test/AWS/EventBridge/ScheduleSpec.hs +9/−0
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)