From e79b411328b760e1d5ea8060ed6e350712fa4dce Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Wed, 4 Nov 2026 04:47:21 -0600 Subject: [PATCH] Document Carbon precision limits for huge friendly durations --- CHANGELOG.md | 11 ++++++++- docs/portal/build/workflows-activities.md | 21 ++++++++++++++--- src/Worker/WorkflowDuration.php | 6 +++-- tests/WorkflowDurationTest.php | 28 +++++++++++++++++++++++ 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b2b4937..2510a805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -156,7 +156,16 @@ restarting a `waitCondition()` timeout (#269). interval therefore uses its fields (a 4-week month), not its real date span. - Carbon's default cascade factors make the result independent of the - current date, time zone, PHP version and Carbon major version. + current date and time zone. + - **Correction (#265):** the original notes also claimed independence + from PHP and Carbon major versions. Very large durations containing + millisecond or microsecond parts retain the installed Carbon major's + floating-point behavior. For example, + `'5555555555555 seconds 32037 milliseconds'` converts to + `5555555555588` seconds on Carbon 2 and `5555555555556` on Carbon 3. + SDK/embedded parity on each major is preserved. The regression sample + points do not establish a universal precision bound, and no conversion + behavior changes with this documentation correction. - **Stricter than the embedded package.** These throw `InvalidArgumentException` naming the parameter and the accepted forms: - unknown units, extra words or commas; diff --git a/docs/portal/build/workflows-activities.md b/docs/portal/build/workflows-activities.md index b8137a12..1d6360c5 100644 --- a/docs/portal/build/workflows-activities.md +++ b/docs/portal/build/workflows-activities.md @@ -78,12 +78,27 @@ measured by their year, month, day, hour, minute, second and microsecond fields with `CarbonInterval::instance()->totalSeconds`. Carbon converts with its cascade factors, not the calendar: by default a day is -24 hours, a week 7 days, a month 4 weeks and a year 12 months. The result does -not depend on the current date, time zone, daylight-saving changes, PHP version -or Carbon major version. Changing the factors with +24 hours, a week 7 days, a month 4 weeks and a year 12 months. Those factors do +not depend on the current date, time zone or daylight-saving changes. Changing +the factors with `CarbonInterval::setCascadeFactors()` changes the result, in the SDK and in the embedded package, so leave them at their defaults in worker processes. +Conversion retains the installed Carbon major's floating-point behavior. Very +large accepted durations with millisecond or microsecond parts can produce +different whole-second results across Carbon majors. For example, +`'5555555555555 seconds 32037 milliseconds'` converts to `5555555555588` +seconds on Carbon 2 and `5555555555556` on Carbon 3. Each matches the embedded +package on that same Carbon major. + +Regression samples span base durations from 10 billion seconds through the +reported value above, with cases around 100 billion and one trillion seconds. +These sample points do not establish a universal precision bound. Even a +one-microsecond part can disappear before +rounding at large magnitudes. Keep the Carbon major consistent when workers +replay runs using such duration strings; explicit integer seconds avoid this +string-conversion difference. + A `CarbonInterval` from `diff()` is measured by its fields too. For example, the interval from January 1 to February 10, 01:00 is 1 month, 9 days and 1 hour, so it converts to 37 days and 1 hour. On Carbon 3, the embedded package measures diff --git a/src/Worker/WorkflowDuration.php b/src/Worker/WorkflowDuration.php index 8f9877d3..7b119ee3 100644 --- a/src/Worker/WorkflowDuration.php +++ b/src/Worker/WorkflowDuration.php @@ -17,8 +17,10 @@ * measured by their y/m/d/h/i/s/f fields with `CarbonInterval::instance()->totalSeconds`, which * matches the embedded package for every interval that does not carry diff() start and end dates. * Carbon applies its cascade factors rather than a calendar (by default a day is 24 hours, a week - * 7 days, a month 4 weeks and a year 12 months), so the result does not depend on the current date, - * time zone, PHP version or Carbon major version. Changing the factors globally with + * 7 days, a month 4 weeks and a year 12 months), so calendar dates and time zones do not affect + * measurement. Floating-point precision is retained from the installed Carbon major: very large + * durations containing millisecond or microsecond parts can produce different whole seconds + * across majors. Changing the factors globally with * `CarbonInterval::setCascadeFactors()` changes the result, as it does in the embedded package. * * Numeric strings such as "30" or "1.5" convert as they were coerced when these parameters were diff --git a/tests/WorkflowDurationTest.php b/tests/WorkflowDurationTest.php index 1688f1ae..b21d7450 100644 --- a/tests/WorkflowDurationTest.php +++ b/tests/WorkflowDurationTest.php @@ -6,6 +6,7 @@ use Carbon\Carbon; use Carbon\CarbonInterval; +use Composer\InstalledVersions; use DateInterval; use DurableWorkflow\Codec\AvroPayloadCodec; use DurableWorkflow\Exception\NonDeterministicWorkflow; @@ -236,6 +237,33 @@ public function testStringsConvertExactlyLikeTheEmbeddedPackage(string $duration self::assertSame($seconds, WorkflowDuration::seconds($duration, 'timer')); } + /** @return iterable */ + public static function largeSubSecondStrings(): iterable + { + yield 'microsecond at ten billion seconds' => ['10000000000 seconds 1 microsecond', [2 => 10_000_000_001, 3 => 10_000_000_001]]; + yield 'microsecond lost at one hundred billion seconds' => ['100000000000 seconds 1 microsecond', [2 => 100_000_000_000, 3 => 100_000_000_000]]; + yield 'milliseconds at one hundred billion seconds' => ['100000000000 seconds 32037 milliseconds', [2 => 100_000_000_033, 3 => 100_000_000_033]]; + yield 'microseconds at one hundred billion seconds' => ['100000000000 seconds 32037000 microseconds', [2 => 100_000_000_033, 3 => 100_000_000_033]]; + yield 'milliseconds below one trillion seconds' => ['999999999999 seconds 32037 milliseconds', [2 => 1_000_000_000_032, 3 => 1_000_000_000_032]]; + yield 'milliseconds at one trillion seconds' => ['1000000000000 seconds 32037 milliseconds', [2 => 1_000_000_000_033, 3 => 1_000_000_000_001]]; + yield 'microseconds at one trillion seconds' => ['1000000000000 seconds 32037000 microseconds', [2 => 1_000_000_000_033, 3 => 1_000_000_000_001]]; + yield 'reported milliseconds example' => ['5555555555555 seconds 32037 milliseconds', [2 => 5_555_555_555_588, 3 => 5_555_555_555_556]]; + yield 'reported example in microseconds' => ['5555555555555 seconds 32037000 microseconds', [2 => 5_555_555_555_588, 3 => 5_555_555_555_556]]; + } + + /** @param array{2: int, 3: int} $secondsByCarbonMajor */ + #[DataProvider('largeSubSecondStrings')] + public function testLargeSubSecondStringsPreserveTheInstalledCarbonBehavior(string $duration, array $secondsByCarbonMajor): void + { + $major = (int) InstalledVersions::getVersion('nesbot/carbon'); + self::assertArrayHasKey($major, $secondsByCarbonMajor); + $seconds = $secondsByCarbonMajor[$major]; + + self::assertSame($seconds, (int) ceil(CarbonInterval::fromString($duration)->totalSeconds)); + self::assertSame($seconds, WorkflowDuration::seconds($duration, 'timer')); + self::assertSame($seconds, self::commandsFor(static fn (WorkflowContext $context) => $context->sleep($duration))[0]['delay_seconds']); + } + public function testIntervalsConvertExactlyLikeTheEmbeddedPackage(): void { foreach ([