Skip to content

Commit

Permalink
Add support for converting a snowflake back to a Carbon instance (#3)
Browse files Browse the repository at this point in the history
* Working but ugly

* Slightly better

* Better!

* Use DI

* Even better

* Use custom setTestNow method

* Code style

* WIP

* Working tests

* Simplify the math

* Better comments for this monster math

* Code Style

* Call $factory->setTestNow

* Better naming

* Code style
  • Loading branch information
inxilpro authored Jul 10, 2024
1 parent 8fb6b28 commit 3964ae1
Show file tree
Hide file tree
Showing 11 changed files with 245 additions and 13 deletions.
25 changes: 24 additions & 1 deletion src/Bits.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

namespace Glhd\Bits;

use Carbon\CarbonInterface;
use DateTimeInterface;
use Glhd\Bits\Config\SegmentType;
use Glhd\Bits\Contracts\Configuration;
use Glhd\Bits\Contracts\MakesBits;
use Glhd\Bits\Contracts\MakesSnowflakes;
Expand All @@ -12,6 +15,7 @@
use Illuminate\Contracts\Support\Jsonable;
use Illuminate\Database\Grammar;
use Illuminate\Support\Collection;
use Illuminate\Support\DateFactory;
use JsonSerializable;

// This adds support for the Expression interface in earlier versions of Laravel
Expand Down Expand Up @@ -51,7 +55,8 @@ public static function castUsing(array $arguments): BitsCast

public function __construct(
array $values,
protected Configuration $config
protected Configuration $config,
protected CarbonInterface $epoch,
) {
$this->config->validate($values);

Expand All @@ -73,6 +78,19 @@ public function getValue(?Grammar $grammar = null): int
return $this->id();
}

public function toDateTime(): DateTimeInterface
{
return $this->config->timestampToDateTime(
epoch: $this->epoch,
timestamp: $this->rawSegmentValue(SegmentType::Timestamp),
);
}

public function toCarbon(): CarbonInterface
{
return app(DateFactory::class)->instance($this->toDateTime());
}

public function __toString(): string
{
return (string) $this->id();
Expand All @@ -87,4 +105,9 @@ public function jsonSerialize(): mixed
{
return (string) $this->id();
}

protected function rawSegmentValue(SegmentType $segment, int $position = 0): int
{
return $this->values[$this->config->positionOf($segment)[$position]];
}
}
39 changes: 38 additions & 1 deletion src/Config/GenericConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@

use BadMethodCallException;
use Carbon\CarbonInterface;
use DateTimeImmutable;
use DateTimeInterface;
use Glhd\Bits\Contracts\Configuration;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use InvalidArgumentException;
use RuntimeException;

/** @property Collection<int, \Glhd\Bits\Config\Segment> $segments */
class GenericConfig implements Configuration
Expand Down Expand Up @@ -65,11 +68,45 @@ public function combine(int ...$values): int
}, 0);
}

public function positionOf(SegmentType $type): array
{
$matches = $this->segments
->filter(fn(Segment $segment) => $segment->type === $type)
->map(fn(Segment $segment) => $segment->position());

return $matches->values()->toArray();
}

public function timestamp(CarbonInterface $epoch, CarbonInterface $timestamp): int
{
return (int) round(($this->getPreciseTimestamp($timestamp) - $this->getPreciseTimestamp($epoch)) / $this->unit);
}

public function timestampToDateTime(DateTimeInterface $epoch, int $timestamp): DateTimeImmutable
{
// The multiplier is the inverse of the precision. For example, if the precision is 6 (i.e. 6 digits
// of decimal points after the second [aka microseconds]), then our multiplier is 1. On the other hand,
// if the precision is 3 (aka milliseconds), our multiplier needs to be 1,000, to convert milliseconds
// to microseconds (eg. 1,000ms = 1,000,000us).
$multiplier = pow(10, 6 - $this->precision);

// First, convert the timestamp from a relative integer to a full-precision timestamp (in microseconds)
$precise_timestamp = ((float) $epoch->format('Uu')) + ($timestamp * $this->unit * $multiplier);

// We need to then convert our full-precision timestamp into a format that PHP can parse into a precise
// DateTime object. The format "U.u" seems to be the best way to do that, so we need to split our timestamp
// into a unix timestamp in seconds, and exactly 6 digits of microseconds to add to that timestamp.
$seconds = (int) floor($precise_timestamp / 1_000_000);
$remaining_microseconds = ($precise_timestamp % 1_000_000) * 1_000_000;
$formatted_microseconds = substr(sprintf('%06d', $remaining_microseconds), 0, 6);

if ($result = DateTimeImmutable::createFromFormat('U.u', "{$seconds}.{$formatted_microseconds}", $epoch->getTimezone())) {
return $result;
}

throw new RuntimeException('Carbon error: '.json_encode(DateTimeImmutable::getLastErrors()));
}

