diff --git a/.github/changelog/historical-tids b/.github/changelog/historical-tids new file mode 100644 index 0000000..da6e6df --- /dev/null +++ b/.github/changelog/historical-tids @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Backfilled posts can now publish with rkeys that reflect the original publish date. diff --git a/includes/transformer/class-base.php b/includes/transformer/class-base.php index 99a3163..fc96e15 100644 --- a/includes/transformer/class-base.php +++ b/includes/transformer/class-base.php @@ -72,6 +72,76 @@ public function get_uri(): string { ); } + /** + * Mint an rkey that honors the historical-TID filter contract. + * + * Shared by `Post::get_rkey()`, `Document::get_rkey()`, and + * `Comment::get_rkey()`. Each subclass calls this with its own + * collection NSID, the relevant `*_date_gmt` string and ID, a + * per-class salt prefix (so `applyWrites` can never see two records + * collapse onto the same rkey within a single collection), and an + * optional kind label that separates records of different WordPress + * object kinds when they share an AT Protocol collection. + * + * The historical path falls back to `TID::generate()` when either + * the filter opts out or the GMT datetime cannot be parsed + * (empty string, MySQL `0000-00-00 00:00:00` sentinel, garbage), + * so callers never need to handle the "no usable date" case. + * + * @since unreleased + * + * @param string $collection AT Protocol collection NSID. + * @param string $gmt_datetime GMT datetime string from the WP object. + * @param int $object_id Post or comment identifier. + * @param string $salt_prefix Per-class salt prefix (e.g. `'post:'`). + * @param \WP_Post|\WP_Comment $wp_object WordPress object being transformed. + * @param string $kind Optional kind label for collection-sharing + * record types (e.g. `'comment'`). + * @return string Minted TID rkey. + */ + final protected function mint_historical_rkey( + string $collection, + string $gmt_datetime, + int $object_id, + string $salt_prefix, + \WP_Post|\WP_Comment $wp_object, + string $kind = '' + ): string { + /** + * Filters whether to mint a historical TID based on the post's + * original publish date. + * + * Defaults to true so backfilled posts get rkeys that reflect + * the original publish time rather than the time of the + * backfill run. Return false to fall back to the live-publish + * `TID::generate()` path (now-based, with the monotonic floor). + * + * @since unreleased + * + * @param bool $use_historical Whether to mint a historical TID. + * @param \WP_Post|\WP_Comment $object WordPress object being transformed. + * @param string $collection AT Protocol collection NSID. + */ + $use_historical = (bool) \apply_filters( + 'atmosphere_use_historical_tid', + true, + $wp_object, + $collection + ); + + if ( ! $use_historical ) { + return TID::generate(); + } + + $microseconds = TID::microseconds_from_post_date( $gmt_datetime, $object_id, $kind ); + + if ( $microseconds <= 0 ) { + return TID::generate(); + } + + return TID::generate_for_time( $microseconds, $salt_prefix . $object_id ); + } + /** * WordPress locale as BCP-47 language tag array. * diff --git a/includes/transformer/class-comment.php b/includes/transformer/class-comment.php index 69e7a9c..97b9bd8 100644 --- a/includes/transformer/class-comment.php +++ b/includes/transformer/class-comment.php @@ -26,6 +26,33 @@ */ class Comment extends Base { + /** + * Salt prefix passed to {@see TID::generate_for_time()} for comments. + * + * Combined with the comment ID it produces the deterministic + * clock-id input for the historical TID path. + * + * @since unreleased + * + * @var string + */ + public const TID_SALT_PREFIX = 'comment:'; + + /** + * Kind label passed to {@see TID::microseconds_from_post_date()}. + * + * Comments share the `app.bsky.feed.post` collection with posts, so + * a post-id-vs-comment-id collision inside a single GMT second would + * otherwise mint identical rkeys. The kind label folds an extra + * deterministic offset into the microsecond portion so posts and + * comments occupy different sub-second windows. + * + * @since unreleased + * + * @var string + */ + public const TID_KIND = 'comment'; + /** * Comment meta key for the bsky post TID (rkey). * @@ -139,7 +166,14 @@ public function get_rkey(): string { $rkey = \get_comment_meta( (int) $this->object->comment_ID, self::META_TID, true ); if ( empty( $rkey ) ) { - $rkey = TID::generate(); + $rkey = $this->mint_historical_rkey( + $this->get_collection(), + (string) $this->object->comment_date_gmt, + (int) $this->object->comment_ID, + self::TID_SALT_PREFIX, + $this->object, + self::TID_KIND + ); \update_comment_meta( (int) $this->object->comment_ID, self::META_TID, $rkey ); } diff --git a/includes/transformer/class-document.php b/includes/transformer/class-document.php index 3f49231..eaeadc8 100644 --- a/includes/transformer/class-document.php +++ b/includes/transformer/class-document.php @@ -23,6 +23,20 @@ */ class Document extends Base { + /** + * Salt prefix passed to {@see TID::generate_for_time()} for documents. + * + * Combined with the post ID it produces the deterministic clock-id + * input for the historical TID path; namespacing by class keeps + * `Document` and `Post` rkeys distinct even though both derive from + * the same `WP_Post`. + * + * @since unreleased + * + * @var string + */ + public const TID_SALT_PREFIX = 'document:'; + /** * Post meta key for the document TID. * @@ -199,7 +213,13 @@ public function get_rkey(): string { $rkey = \get_post_meta( $this->object->ID, self::META_TID, true ); if ( empty( $rkey ) ) { - $rkey = TID::generate(); + $rkey = $this->mint_historical_rkey( + $this->get_collection(), + (string) $this->object->post_date_gmt, + (int) $this->object->ID, + self::TID_SALT_PREFIX, + $this->object + ); \update_post_meta( $this->object->ID, self::META_TID, $rkey ); } diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 35fe8b3..f98a1ba 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -22,6 +22,20 @@ */ class Post extends Base { + /** + * Salt prefix passed to {@see TID::generate_for_time()} for posts. + * + * Combined with the post ID it produces the deterministic clock-id + * input for the historical TID path; namespacing by class keeps + * `Post` and `Document` rkeys distinct even though both derive from + * the same `WP_Post`. + * + * @since unreleased + * + * @var string + */ + public const TID_SALT_PREFIX = 'post:'; + /** * Post meta key for the bsky post TID. * @@ -277,7 +291,13 @@ public function get_rkey(): string { $rkey = \get_post_meta( $this->object->ID, self::META_TID, true ); if ( empty( $rkey ) ) { - $rkey = TID::generate(); + $rkey = $this->mint_historical_rkey( + $this->get_collection(), + (string) $this->object->post_date_gmt, + (int) $this->object->ID, + self::TID_SALT_PREFIX, + $this->object + ); \update_post_meta( $this->object->ID, self::META_TID, $rkey ); } diff --git a/includes/transformer/class-tid.php b/includes/transformer/class-tid.php index 0e92a62..ffbf988 100644 --- a/includes/transformer/class-tid.php +++ b/includes/transformer/class-tid.php @@ -167,22 +167,167 @@ public static function generate(): string { \wp_cache_delete( self::OPTION_LAST_TS, 'options' ); } - if ( null === self::$clock_id ) { - /* - * `random_int` throws on systems without a usable CSPRNG - * (essentially never on a working PHP install, but a worth - * a fallback so a missing entropy source can't bring down - * publishing). `wp_rand` is non-cryptographic but the - * collision space is still 1024. - */ - try { - self::$clock_id = \random_int( 0, 1023 ); - } catch ( \Throwable $e ) { - self::$clock_id = \wp_rand( 0, 1023 ); - } + return self::encode( ( $ts << 10 ) | self::ensure_clock_id() ); + } + + /** + * Generate a TID that encodes a specific historical microsecond. + * + * Unlike {@see self::generate()}, this path is intentionally + * floor-free: backfilled records carry timestamps far older than + * any current-time floor would allow, so consulting or updating + * `OPTION_LAST_TS` (or `self::$last_ts`) would either snap the + * value forward to "now" — defeating the entire point — or + * regress the floor used by live publishing. Callers are + * responsible for supplying a microsecond value that is + * collision-resistant within their batch; see + * {@see self::microseconds_from_post_date()} for the standard + * deterministic helper. + * + * The 10-bit clock identifier is derived deterministically from + * `$salt` rather than the per-process random value used by + * `generate()`. Without that, retrying a backfill in a different + * PHP worker before the record's TID meta is persisted would mint + * a different rkey for the same post — the AT Protocol create + * would then succeed twice and orphan the first record. Callers + * should pass a salt that is unique to the record being minted; + * the conventional shape used in the bundled transformers is + * `"{kind}:{object_id}"` (e.g. `"post:42"`, `"comment:128"`), + * which together with the modulo-disambiguated microsecond + * portion makes both 10-bit fields depend on the full object + * identity — closing the "IDs differ by a multiple of 1,000,000 + * inside the same GMT second" modulo collision. + * + * Non-positive `$microseconds` (zero or negative) and values + * above `PHP_INT_MAX >> 10` fall through to {@see self::generate()} + * rather than encoding garbage. The standard caller path through + * {@see self::microseconds_from_post_date()} already returns 0 + * for unparseable input, but this guard protects direct callers + * who hand the helper their own integer (a stray `* 1_000_000` + * applied twice, a negative pre-epoch timestamp) from silently + * minting an unsortable / negative-encoded TID. The upper bound + * corresponds to roughly the year 2255. + * + * @since unreleased + * + * @param int $microseconds Microseconds since the Unix epoch. Must + * be in `(0, PHP_INT_MAX >> 10]` to mint + * a historical TID; out-of-range values + * fall back to {@see self::generate()}. + * @param string $salt Deterministic disambiguation salt, + * conventionally `"{kind}:{object_id}"`. + * Defaults to the microseconds string so + * ad-hoc callers still get a stable rkey. + * @return string 13-character identifier. + */ + public static function generate_for_time( int $microseconds, string $salt = '' ): string { + if ( $microseconds <= 0 || $microseconds > ( \PHP_INT_MAX >> 10 ) ) { + return self::generate(); + } + + $clock_source = '' === $salt ? (string) $microseconds : $salt; + $clock_id = \crc32( $clock_source ) & 0x3FF; + + return self::encode( ( $microseconds << 10 ) | $clock_id ); + } + + /** + * Convert a GMT datetime + object ID into a deterministic microsecond value. + * + * `post_date_gmt` and `comment_date_gmt` are MySQL second + * resolution, so two records published in the same second would + * otherwise hash to identical microseconds and collide on the + * 10-bit clock identifier within a single backfill run. Mixing + * the object ID (modulo one second of microseconds) into the + * microsecond portion disambiguates those collisions + * deterministically — re-running the backfill mints the same TID + * for the same record, which keeps the operation idempotent + * against `applyWrites`. + * + * Cross-kind rkey collisions in the same collection (e.g. a Post + * and a Comment both landing in `app.bsky.feed.post`) are + * impossible by construction: posts/documents occupy microseconds + * 0..499,999 within each second, comments occupy 500,000..999,999. + * Even if the salt-derived 10-bit `clock_id` collides across kinds + * (1/1024 chance through CRC32), the microsecond field differs and + * the rkeys differ. Within a single kind, two records published in + * the exact same second still need a matching `object_id % 500_000` + * AND a `clock_id` collision — astronomically unlikely in practice. + * + * An earlier shape folded `crc32($kind)` into the same 0..999_999 + * window via modulo addition. That preserved idempotency per kind + * but did not guarantee disjoint ranges, so specific cross-kind ID + * pairs (e.g. post id 1288 vs comment id 350044 at the same GMT + * second) could still collapse onto identical microseconds — and, + * when the salt-derived clock_id also collided, identical rkeys. + * Codex surfaced that pattern by construction during PR review; + * range segregation removes the failure mode at its root rather + * than relying on the hash space staying lucky. + * + * Bundled `$kind` values used by the plugin's transformers: + * + * - `''` (default) — `Post` and `Document`. Their AT collections + * (`app.bsky.feed.post` and `site.standard.document`) don't + * overlap so no kind segregation is needed and they share the + * 0..499,999 range. + * - `'comment'` — `Comment`. Shares `app.bsky.feed.post` with + * `Post`, so it occupies the 500,000..999,999 range. See + * `Comment::TID_KIND`. + * + * Returns `0` for any unparseable input — empty / whitespace-only + * strings, the MySQL `0000-00-00 00:00:00` sentinel, pre-epoch + * datetimes, or garbage `strtotime()` rejects. Callers must check + * for the zero return and fall back to {@see self::generate()} + * rather than passing the zero through to + * {@see self::generate_for_time()} (which would itself fall back, + * but the explicit check at the call site keeps the intent + * obvious). + * + * @since unreleased + * + * @param string $gmt_datetime GMT datetime string (e.g. `post_date_gmt`). + * @param int $object_id Post or comment identifier for disambiguation. + * @param string $kind Optional kind label to separate records + * sharing a collection. See list above for + * the values used by bundled transformers. + * @return int Microseconds since the Unix epoch, or 0 on parse failure + * / pre-epoch / sentinel input. + */ + public static function microseconds_from_post_date( string $gmt_datetime, int $object_id, string $kind = '' ): int { + $trimmed = \trim( $gmt_datetime ); + + // MySQL zero-date sentinel and empty strings: bail before + // `strtotime` (which interprets `0000-00-00 00:00:00` as year + // zero on some PHP builds, yielding a far-past-or-future + // timestamp that would mint a meaningless TID). + if ( '' === $trimmed || '0000-00-00 00:00:00' === $trimmed ) { + return 0; + } + + $seconds = \strtotime( $trimmed . ' UTC' ); + + if ( false === $seconds || $seconds <= 0 ) { + return 0; } - return self::encode( ( $ts << 10 ) | self::$clock_id ); + /* + * Reserve disjoint microsecond ranges per kind so cross-kind + * rkeys can never share the microsecond field — eliminating + * the failure mode regardless of whether the salt-derived + * `clock_id` ever collides: + * + * - `''` (post / document) → 0..499,999 within the second. + * - `'comment'` → 500,000..999,999 within the second. + * + * Within a single kind the `(object_id % 500_000)` term still + * disambiguates same-second records, paired with the + * salt-derived `clock_id` for the final 10 bits. + */ + $kind_offset = ( 'comment' === $kind ) ? 500_000 : 0; + $within_kind = $object_id % 500_000; + $micros_within_second = $kind_offset + $within_kind; + + return ( $seconds * 1_000_000 ) + $micros_within_second; } /** @@ -205,6 +350,29 @@ public static function is_valid( string $tid ): bool { return true; } + /** + * Lazily seed and return the per-process clock identifier. + * + * Shared by {@see self::generate()} and {@see self::generate_for_time()}. + * `random_int` throws on systems without a usable CSPRNG (essentially + * never on a working PHP install, but worth a fallback so a missing + * entropy source can't bring down publishing). `wp_rand` is + * non-cryptographic but the collision space is still 1024. + * + * @return int 10-bit clock identifier. + */ + private static function ensure_clock_id(): int { + if ( null === self::$clock_id ) { + try { + self::$clock_id = \random_int( 0, 1023 ); + } catch ( \Throwable $e ) { + self::$clock_id = \wp_rand( 0, 1023 ); + } + } + + return self::$clock_id; + } + /** * Encode a 64-bit integer into a 13-character base-32 string. * diff --git a/tests/phpunit/tests/transformer/class-test-comment.php b/tests/phpunit/tests/transformer/class-test-comment.php index a878df2..70fe7cb 100644 --- a/tests/phpunit/tests/transformer/class-test-comment.php +++ b/tests/phpunit/tests/transformer/class-test-comment.php @@ -13,6 +13,9 @@ use Atmosphere\Reaction_Sync; use Atmosphere\Transformer\Comment; use Atmosphere\Transformer\Post; +use Atmosphere\Transformer\TID; + +require_once __DIR__ . '/class-tid-decoder.php'; /** * Comment transformer tests. @@ -58,6 +61,7 @@ public function set_up(): void { */ public function tear_down(): void { \remove_all_filters( 'atmosphere_transform_comment' ); + \remove_all_filters( 'atmosphere_use_historical_tid' ); parent::tear_down(); } @@ -298,4 +302,153 @@ public function test_long_content_is_truncated() { $this->assertLessThanOrEqual( 300, \mb_strlen( $record['text'] ) ); } + + /** + * By default, Comment::get_rkey() mints a historical TID anchored + * on `comment_date_gmt`. The decoded microseconds must match the + * helper output (including the `comment` kind label that offsets + * comments away from posts in the shared collection). + * + * @covers ::get_rkey + */ + public function test_get_rkey_defaults_to_historical_tid() { + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $this->post_id, + 'comment_date' => '2014-08-20 10:15:00', + 'comment_date_gmt' => '2014-08-20 10:15:00', + 'comment_content' => 'Historical comment.', + 'user_id' => 1, + ) + ); + + $comment = \get_comment( $comment_id ); + $historical_rkey = ( new Comment( $comment ) )->get_rkey(); + $current_rkey = TID::generate(); + + $this->assertNotEmpty( $historical_rkey ); + $this->assertTrue( TID::is_valid( $historical_rkey ) ); + $this->assertLessThan( $current_rkey, $historical_rkey, '2014-anchored rkey must sort before a now-minted TID.' ); + + $expected_microseconds = TID::microseconds_from_post_date( + '2014-08-20 10:15:00', + (int) $comment_id, + Comment::TID_KIND + ); + $this->assertSame( + $expected_microseconds, + TID_Decoder::tid_to_microseconds( $historical_rkey ), + 'Decoded rkey microseconds must match microseconds_from_post_date() including the comment kind.' + ); + } + + /** + * Listeners returning false from atmosphere_use_historical_tid + * fall back to the now-based TID::generate() path for comments + * just like posts. + * + * @covers ::get_rkey + */ + public function test_get_rkey_filter_opt_out_uses_current_time() { + \add_filter( 'atmosphere_use_historical_tid', '__return_false' ); + + $comment_id = self::factory()->comment->create( + array( + 'comment_post_ID' => $this->post_id, + 'comment_date' => '2014-08-20 10:15:00', + 'comment_date_gmt' => '2014-08-20 10:15:00', + 'user_id' => 1, + ) + ); + + $comment = \get_comment( $comment_id ); + $baseline = TID::generate(); + $rkey = ( new Comment( $comment ) )->get_rkey(); + $historical_2014 = TID::generate_for_time( + TID::microseconds_from_post_date( '2014-08-20 10:15:00', (int) $comment_id, Comment::TID_KIND ), + Comment::TID_SALT_PREFIX . $comment_id + ); + + $this->assertGreaterThan( $baseline, $rkey, 'Opting out via filter must mint a now-based TID.' ); + $this->assertGreaterThan( $historical_2014, $rkey, 'Opted-out TID must sort after a 2014 historical TID.' ); + } + + /** + * The headline test for the `$kind` argument: a Post with `ID == N` + * and a Comment with `comment_ID == N`, both with matching + * `*_date_gmt` values, must mint distinct rkeys even though both + * live in `app.bsky.feed.post`. Without the kind offset, both would + * collapse onto the same microsecond + salt-prefix-only-by-class + * encoding and `applyWrites` would reject the second create. + * + * Forces the ID collision via direct `$wpdb` insert because + * `comment_ID` and `post_ID` auto-increment from separate + * sequences — the test factories can't guarantee equality. + * + * @covers ::get_rkey + */ + public function test_post_and_comment_with_matching_id_and_date_mint_distinct_rkeys() { + global $wpdb; + + $gmt = '2018-04-01 12:00:00'; + + $post = self::factory()->post->create_and_get( + array( + 'post_date' => $gmt, + 'post_date_gmt' => $gmt, + ) + ); + + // Insert a comment whose `comment_ID` literally equals the + // post ID. Bypass the factory + `wp_insert_comment` because + // neither honors a caller-supplied `comment_ID`; the AUTO_INCREMENT + // override is the whole point of the test. Use the same + // `$wpdb` direct-write pattern that lives in `class-tid.php` + // for its CAS write, with the same phpcs disable comment. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange + $wpdb->insert( + $wpdb->comments, + array( + 'comment_ID' => $post->ID, + 'comment_post_ID' => $this->post_id, + 'comment_author' => 'tester', + 'comment_author_email' => 'tester@example.com', + 'comment_date' => $gmt, + 'comment_date_gmt' => $gmt, + 'comment_content' => 'Cross-kind test.', + 'comment_approved' => '1', + 'user_id' => 1, + ) + ); + \clean_comment_cache( $post->ID ); + + $comment = \get_comment( $post->ID ); + $this->assertNotNull( $comment, 'Sanity: the forced-ID comment must round-trip through get_comment().' ); + $this->assertSame( (int) $post->ID, (int) $comment->comment_ID, 'Setup: comment_ID must equal post ID.' ); + $this->assertSame( $gmt, $comment->comment_date_gmt, 'Setup: comment_date_gmt must match the post.' ); + + // End-to-end: each transformer mints through `get_rkey()` (which + // goes through `Base::mint_historical_rkey()` with the per-class + // salt + kind), and the resulting rkeys must differ even though + // both live inside `app.bsky.feed.post` with identical ID + date. + $post_rkey = ( new Post( $post ) )->get_rkey(); + $comment_rkey = ( new Comment( $comment ) )->get_rkey(); + + $this->assertTrue( TID::is_valid( $post_rkey ) ); + $this->assertTrue( TID::is_valid( $comment_rkey ) ); + $this->assertNotSame( + $post_rkey, + $comment_rkey, + 'Post and Comment with identical id+date must mint distinct rkeys via the kind namespace.' + ); + + // Both transformers share the `app.bsky.feed.post` collection, + // so the rkeys must also differ at the microsecond level — not + // just at the 10-bit clock_id derived from the salt. + $this->assertNotSame( + TID_Decoder::tid_to_microseconds( $post_rkey ), + TID_Decoder::tid_to_microseconds( $comment_rkey ), + 'Microsecond portions must differ — the kind label offsets one of them inside the GMT second.' + ); + } } diff --git a/tests/phpunit/tests/transformer/class-test-document.php b/tests/phpunit/tests/transformer/class-test-document.php index bfa4a3d..f8e840f 100644 --- a/tests/phpunit/tests/transformer/class-test-document.php +++ b/tests/phpunit/tests/transformer/class-test-document.php @@ -10,16 +10,26 @@ namespace Atmosphere\Tests\Transformer; require_once __DIR__ . '/class-stub-parser.php'; +require_once __DIR__ . '/class-tid-decoder.php'; use WP_UnitTestCase; use Atmosphere\Transformer\Document; use Atmosphere\Transformer\Post; +use Atmosphere\Transformer\TID; /** * Document transformer tests. */ class Test_Document extends WP_UnitTestCase { + /** + * Remove any filter overrides so they do not leak between tests. + */ + public function tear_down(): void { + \remove_all_filters( 'atmosphere_use_historical_tid' ); + parent::tear_down(); + } + /** * Test that content field is absent when no parser is registered. */ @@ -305,4 +315,68 @@ public function test_collection() { $this->assertSame( 'site.standard.document', $transformer->get_collection() ); } + + /** + * By default, get_rkey() mints a historical TID for the post's + * original publish date, so a 2010 post lands at a 2010 rkey + * regardless of when the backfill runs. + * + * @covers \Atmosphere\Transformer\Document::get_rkey + */ + public function test_get_rkey_defaults_to_historical_tid() { + $post = self::factory()->post->create_and_get( + array( + 'post_date' => '2010-01-15 12:00:00', + 'post_date_gmt' => '2010-01-15 12:00:00', + ) + ); + + // Capture the historical rkey first so the assertion compares + // purely TID encodings rather than the side effect of meta. + $historical_rkey = ( new Document( $post ) )->get_rkey(); + $current_rkey = TID::generate(); + + $this->assertNotEmpty( $historical_rkey ); + $this->assertTrue( TID::is_valid( $historical_rkey ) ); + $this->assertLessThan( $current_rkey, $historical_rkey, '2010-anchored rkey must sort before a now-minted TID.' ); + + // Lock the encoding contract: decoding the rkey must return the + // same microseconds we'd get from the helper for this post. + $expected_microseconds = TID::microseconds_from_post_date( '2010-01-15 12:00:00', $post->ID ); + $this->assertSame( + $expected_microseconds, + TID_Decoder::tid_to_microseconds( $historical_rkey ), + 'Decoded rkey microseconds must match microseconds_from_post_date().' + ); + } + + /** + * Listeners returning false from the atmosphere_use_historical_tid + * filter fall back to the now-based TID::generate() path. + * + * @covers \Atmosphere\Transformer\Document::get_rkey + */ + public function test_get_rkey_filter_opt_out_uses_current_time() { + \add_filter( 'atmosphere_use_historical_tid', '__return_false' ); + + $post = self::factory()->post->create_and_get( + array( + 'post_date' => '2010-01-15 12:00:00', + 'post_date_gmt' => '2010-01-15 12:00:00', + ) + ); + + // A baseline current-time TID minted just before the rkey: + // the filter-disabled rkey should sort right next to it, + // not anywhere near a 2010 historical TID. + $baseline = TID::generate(); + $rkey = ( new Document( $post ) )->get_rkey(); + $historical_2010 = TID::generate_for_time( + TID::microseconds_from_post_date( '2010-01-15 12:00:00', $post->ID ), + Document::TID_SALT_PREFIX . $post->ID + ); + + $this->assertGreaterThan( $baseline, $rkey, 'Opting out via filter must mint a now-based TID.' ); + $this->assertGreaterThan( $historical_2010, $rkey, 'Opted-out TID must sort after a 2010 historical TID.' ); + } } diff --git a/tests/phpunit/tests/transformer/class-test-post.php b/tests/phpunit/tests/transformer/class-test-post.php index 5aaaec9..c4c567a 100644 --- a/tests/phpunit/tests/transformer/class-test-post.php +++ b/tests/phpunit/tests/transformer/class-test-post.php @@ -11,6 +11,9 @@ use WP_UnitTestCase; use Atmosphere\Transformer\Post; +use Atmosphere\Transformer\TID; + +require_once __DIR__ . '/class-tid-decoder.php'; /** * Post transformer tests. @@ -28,6 +31,7 @@ public function tear_down() { \remove_all_filters( 'atmosphere_teaser_thread_posts' ); \remove_all_filters( 'atmosphere_transform_bsky_post' ); \remove_all_filters( 'atmosphere_post_embed' ); + \remove_all_filters( 'atmosphere_use_historical_tid' ); \remove_all_actions( 'atmosphere_long_form_strategy_downgraded' ); parent::tear_down(); } @@ -2030,4 +2034,63 @@ public function test_get_attachment_aspect_ratio_returns_null_for_zero_dimension $this->assertNull( Post::get_attachment_aspect_ratio( $attachment_id ) ); } + + /** + * By default, Post::get_rkey() mints a historical TID anchored on + * the post's original publish date. The decoded microseconds must + * match microseconds_from_post_date(), locking the encoding + * contract end-to-end. + * + * @covers ::get_rkey + */ + public function test_get_rkey_defaults_to_historical_tid() { + $post = self::factory()->post->create_and_get( + array( + 'post_date' => '2012-06-15 09:30:00', + 'post_date_gmt' => '2012-06-15 09:30:00', + ) + ); + + $historical_rkey = ( new Post( $post ) )->get_rkey(); + $current_rkey = TID::generate(); + + $this->assertNotEmpty( $historical_rkey ); + $this->assertTrue( TID::is_valid( $historical_rkey ) ); + $this->assertLessThan( $current_rkey, $historical_rkey, '2012-anchored rkey must sort before a now-minted TID.' ); + + $expected_microseconds = TID::microseconds_from_post_date( '2012-06-15 09:30:00', $post->ID ); + $this->assertSame( + $expected_microseconds, + TID_Decoder::tid_to_microseconds( $historical_rkey ), + 'Decoded rkey microseconds must match microseconds_from_post_date().' + ); + } + + /** + * Listeners returning false from atmosphere_use_historical_tid + * fall back to the now-based TID::generate() path even when the + * post has a usable historical date. + * + * @covers ::get_rkey + */ + public function test_get_rkey_filter_opt_out_uses_current_time() { + \add_filter( 'atmosphere_use_historical_tid', '__return_false' ); + + $post = self::factory()->post->create_and_get( + array( + 'post_date' => '2012-06-15 09:30:00', + 'post_date_gmt' => '2012-06-15 09:30:00', + ) + ); + + $baseline = TID::generate(); + $rkey = ( new Post( $post ) )->get_rkey(); + $historical_2012 = TID::generate_for_time( + TID::microseconds_from_post_date( '2012-06-15 09:30:00', $post->ID ), + Post::TID_SALT_PREFIX . $post->ID + ); + + $this->assertGreaterThan( $baseline, $rkey, 'Opting out via filter must mint a now-based TID.' ); + $this->assertGreaterThan( $historical_2012, $rkey, 'Opted-out TID must sort after a 2012 historical TID.' ); + } } diff --git a/tests/phpunit/tests/transformer/class-test-tid.php b/tests/phpunit/tests/transformer/class-test-tid.php index 634a3f5..52fd98a 100644 --- a/tests/phpunit/tests/transformer/class-test-tid.php +++ b/tests/phpunit/tests/transformer/class-test-tid.php @@ -12,6 +12,9 @@ /** * TID tests. + * + * @group atmosphere + * @group transformer */ class Test_TID extends WP_UnitTestCase { @@ -52,4 +55,235 @@ public function test_is_valid_rejects_bad_input() { $this->assertFalse( TID::is_valid( '0000000000000' ) ); // '0' and '1' not in charset. $this->assertFalse( TID::is_valid( 'AAAAAAAAAAAAA' ) ); // Uppercase not in charset. } + + /** + * The generate_for_time() helper returns a 13-character valid TID. + */ + public function test_generate_for_time_shape() { + $tid = TID::generate_for_time( 1_500_000_000_000_000 ); + + $this->assertSame( 13, \strlen( $tid ) ); + $this->assertTrue( TID::is_valid( $tid ) ); + } + + /** + * The generate_for_time() helper is fully deterministic for the + * same microsecond input. The clock_id is derived from the input + * rather than the per-process random value, so a retry in a + * different PHP worker reconstructs the same rkey — which is the + * idempotency guarantee the backfill flow depends on. + */ + public function test_generate_for_time_is_deterministic() { + $microseconds = 1_500_000_000_000_000; + + $first = TID::generate_for_time( $microseconds ); + $second = TID::generate_for_time( $microseconds ); + + $this->assertSame( $first, $second ); + } + + /** + * The generate_for_time() helper must not consult or update the + * monotonic floor. After a historical mint with an old timestamp + * the live generate() must still produce a now-based TID (not + * floor + 1). + */ + public function test_generate_for_time_does_not_poison_floor() { + // Prime the floor with a current-time call. + $baseline = TID::generate(); + $floor_before = (int) \get_option( 'atmosphere_tid_last_ts', 0 ); + + // Mint a historical TID well below the floor. + $historical_micros = 1_000_000_000_000_000; // 2001-09-09. + $historical = TID::generate_for_time( $historical_micros ); + + $floor_after = (int) \get_option( 'atmosphere_tid_last_ts', 0 ); + + // Floor must not change. + $this->assertSame( $floor_before, $floor_after, 'Historical TID write must not bump the persisted floor.' ); + + // And the historical TID must sort *before* the baseline (proves + // it was not snapped forward to the floor). + $this->assertLessThan( $baseline, $historical, 'Historical TID for 2001 must sort before a current-time TID.' ); + + // A subsequent live generate() should keep moving forward + // relative to the baseline, not regress to the historical value. + $next_live = TID::generate(); + $this->assertGreaterThan( $baseline, $next_live, 'Live generate() after a historical mint must still increase.' ); + } + + /** + * The microseconds_from_post_date() helper returns deterministic, + * post-ID-disambiguated microseconds for two posts at the same second. + */ + public function test_microseconds_from_post_date_disambiguates_same_second() { + $gmt = '2019-03-14 15:09:26'; + + $a = TID::microseconds_from_post_date( $gmt, 42 ); + $b = TID::microseconds_from_post_date( $gmt, 43 ); + $c = TID::microseconds_from_post_date( $gmt, 42 ); + + $this->assertNotSame( $a, $b, 'Different post IDs in the same second must produce different microseconds.' ); + $this->assertSame( $a, $c, 'Same post ID + same second must be idempotent.' ); + + // Sanity-check: the seconds-portion of all three matches. + $seconds = (int) \strtotime( $gmt . ' UTC' ); + $this->assertSame( $seconds, \intdiv( $a, 1_000_000 ) ); + $this->assertSame( $seconds, \intdiv( $b, 1_000_000 ) ); + } + + /** + * The microseconds_from_post_date() helper returns 0 for unparseable + * input so callers can fall back to TID::generate() rather than + * minting an epoch-anchored TID. + */ + public function test_microseconds_from_post_date_returns_zero_on_parse_failure() { + $this->assertSame( 0, TID::microseconds_from_post_date( '', 42 ) ); + $this->assertSame( 0, TID::microseconds_from_post_date( '0000-00-00 00:00:00', 42 ) ); + $this->assertSame( 0, TID::microseconds_from_post_date( 'not a date', 42 ) ); + } + + /** + * Pre-epoch dates are rejected so the historical path never mints a + * negative-encoded TID. Posts dated before 1970-01-01 UTC are + * vanishingly rare in WordPress but possible (manual `post_date_gmt` + * edits, imports from legacy systems); the explicit guard keeps the + * caller-side fallback to {@see TID::generate()} working. + */ + public function test_microseconds_from_post_date_returns_zero_for_pre_epoch_date() { + $this->assertSame( 0, TID::microseconds_from_post_date( '1969-12-31 23:59:00', 42 ) ); + $this->assertSame( 0, TID::microseconds_from_post_date( '1900-01-01 00:00:00', 42 ) ); + } + + /** + * A historical-mint with an old timestamp produces a TID that sorts + * well before a TID minted from `microtime(true)` — proving the + * historical-ordering property end-to-end. + */ + public function test_generate_for_time_sorts_before_current() { + $historical_micros = TID::microseconds_from_post_date( '2010-01-01 00:00:00', 1 ); + $historical = TID::generate_for_time( $historical_micros ); + + $current = TID::generate(); + + $this->assertLessThan( $current, $historical, 'A 2010 TID must sort before a now-minted TID.' ); + } + + /** + * The salt argument to generate_for_time() makes two records that + * map to the same microsecond (e.g. IDs that collide modulo 1M + * inside the same GMT second) mint distinct rkeys. + */ + public function test_generate_for_time_salt_disambiguates_modulo_collision() { + $gmt = '2019-03-14 15:09:26'; + + // IDs that collide on `% 1_000_000` produce identical microseconds. + $micros_a = TID::microseconds_from_post_date( $gmt, 42 ); + $micros_b = TID::microseconds_from_post_date( $gmt, 1_000_042 ); + $this->assertSame( $micros_a, $micros_b, 'Setup: ids differing by 1M must share microseconds.' ); + + // Without a salt, the rkey collides — that's the documented + // failure mode we are guarding against. + $rkey_no_salt_a = TID::generate_for_time( $micros_a ); + $rkey_no_salt_b = TID::generate_for_time( $micros_b ); + $this->assertSame( $rkey_no_salt_a, $rkey_no_salt_b, 'Sanity: no-salt path collides when microseconds match.' ); + + // With a per-record salt, the rkeys must differ even though + // the microsecond portion is identical. + $rkey_a = TID::generate_for_time( $micros_a, 'post:42' ); + $rkey_b = TID::generate_for_time( $micros_b, 'post:1000042' ); + $this->assertNotSame( $rkey_a, $rkey_b, 'Distinct salts must mint distinct rkeys.' ); + } + + /** + * Non-positive microseconds fall back to {@see TID::generate()} + * rather than encoding a negative or epoch-anchored value. Mirrors + * the parse-failure guard in `microseconds_from_post_date()` and + * protects direct callers who hand the helper their own integer. + */ + public function test_generate_for_time_rejects_non_positive_microseconds() { + $zero_tid = TID::generate_for_time( 0, 'post:1' ); + $negative_tid = TID::generate_for_time( -1, 'post:1' ); + + $this->assertTrue( TID::is_valid( $zero_tid ), 'Zero microseconds must fall back to a valid TID.' ); + $this->assertTrue( TID::is_valid( $negative_tid ), 'Negative microseconds must fall back to a valid TID.' ); + + // Fall-back path goes through `generate()` which uses the live + // monotonic floor, so the fallback rkey must sort after a 2010 + // historical TID — the same ordering invariant that proves the + // fallback didn't accidentally mint a near-epoch garbage value. + $historical_2010 = TID::generate_for_time( + TID::microseconds_from_post_date( '2010-01-01 00:00:00', 1 ), + 'post:1' + ); + $this->assertGreaterThan( $historical_2010, $zero_tid ); + $this->assertGreaterThan( $historical_2010, $negative_tid ); + } + + /** + * Microsecond values large enough to overflow into negative when + * shifted left by 10 bits also fall back to `generate()`. Guards + * against caller bugs (e.g. an accidental double `* 1_000_000`). + */ + public function test_generate_for_time_rejects_oversized_microseconds() { + $overflow_tid = TID::generate_for_time( \PHP_INT_MAX, 'post:1' ); + + $this->assertTrue( TID::is_valid( $overflow_tid ), 'Oversized microseconds must fall back to a valid TID.' ); + } + + /** + * The kind argument segregates Post/Document records (microseconds + * 0..499,999 within each second) from Comment records (500,000.. + * 999,999), so they cannot share the microsecond field even with + * matching IDs and timestamps. Idempotency per kind still holds. + */ + public function test_microseconds_from_post_date_namespace_disambiguates() { + $gmt = '2019-03-14 15:09:26'; + $id = 42; + $seconds = (int) \strtotime( $gmt . ' UTC' ); + + $post = TID::microseconds_from_post_date( $gmt, $id ); + $comment = TID::microseconds_from_post_date( $gmt, $id, 'comment' ); + + $this->assertNotSame( $post, $comment, 'Namespaced and bare results must differ for the same id+second.' ); + + // Namespace must be deterministic across calls. + $comment_again = TID::microseconds_from_post_date( $gmt, $id, 'comment' ); + $this->assertSame( $comment, $comment_again, 'Same namespace + id + second must be idempotent.' ); + + // Both still land inside the same GMT second. + $this->assertSame( $seconds, \intdiv( $post, 1_000_000 ) ); + $this->assertSame( $seconds, \intdiv( $comment, 1_000_000 ) ); + + // Disjoint ranges by construction: posts/documents in 0..499_999, + // comments in 500_000..999_999. Lock the exact offsets for id 42 + // so a future refactor cannot silently shrink the segregation. + $this->assertSame( ( $seconds * 1_000_000 ) + 42, $post ); + $this->assertSame( ( $seconds * 1_000_000 ) + 500_042, $comment ); + } + + /** + * Regression test for the cross-kind microsecond collision codex + * surfaced during PR 92 review. Post id 1288 and Comment id 350044 + * landed on identical microseconds under the old modulo+CRC32 scheme + * because `(350044 + crc32('comment')) % 1_000_000 == 1288`, and the + * salt-derived clock_id happened to collide on the same 1/1024 + * bucket — minting identical rkeys inside `app.bsky.feed.post` that + * `applyWrites` would reject on the second create. + * + * The disjoint-range scheme eliminates the failure mode by + * construction; this test locks the specific pair so we cannot + * silently regress. + */ + public function test_microseconds_disambiguate_codex_collision_pair(): void { + $gmt = '2020-01-01 00:00:00'; + $post_micros = TID::microseconds_from_post_date( $gmt, 1288, '' ); + $comment_micros = TID::microseconds_from_post_date( $gmt, 350044, 'comment' ); + + $this->assertNotSame( + $post_micros, + $comment_micros, + 'Post id 1288 and Comment id 350044 must not produce identical microseconds in the same second.' + ); + } } diff --git a/tests/phpunit/tests/transformer/class-tid-decoder.php b/tests/phpunit/tests/transformer/class-tid-decoder.php new file mode 100644 index 0000000..b050b4d --- /dev/null +++ b/tests/phpunit/tests/transformer/class-tid-decoder.php @@ -0,0 +1,61 @@ +> 10; + } +}