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
7 changes: 4 additions & 3 deletions docs/guide/getting_started.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Before we dive in, ensure you understand what SLAs & SLA Timers are by exploring

Let's set up an SLA timer similar to the schedule shown below.

For this example, we're working in the week of 25<sup>th</sup> - 29<sup>th</sup> July 2022, but the library works over [as long as you want*](#disclaimers).
For this example, we're working in the week of 25<sup>th</sup> - 29<sup>th</sup> July 2022, but the library works over [as long as you want](#performance).

<script setup>
import { withBase } from 'vitepress';
Expand Down Expand Up @@ -107,8 +107,9 @@ Sometimes we will want to pause our timer, start this, head over to ["Pausing th

---

#### Disclaimers
## Performance

\* `sifex/sla-timer` is still inefficient at calculating SLAs over periods longer than a month.
SLAs spanning months or years – even with hundreds of pause periods or holidays – are calculated in
milliseconds: a year-long SLA with a holiday on every day takes ~30ms, and a 10-year calculation ~90ms.


18 changes: 18 additions & 0 deletions docs/guide/scheduling.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,21 @@ $sla = SLA::fromSchedule(
->from('23:00:00')->to('23:30:00')->everyDay()
);
```

## Overnight Schedules

Schedules that cross midnight are supported. A window whose end time is earlier than its start time
carries over into the next day:

```php
// Covers 22:00 -> midnight, then midnight -> 02:00 the next day
SLASchedule::create()->from('22:00:00')->to('02:00:00')->everyDay()
```

A full 24 hour day can be expressed by using the same time for `from` and `to`:

```php
// 24/7 coverage
SLASchedule::create()->from('09:00:00')->to('09:00:00')->everyDay()
```

4 changes: 0 additions & 4 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,3 @@ parameters:
level: 8
paths:
- src

ignoreErrors:
# Provided at runtime by the Cmixin\EnhancedPeriod mixin
- '#Call to an undefined method Carbon\\CarbonPeriod::diff\(\)#'
19 changes: 12 additions & 7 deletions src/Agenda/Weekly.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public function setDays(array $days): Weekly
* we need to generate a full number of periods surrounding/covering our subject period, because Carbon is not
* capable of generating a full infinite series of 'Fridays 9am to 5pm', so we have to do the heavy lifting for it
*
* @return CarbonPeriod[]
* @return array<int, array{0: CarbonInterface, 1: CarbonInterface}>
*/
public function toPeriods(CarbonPeriod $subject_period): array
{
Expand All @@ -81,12 +81,17 @@ public function toPeriods(CarbonPeriod $subject_period): array
})
->flatMap(function (CarbonInterface $day) {
return collect($this->time_periods)
->map(function (array $t) use ($day) {
return CarbonPeriod::create(
$day->clone()->setTimeFromTimeString($t[0]),
'1 second',
$day->clone()->setTimeFromTimeString($t[1]),
);
->flatMap(function (array $t) use ($day) {
$start = $day->clone()->setTimeFromTimeString($t[0]);
$end = $day->clone()->setTimeFromTimeString($t[1]);

if ($end->lessThanOrEqualTo($start)) {
// Overnight period: keep it as a single period spanning midnight, the
// daily overlap logic in SLA::calculate clips it per day
return [[$start, $end->clone()->addDay()]];
}

return [[$start, $end]];
});
})->toArray();
}
Expand Down
5 changes: 4 additions & 1 deletion src/Interfaces/AgendaInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

namespace Sifex\SlaTimer\Interfaces;

use Carbon\CarbonInterface;
use Carbon\CarbonPeriod;

interface AgendaInterface
{
/**
* @return CarbonPeriod[]
* Returns the agenda periods for the subject period as start/end pairs.
*
* @return array<int, array{0: CarbonInterface, 1: CarbonInterface}>
*/
public function toPeriods(CarbonPeriod $subject_period): array;

Expand Down
234 changes: 155 additions & 79 deletions src/SLA.php
Original file line number Diff line number Diff line change
Expand Up @@ -140,89 +140,190 @@ private function calculate(string $subject_start_time, ?string $subject_stop_tim
$subject_start = Carbon::parse($subject_start_time);
$subject_end = Carbon::parse($subject_stop_time ?? Carbon::now());

$subject_start_ts = $subject_start->getTimestamp();
$subject_end_ts = $subject_end->getTimestamp();

$main_target_period = $this->get_current_duration($subject_start, $subject_end);

// TODO End period should just be up until the next schedule is made
$sla_periods = $this->recalculate_sla_periods($subject_start, $subject_end);

// Iterate over the period
$interval = collect(iterator_to_array($main_target_period))->map(function (Carbon $daily_subject_period) use ($subject_start, $subject_end, &$sla_periods) {
/**
* After we've divided each day, find where the start and end times are by min/max'ing them
*/
$start_of_day = max($subject_start->clone(), $daily_subject_period->clone());
$end_of_day = min($subject_end->clone(), $daily_subject_period->clone()->addHours(24));
$schedule_valid_from_unixes = array_map(
fn (SLASchedule $schedule) => Carbon::parse($schedule->valid_from)->startOfDay()->unix(),
$this->schedules
);

/**
* Create a 24h period
*/
$daily_period = CarbonPeriod::create($start_of_day, $end_of_day)
->setDateInterval(CarbonInterval::seconds());
$default_schedule = SLASchedule::create();

$pause_periods = collect($this->pause_periods)
->map(fn (SLAPause $pp) => $pp->toPeriod()->setDateInterval(CarbonInterval::seconds()))
->sortBy(fn (CarbonPeriod $p) => $p->start?->getTimestamp())
->values()
->toArray();

$sla_cursor = 0;
$pause_cursor = 0;
$enabled_schedule = null;
$last_enabled_schedule = null;
$valid_from_unix = null;
$total_seconds = 0;

// Iterate over the period, one day at a time. Each day is anchored at the start of
// day, so its bounds and the schedule lookups only need integer timestamps.
foreach ($main_target_period as $daily) {
$daily_ts = $daily->getTimestamp();
$day_start_ts = max($subject_start_ts, $daily_ts);
$day_end_ts = min($subject_end_ts, $daily_ts + 86400);

/**
* Grab the enabled schedule, compare this every day to see if we now have a schedule that would
* supersede it.
*/
$enabled_schedule = $this->get_enabled_schedule_for_day($start_of_day);
$enabled_schedule = $default_schedule;
foreach ($this->schedules as $index => $schedule) {
if ($schedule_valid_from_unixes[$index] <= $daily_ts) {
$enabled_schedule = $schedule;
}
}

if ($enabled_schedule !== $last_enabled_schedule) {
$last_enabled_schedule = $enabled_schedule;
$valid_from_unix = Carbon::parse($enabled_schedule->valid_from)->startOfDay()->unix();
}

/**
* Deduplicate our SLA Periods
* Why do this here? Mostly because of superseded schedules...
*/
if ($start_of_day->clone()->startOfDay()->unix() === Carbon::parse($enabled_schedule->valid_from)->clone()->startOfDay()->unix()) {
$sla_periods = $this->recalculate_sla_periods($start_of_day, $subject_end);
if ($daily_ts === $valid_from_unix) {
$sla_periods = $this->recalculate_sla_periods($daily, $subject_end);
$sla_cursor = 0;
}

/**
* Only consider SLA periods that can possibly overlap the current day, otherwise
* the work below would scale with the entire subject duration on every day.
*
* The periods are generated in chronological order, so a single forward-only
* cursor sweeps them without rescanning the whole list for every day.
*/
$sla_count = count($sla_periods);
while ($sla_cursor < $sla_count && $sla_periods[$sla_cursor][1]->getTimestamp() <= $day_start_ts) {
$sla_cursor++;
}

$day_sla_periods = [];
for ($i = $sla_cursor; $i < $sla_count; $i++) {
if ($sla_periods[$i][0]->getTimestamp() >= $day_end_ts) {
break;
}

$day_sla_periods[] = $sla_periods[$i];
}

/**
* SLA Overlap
* This function has been optimised
*
* Clip each SLA period to the current day and merge the results into a
* continuous union using integer timestamps, avoiding the per-day cost of
* building CarbonPeriod objects and diffing them via spatie/period.
*/
$sla_coverage_periods = collect($sla_periods)
->map(function (CarbonPeriod $sla_period) use ($start_of_day, $end_of_day) {
if ($sla_period->start === null || $sla_period->end === null) {
return null;
}
$coverage = [];
foreach ($day_sla_periods as [$period_start, $period_end]) {
$start = max($period_start->getTimestamp(), $day_start_ts);
$end = min($period_end->getTimestamp(), $day_end_ts);

$e = max($sla_period->start->getTimestamp(), $start_of_day->getTimestamp());
$f = min($sla_period->end->getTimestamp(), $end_of_day->getTimestamp());

if ($e > $f) {
return null;
} // No Overlap

return CarbonPeriod::create(
Carbon::createFromTimestamp($e),
Carbon::createFromTimestamp($f),
)->setDateInterval(CarbonInterval::seconds());
})
->whereNotNull()
->reduce(function (array $carry, ?CarbonPeriod $p) {
/** De-duplicate overlapping SLA periods */
return $p === null ? $carry : (count($carry) ? [...$p->diff(...$carry), ...$carry] : [$p]);
}, []);

if ($this->pause_periods) {
$sla_coverage_periods = collect($sla_coverage_periods)->flatMap(function (CarbonPeriod $period): array {
$pause_periods = collect($this->pause_periods)->map(fn (SLAPause $pp) => $pp->toPeriod()->setDateInterval(CarbonInterval::seconds()))->toArray();

return $period->diff(...$pause_periods);
})->toArray();
if ($start >= $end) {
continue;
}

$coverage[] = [$start, $end];
}

if (count($coverage) > 1) {
usort($coverage, fn (array $a, array $b) => $a[0] <=> $b[0]);
}

$merged = [];
foreach ($coverage as [$start, $end]) {
if ($merged && $start <= $merged[count($merged) - 1][1]) {
$merged[count($merged) - 1][1] = max($merged[count($merged) - 1][1], $end);
} else {
$merged[] = [$start, $end];
}
}

/**
* Get the interval of each overlapping period and place it into an array of intervals
* Subtract the pauses that overlap the current day, reproducing the
* closed-interval semantics of spatie/period (the instants a pause
* starts and ends at are consumed by it).
*
* The pauses are sorted by start time and swept with a forward-only
* cursor, so only pauses that can overlap the day are considered.
*/
/** @var CarbonInterval[] $intervals */
$intervals = collect($sla_coverage_periods)
->map(fn (CarbonPeriod $carbonPeriod): CarbonInterval => self::calculate_interval($carbonPeriod))
->toArray();
if ($pause_periods) {
$pause_count = count($pause_periods);
while ($pause_cursor < $pause_count && ($pause_periods[$pause_cursor]->end === null || $pause_periods[$pause_cursor]->end->getTimestamp() <= $day_start_ts)) {
$pause_cursor++;
}

$day_pause_periods = [];
for ($i = $pause_cursor; $i < $pause_count; $i++) {
$pause = $pause_periods[$i];

if ($pause->start === null || $pause->end === null) {
continue;
}

return self::combine_intervals($intervals);
if ($pause->start->getTimestamp() >= $day_end_ts) {
break;
}

$day_pause_periods[] = $pause;
}

$merged = collect($merged)->flatMap(function (array $pair) use ($day_pause_periods) {
$parts = [$pair];

foreach ($day_pause_periods as $pause) {
$pause_start = $pause->start?->getTimestamp();
$pause_end = $pause->end?->getTimestamp();

if ($pause_start === null || $pause_end === null || $pause_end < $pair[0] || $pause_start > $pair[1]) {
continue;
}

$next = [];
foreach ($parts as [$start, $end]) {
if ($pause_start - 1 >= $start) {
$next[] = [$start, min($end, $pause_start - 1)];
}

if ($pause_end + 1 <= $end) {
$next[] = [max($start, $pause_end + 1), $end];
}
}

$parts = $next;

if (! $parts) {
break;
}
}

return $parts;
})->toArray();
}

/**
* Then combine all intervals
* Sum the seconds of every remaining coverage period of the day
*/
})->pipe(fn ($c) => self::combine_intervals($c->toArray()));
foreach ($merged as [$start, $end]) {
$total_seconds += $end - $start;
}
}

$interval = CarbonInterval::seconds((int) round($total_seconds))->cascade();

return new SLAStatus(
collect($this->breach_definitions)
Expand All @@ -234,7 +335,7 @@ private function calculate(string $subject_start_time, ?string $subject_stop_tim
}

/**
* @return CarbonPeriod[]
* @return array<int, array{0: CarbonInterface, 1: CarbonInterface}>
*/
private function recalculate_sla_periods(CarbonInterface $from, CarbonInterface $to): array
{
Expand Down Expand Up @@ -271,31 +372,6 @@ private function get_enabled_schedule_for_day(CarbonInterface $day): SLASchedule
->last() ?? SLASchedule::create();
}

/**
* Turns a single period into an interval
*/
private static function calculate_interval(CarbonPeriod $period): CarbonInterval
{
if ($period->start === null || $period->end === null) {
return CarbonInterval::seconds(0);
}

return CarbonInterval::seconds($period->end->getTimestamp() - $period->start->getTimestamp());
}

/**
* Combines two different intervals
*
* @param CarbonInterval[] $intervals
*/
private static function combine_intervals(array $intervals): CarbonInterval
{
return collect($intervals)
->reduce(function (CarbonInterval $i, CarbonInterval $overlapping_period) {
return $i->add($overlapping_period->cascade())->cascade();
}, CarbonInterval::seconds(0));
}

/**
* Filter out any excluded dates
*/
Expand Down
Loading