public function maxSequence(): int
{
return $this->sequence_segment->maxValue();
Expand Down Expand Up @@ -186,7 +223,7 @@ protected function setTimestampAndSequenceSegments(): void
protected function setPositionsAndOffsets(): void
{
$shift = 0;

foreach ($this->segments->reverse() as $index => $segment) {
$segment->setPosition($index);
$segment->setOffset($shift);
Expand Down
7 changes: 7 additions & 0 deletions src/Contracts/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
namespace Glhd\Bits\Contracts;

use Carbon\CarbonInterface;
use DateTimeInterface;
use Glhd\Bits\Config\SegmentType;
use Glhd\Bits\Config\WorkerIds;
use Illuminate\Support\Collection;

Expand All @@ -15,8 +17,13 @@ public function parse(int $id): array;

public function combine(int ...$values): int;

/** @return int[] */
public function positionOf(SegmentType $type): array;

public function timestamp(CarbonInterface $epoch, CarbonInterface $timestamp): int;

public function timestampToDateTime(DateTimeInterface $epoch, int $timestamp): DateTimeInterface;

public function maxSequence(): int;

public function validate(Collection|array|WorkerIds $values): void;
Expand Down
6 changes: 3 additions & 3 deletions src/Factories/GenericFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public function make(): Bits

$values = $this->config->organize($this->ids, $timestamp, $sequence);

return new Bits($values, $this->config);
return new Bits($values, $this->config, $this->epoch);
}

public function makeFromTimestamp(CarbonInterface $timestamp): Bits
Expand All @@ -44,14 +44,14 @@ public function makeFromTimestamp(CarbonInterface $timestamp): Bits

$values = $this->config->organize($this->ids, $timestamp, $sequence);

return new Bits($values, $this->config);
return new Bits($values, $this->config, $this->epoch);
}

public function fromId(int|string $id): Bits
{
$values = $this->config->parse((int) $id);

return new Bits($values, $this->config);
return new Bits($values, $this->config, $this->epoch);
}

public function coerce(int|string|Bits $value): Bits
Expand Down
6 changes: 3 additions & 3 deletions src/Factories/SnowflakeFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public function make(): Snowflake
{
[$timestamp, $sequence] = $this->waitForValidTimestampAndSequence();

return new Snowflake($timestamp, $this->datacenter_id, $this->worker_id, $sequence, $this->config);
return new Snowflake($timestamp, $this->datacenter_id, $this->worker_id, $sequence, $this->epoch, $this->config);
}

public function makeFromTimestamp(CarbonInterface $timestamp): Snowflake
Expand All @@ -43,14 +43,14 @@ public function makeFromTimestamp(CarbonInterface $timestamp): Snowflake
throw new InvalidArgumentException('Hit sequence limit for timestamp.');
}

return new Snowflake($timestamp, $this->datacenter_id, $this->worker_id, $sequence, $this->config);
return new Snowflake($timestamp, $this->datacenter_id, $this->worker_id, $sequence, $this->epoch, $this->config);
}

public function fromId(int|string $id): Snowflake
{
[$_, $timestamp, $datacenter_id, $worker_id, $sequence] = $this->config->parse((int) $id);

return new Snowflake($timestamp, $datacenter_id, $worker_id, $sequence, $this->config);
return new Snowflake($timestamp, $datacenter_id, $worker_id, $sequence, $this->epoch, $this->config);
}

public function coerce(int|string|Bits $value): Snowflake
Expand Down
6 changes: 3 additions & 3 deletions src/Factories/SonyflakeFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public function make(): Sonyflake
{
[$timestamp, $sequence] = $this->waitForValidTimestampAndSequence();

return new Sonyflake($timestamp, $sequence, $this->machine_id, $this->config);
return new Sonyflake($timestamp, $sequence, $this->machine_id, $this->epoch, $this->config);
}

public function makeFromTimestamp(CarbonInterface $timestamp): Sonyflake
Expand All @@ -42,14 +42,14 @@ public function makeFromTimestamp(CarbonInterface $timestamp): Sonyflake
throw new InvalidArgumentException('Hit sequence limit for timestamp.');
}

return new Sonyflake($timestamp, $sequence, $this->machine_id, $this->config);
return new Sonyflake($timestamp, $sequence, $this->machine_id, $this->epoch, $this->config);
}

public function fromId(int|string $id): Sonyflake
{
[$timestamp, $sequence, $machine_id] = $this->config->parse((int) $id);

return new Sonyflake($timestamp, $sequence, $machine_id, $this->config);
return new Sonyflake($timestamp, $sequence, $machine_id, $this->epoch, $this->config);
}

