Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ All notable changes to LaraFly are documented here. This project uses CalVer (`Y

## [Unreleased]

## [26.09.11] - 2026-09-30

### Fixed

- **Scheduling:** every duration of a `#[Scheduled]` method — `fixedRate`, `fixedDelay` and `lockTtl`, as
`initialDelay` already was — is parsed at boot, and one that does not parse refuses the boot naming its method.
`lockTtl` used to be parsed only when its task ran, so an unparseable value failed that task on every tick and
the task never ran while the application booted and looked healthy.
- **Durations:** `Firefly\Resilience\Duration` also reads ISO-8601 durations (`PT14M`, `PT1H30M`, `P1D`,
`PT0.25S`), the form Spring's `@Scheduled` and `java.time.Duration` use. Years, months and weeks are refused
because their length is not fixed. Resilience settings and every scheduling duration accept it.

## [26.09.10] - 2026-09-29

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<a href="docs/installation.md#requirements"><img src="https://img.shields.io/badge/php-8.3%2B-blue?logo=php&logoColor=white" alt="PHP 8.3+"></a>
<a href="docs/laravel-comparison.md"><img src="https://img.shields.io/badge/Laravel-13-FF2D20?logo=laravel&logoColor=white" alt="Laravel 13"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License: Apache 2.0"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-26.09.10-brightgreen" alt="Version: 26.09.10"></a>
<a href="CHANGELOG.md"><img src="https://img.shields.io/badge/version-26.09.11-brightgreen" alt="Version: 26.09.11"></a>
<a href="docs/contributing.md#conventions"><img src="https://img.shields.io/badge/PHPStan-max-8A2BE2" alt="PHPStan: max"></a>
<a href="pint.json"><img src="https://img.shields.io/badge/code%20style-Pint-F55247" alt="Code Style: Pint"></a>
</p>
Expand Down
6 changes: 4 additions & 2 deletions docs/modules/resilience.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,10 @@ several of those example values are deliberately not the framework default (the
```

Duration-shaped keys (`wait-duration`, `max-wait`, `wait-duration-in-open`, `timeout`, …) accept either a
bare number of seconds or a `Firefly\Resilience\Duration`-parsed string: `250ms`, `30s`, `5m`, `1h`. `Duration`
is also reused by `firefly/scheduling` for `lock-ttl` and `fixedRate`/`fixedDelay`.
bare number of seconds or a `Firefly\Resilience\Duration`-parsed string: `250ms`, `30s`, `5m`, `1h`, or an ISO-8601
duration such as `PT5M` (days, hours, minutes and seconds; years, months and weeks are refused because their length
is not fixed). `Duration` is also reused by `firefly/scheduling` for `lockTtl`, `initialDelay` and
`fixedRate`/`fixedDelay`.

## The six patterns

Expand Down
15 changes: 10 additions & 5 deletions docs/modules/scheduling.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,19 @@ Exactly one of `cron`, `fixedRate`, or `fixedDelay` must be set (the attribute's
`InvalidArgumentException` otherwise):

- **`cron`** — a Laravel/crontab expression, applied to the scheduled `Event` verbatim (`$event->cron(...)`).
- **`fixedRate`** / **`fixedDelay`** — a `Duration`-parsed string (`'250ms'`, `'30s'`, `'5m'`, `'1h'`, or a
bare number of seconds) mapped to the *nearest* native Laravel frequency method — see
[Known-latent](#known-latent).
- **`fixedRate`** / **`fixedDelay`** — a `Duration`-parsed string (`'250ms'`, `'30s'`, `'5m'`, `'1h'`, a bare
number of seconds, or an ISO-8601 duration such as `'PT5M'`, the form Spring's `@Scheduled` reads) mapped to the
*nearest* native Laravel frequency method — see [Known-latent](#known-latent).
- **`lock`** — `true` shares a lock named `"Class::method"` (derived from the annotated method); a string is
an explicit shared lock name (so several methods can share one lock); `null`/`false` (the default) runs
unlocked, with no `DistributedLock` guard at all.
- **`lockTtl`** — a `Duration`-parsed string bounding how long the lock may be held; defaults to `30.0`
seconds when the trigger is locked and no `lockTtl` is given.
- **`lockTtl`** — a `Duration`-parsed string, in either form, bounding how long the lock may be held; defaults
to `30.0` seconds when the trigger is locked and no `lockTtl` is given.

Every duration of a `#[Scheduled]` method (`fixedRate`, `fixedDelay`, `initialDelay`, `lockTtl`) is parsed when
the application boots, and one that does not parse refuses the boot with the method it belongs to. Before 26.09.11
a `lockTtl` was only parsed when its task ran, so an unparseable value failed that task on every tick while the
application looked healthy.

<!-- illustrative: an application's own bean with two scheduled methods -->
```php
Expand Down
2 changes: 1 addition & 1 deletion docs/publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ CI runs these package checks on PHP 8.3, 8.4 and 8.5 for PRs to `main` and pushe
To repeat the public install check locally:

```bash
RELEASE_TAG=v26.09.10 RELEASE_SHA="$(git rev-parse 'v26.09.10^{commit}')" php scripts/check-package-install.php --published
RELEASE_TAG=v26.09.11 RELEASE_SHA="$(git rev-parse 'v26.09.11^{commit}')" php scripts/check-package-install.php --published
```

An unmerged branch or a local consumer check is not a published release.
Expand Down
2 changes: 1 addition & 1 deletion docs/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ The single place the current version *is* asserted in code is:
```php
final class Version
{
public const string VERSION = '26.09.10';
public const string VERSION = '26.09.11';
}
```

Expand Down
2 changes: 1 addition & 1 deletion packages/kernel/src/Version.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@
*/
final class Version
{
public const string VERSION = '26.09.10';
public const string VERSION = '26.09.11';
}
25 changes: 21 additions & 4 deletions packages/resilience/src/Duration.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,34 @@
use Firefly\Kernel\Exception\Framework\ConfigurationException;

/**
* Parses a human duration string to a float number of seconds. Grammar: an optional-decimal magnitude with
* an optional unit suffix (ms/s/m/h); a bare number is seconds (pyfly parity). Shared by resilience config
* (wait-duration, timeout, …) and scheduling (lock-ttl, fixed-rate).
* Parses a duration string to a float number of seconds. Two grammars are accepted:
*
* - the short form: an optional-decimal magnitude with an optional unit suffix (ms/s/m/h); a bare number is
* seconds (pyfly parity);
* - ISO-8601 as java.time.Duration reads it (Spring parity): `P[nD][T[nH][nM][n[.n]S]]`, case-insensitive, with at
* least one component. Years, months and weeks are refused, as java.time.Duration refuses them: their length
* in seconds is not fixed.
*
* Shared by resilience config (wait-duration, timeout, …) and scheduling (lock-ttl, fixed-rate, initial-delay).
*/
final class Duration
{
private const string ISO_8601 = '/^\s*P(?:(\d+)D)?(?:T(?=\d)(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?\s*$/i';

public static function parse(string $value): float
{
if (preg_match(self::ISO_8601, $value, $iso) === 1 && preg_match('/\d/', $value) === 1) {
// A trailing `T` with nothing after it is caught by the lookahead; `P` alone has no digit at all.
return (float) ($iso[1] ?? 0) * 86400.0
+ (float) ($iso[2] ?? 0) * 3600.0
+ (float) ($iso[3] ?? 0) * 60.0
+ (float) ($iso[4] ?? 0);
}

if (preg_match('/^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h)?\s*$/', $value, $matches) !== 1) {
throw new ConfigurationException(
"Invalid duration [{$value}]. Use e.g. '250ms', '30s', '5m', '1h', or a bare number of seconds.",
"Invalid duration [{$value}]. Use e.g. '250ms', '30s', '5m', '1h', a bare number of seconds, "
."or an ISO-8601 duration such as 'PT5M'.",
);
}

Expand Down
33 changes: 33 additions & 0 deletions packages/resilience/tests/DurationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,36 @@
it('rejects an unparseable duration', function (string $input) {
expect(fn () => Duration::parse($input))->toThrow(ConfigurationException::class);
})->with(['empty' => [''], 'letters' => ['abc'], 'bad unit' => ['5d'], 'negative' => ['-3s']]);

/*
* SPRING'S DURATION FORMAT IS ACCEPTED TOO. `#[Scheduled]` claims @Scheduled parity, and Spring reads
* `fixedDelayString`, `initialDelayString` and lock durations as ISO-8601 (`PT14M`), the java.time.Duration
* form. Days, hours, minutes and fractional seconds are accepted case-insensitively; years, months and weeks
* are refused because their length in seconds is not fixed, as java.time.Duration refuses them.
*/
it('parses an ISO-8601 duration to seconds', function (string $input, float $seconds) {
expect(Duration::parse($input))->toBe($seconds);
})->with([
'minutes' => ['PT14M', 840.0],
'hours' => ['PT1H', 3600.0],
'seconds' => ['PT30S', 30.0],
'fractional seconds' => ['PT0.25S', 0.25],
'hours and minutes' => ['PT1H30M', 5400.0],
'days' => ['P1D', 86400.0],
'days and hours' => ['P1DT2H', 93600.0],
'lower case' => ['pt5m', 300.0],
'whitespace tolerated' => [' PT4M ', 240.0],
]);

it('rejects an ISO-8601 duration without a fixed length or a component', function (string $input) {
expect(fn () => Duration::parse($input))->toThrow(ConfigurationException::class);
})->with([
'no component' => ['P'],
'time marker only' => ['PT'],
'years' => ['P1Y'],
'months' => ['P1M'],
'weeks' => ['P1W'],
'negative' => ['-PT5M'],
'unit missing' => ['PT5'],
'minutes before hours' => ['PT5M1H'],
]);
4 changes: 3 additions & 1 deletion packages/scheduling/src/Attributes/Scheduled.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
/**
* Marks a public method as a scheduled task (pyfly/Spring @Scheduled parity). Exactly one trigger is required:
* `cron` (a Laravel/crontab expression, applied verbatim), `fixedRate`, or `fixedDelay` (duration strings parsed
* by Duration::parse at wiring time and mapped to the nearest native Laravel frequency). `lock === true` shares a
* by Duration::parse at wiring time and mapped to the nearest native Laravel frequency). Durations — these two,
* `initialDelay` and `lockTtl` — take the short form ('30s', '5m') or ISO-8601 ('PT5M'), and a value that does not
* parse refuses to boot. `lock === true` shares a
* lock named "Class::method"; a string is an explicit shared lock name; null/false runs unlocked. The scanner
* (the package's sole reflection site) reads these into pure-array descriptors; nothing here parses durations.
*/
Expand Down
30 changes: 30 additions & 0 deletions packages/scheduling/src/Boot/ScheduleWiringPass.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ public function run(BootContext $context): void
$lock = $container->make(DistributedLock::class);

$config = $context->config;
$this->validateDurations($manifest);
$this->validateDelays($manifest, $config);

$container->afterResolving(Schedule::class, function (Schedule $schedule) use ($manifest, $lock, $container, $config): void {
Expand All @@ -84,6 +85,35 @@ public function run(BootContext $context): void
});
}

/**
* `fixedRate`, `fixedDelay` and `lockTtl` are PARSED HERE for their refusal, as validateDelays() parses
* `initialDelay`. Neither the attribute nor the scanner validates them. `lockTtl` used to be parsed only inside
* the task closure, so a string Duration::parse refused threw on every tick, was reported and the task never
* ran — while the application booted, served and looked healthy. A rate or delay was parsed when the Schedule
* was resolved, so one bad string stopped every task of the scheduler, not just its own.
*/
private function validateDurations(ScheduledManifest $manifest): void
{
foreach ($manifest->all() as $descriptor) {
$durations = ['fixedRate' => $descriptor->fixedRate, 'fixedDelay' => $descriptor->fixedDelay, 'lockTtl' => $descriptor->lockTtl];
foreach ($durations as $parameter => $value) {
if ($value === null) {
continue;
}

try {
Duration::parse($value);
} catch (ConfigurationException $exception) {
throw new ConfigurationException(
"#[Scheduled({$parameter}: '{$value}')] on {$descriptor->class}::{$descriptor->method} "
."is not a duration this framework can parse. {$exception->getMessage()}",
previous: $exception,
);
}
}
}
}

/**
* `initialDelay` is APPLIED as a per-tick predicate (see InitialDelayGate) — or, when the gate is
* switched off, REFUSED at boot. The one thing it must never do again is what it did for two releases:
Expand Down
65 changes: 65 additions & 0 deletions packages/scheduling/tests/Boot/ScheduleWiringPassTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -368,3 +368,68 @@ public function store($name = null)

expect($logger->records)->toBe([]);
});

/*
* A LOCK TTL THAT IS NOT A DURATION USED TO DISABLE ITS TASK FOR GOOD. `lockTtl` was parsed inside the task
* closure, so a value Duration::parse refused threw on every tick, was reported and the task never ran — while
* the application booted, served and looked healthy. Every duration of the manifest is now proved where
* initialDelay already was: at boot.
*/

it('REFUSES TO BOOT on a lockTtl that is not a duration, rather than failing its task on every tick', function () {
$refuse = fn () => scheduleWithInitialDelayGate([
new ScheduledDescriptor(class: ScheduledJobs::class, method: 'reconcile', cron: '*/5 * * * *', lockName: 'reconcile', lockTtl: 'four minutes'),
], enabled: true);

expect($refuse)->toThrow(
ConfigurationException::class,
"#[Scheduled(lockTtl: 'four minutes')] on ".ScheduledJobs::class.'::reconcile',
);
});

it('REFUSES TO BOOT on a fixedRate or fixedDelay that is not a duration', function (string $parameter) {
$refuse = fn () => scheduleWithInitialDelayGate([
new ScheduledDescriptor(...['class' => ScheduledJobs::class, 'method' => 'reconcile', $parameter => 'often']),
], enabled: true);

expect($refuse)->toThrow(
ConfigurationException::class,
"#[Scheduled({$parameter}: 'often')] on ".ScheduledJobs::class.'::reconcile',
);
})->with(['fixedRate', 'fixedDelay']);

it('acquires the lock for the ISO-8601 lockTtl Spring authors write', function () {
$lock = new class implements DistributedLock
{
/** @var array<string, float> */
public array $acquired = [];

public function tryAcquire(string $name, float $ttlSeconds): bool
{
$this->acquired[$name] = $ttlSeconds;

return true;
}

public function release(string $name): void {}
};
$container = new Container;
Container::setInstance($container);
$container->instance(CacheFactoryContract::class, new class implements CacheFactoryContract
{
public function store($name = null)
{
return new CacheRepository(new ArrayStore);
}
});
$container->instance(DistributedLock::class, $lock);
$container->instance(ScheduledManifest::class, new ScheduledManifest([
new ScheduledDescriptor(class: ScheduledJobs::class, method: 'reconcile', cron: '*/5 * * * *', lockName: 'reconcile', lockTtl: 'PT4M'),
]));
(new ScheduleWiringPass)->run(scheduleWiringContext($container));

$event = $container->make(Schedule::class)->events()[0];
$event->run($container);

expect($lock->acquired)->toBe(['reconcile' => 240.0]);
});
Loading