public function coerce(int|string|Bits $value): Sonyflake
Expand Down
3 changes: 3 additions & 0 deletions src/Snowflake.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Glhd\Bits;

use Carbon\CarbonInterface;
use Glhd\Bits\Config\SnowflakesConfig;
use Glhd\Bits\Contracts\MakesSnowflakes;

Expand All @@ -27,11 +28,13 @@ public function __construct(
public readonly int $datacenter_id,
public readonly int $worker_id,
public readonly int $sequence,
CarbonInterface $epoch,
?SnowflakesConfig $config = null,
) {
parent::__construct(
values: [0, $this->timestamp, $this->datacenter_id, $this->worker_id, $this->sequence],
config: $config ?? app(SnowflakesConfig::class),
epoch: $epoch,
);
}

Expand Down
3 changes: 3 additions & 0 deletions src/Sonyflake.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Glhd\Bits;

use Carbon\CarbonInterface;
use Glhd\Bits\Config\SonyflakesConfig;
use Glhd\Bits\Contracts\MakesSonyflakes;

Expand All @@ -26,11 +27,13 @@ public function __construct(
public readonly int $timestamp,
public readonly int $sequence,
public readonly int $machine_id,
CarbonInterface $epoch,
?SonyflakesConfig $config = null,
) {
parent::__construct(
values: [$this->timestamp, $this->sequence, $this->machine_id],
config: $config ?? app(SonyflakesConfig::class),
epoch: $epoch,
);
}

Expand Down
16 changes: 14 additions & 2 deletions src/Support/BitsServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Illuminate\Contracts\Container\Container;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\DateFactory;
use Illuminate\Support\Facades\ParallelTesting;
use Illuminate\Support\ServiceProvider;
use InvalidArgumentException;

Expand All @@ -40,7 +41,7 @@ public function register()
return new SnowflakeFactory(
epoch: $dates->parse($config->get('bits.epoch', '2023-01-01'), 'UTC')->startOfDay(),
datacenter_id: $config->get('bits.datacenter_id') ?? random_int(0, 31),
worker_id: $config->get('bits.worker_id') ?? random_int(0, 31),
worker_id: $config->get('bits.worker_id') ?? $this->generateWorkerId(31),
config: $container->make(SnowflakesConfig::class),
sequence: $container->make(ResolvesSequences::class),
);
Expand All @@ -52,7 +53,7 @@ public function register()

return new SonyflakeFactory(
epoch: $dates->parse($config->get('bits.epoch', '2023-01-01'), 'UTC')->startOfDay(),
machine_id: $config->get('bits.worker_id') ?? random_int(0, 65535),
machine_id: $config->get('bits.worker_id') ?? $this->generateWorkerId(65535),
config: $container->make(SonyflakesConfig::class),
sequence: $container->make(ResolvesSequences::class),
);
Expand Down Expand Up @@ -98,4 +99,15 @@ protected function packageConfigFile(): string
{
return dirname(__DIR__, 2).'/config/bits.php';
}

protected function generateWorkerId(int $max): int
{
$token = $this->app->runningUnitTests() ? ParallelTesting::token() : null;

if (is_numeric($token) && (int) $token <= $max) {
return (int) $token;
}

return random_int(0, $max);
}
}
27 changes: 27 additions & 0 deletions tests/Unit/SnowflakeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Carbon\CarbonInterval;
use Glhd\Bits\Config\SnowflakesConfig;
use Glhd\Bits\Contracts\MakesBits;
use Glhd\Bits\Contracts\MakesSnowflakes;
use Glhd\Bits\Contracts\ResolvesSequences;
use Glhd\Bits\Factories\SnowflakeFactory;
Expand Down Expand Up @@ -222,4 +223,30 @@ public function test_a_snowflake_can_be_serialized_to_json(): void
$this->assertEquals($string, $snowflake->toJson());
$this->assertEquals($string, json_encode($snowflake));
}

public function test_it_parses_timestamps_correctly(): void
{
// Only the 842 will be preserved because snowflakes are only
// millisecond-precise, not microsecond-precise
Date::setTestNow(now()->microseconds(842000));

$sequence = 0;

$factory = new SnowflakeFactory(
epoch: now()->microseconds(0),
datacenter_id: 1,
worker_id: 15,
config: app(SnowflakesConfig::class),
sequence: new TestingSequenceResolver($sequence)
);
$factory->setTestNow(now());

$this->app->instance(MakesBits::class, $factory);

$snowflake_at_epoch = $factory->make();

$instance = $snowflake_at_epoch->toCarbon();

$this->assertEquals(now()->format('U.u'), $instance->format('U.u'));
}
}
Loading

0 comments on commit 3964ae1

Please sign in to comment.