Skip to content

Commit 5729c5e

Browse files
authored
Add retry with backoff for failed publish and update cron workers (#182)
1 parent 78bbece commit 5729c5e

9 files changed

Lines changed: 569 additions & 8 deletions

File tree

.agents/skills/code-style/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ Always reserve the rkey via meta in `get_rkey()` — that meta key is the marker
6060

6161
**Content / composition:** `atmosphere_content_parser` (deprecated; use `Content_Parser\Registry::register()`), `atmosphere_document_content`, `atmosphere_long_form_composition`, `atmosphere_teaser_thread_posts`.
6262

63-
**Gating:** `atmosphere_syncable_post_types`, `atmosphere_should_publish_comment`, `atmosphere_should_sync_reply`, `atmosphere_backfill_query_chunk_size`, `atmosphere_oauth_redirect_uri`, `atmosphere_client_metadata`.
63+
**Gating:** `atmosphere_syncable_post_types`, `atmosphere_should_publish_comment`, `atmosphere_should_sync_reply`, `atmosphere_backfill_query_chunk_size`, `atmosphere_oauth_redirect_uri`, `atmosphere_client_metadata`, `atmosphere_publish_retry_delays` (backoff ladder for failed publish/update cron workers; length = retry budget; empty array disables retries).
6464

6565
**Actions:** `atmosphere_publishing`, `atmosphere_publish_post_result`, `atmosphere_publish_comment_result`, `atmosphere_update_skipped_unsynced_post`, `atmosphere_long_form_strategy_downgraded`, `atmosphere_reaction_synced`. `atmosphere_publishing` receives the current `WP_Post` and is not a request-wide guard.
6666

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: minor
2+
Type: added
3+
4+
Failed attempts to share a post to Bluesky are now retried automatically for about twenty minutes, so a brief network or server hiccup no longer means the post silently never appears.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: patch
2+
Type: fixed
3+
4+
Disconnecting or deactivating now reliably removes all pending background tasks, so a task queued under a previous connection can no longer run against a newly connected account.

docs/php-coding-standards.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ use function Atmosphere\is_connected;
227227
\apply_filters( 'atmosphere_should_publish_comment', $bool, $comment );
228228
\apply_filters( 'atmosphere_should_sync_reply', $bool, $notification, $post_id );
229229
\apply_filters( 'atmosphere_backfill_query_chunk_size', 500 );
230+
\apply_filters( 'atmosphere_publish_retry_delays', array( 60, 300, 900 ) ); // Backoff ladder for failed publish/update cron workers; length = retry budget; empty array disables retries.
230231
\apply_filters( 'atmosphere_oauth_redirect_uri', $uri );
231232
\apply_filters( 'atmosphere_client_metadata', $metadata );
232233
\apply_filters( 'atmosphere_appview_host', 'bsky.app', $path, $context ); // Host/subpath for appview web links; normalized; $context keys: type|did|handle|rkey|tag.

includes/class-atmosphere.php

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,36 @@ class Atmosphere {
4646
*/
4747
private const META_PUBLISH_ATTEMPTS = '_atmosphere_publish_attempts';
4848

49+
/**
50+
* Post meta key tracking how many delayed retries a failed
51+
* publish/update cron worker has scheduled for the post.
52+
*
53+
* Deleted on success, on a permanent (non-retryable) failure, and
54+
* when the retry ladder is exhausted, so the next fresh save always
55+
* starts with a full retry budget.
56+
*
57+
* @var string
58+
*/
59+
private const META_PUBLISH_RETRIES = '_atmosphere_publish_retries';
60+
61+
/**
62+
* Backoff ladder for transient publish/update failures, in seconds.
63+
*
64+
* `wp_schedule_single_event()` is one-shot: without a re-queue, a
65+
* single PDS 5xx / rate limit / network blip permanently drops the
66+
* post from Bluesky with no operator-visible trace. One entry per
67+
* retry — three attempts spread over ~21 minutes rides out PDS
68+
* restarts and rate-limit windows without hammering a struggling
69+
* server.
70+
*
71+
* @var int[]
72+
*/
73+
private const PUBLISH_RETRY_DELAYS = array(
74+
MINUTE_IN_SECONDS,
75+
5 * MINUTE_IN_SECONDS,
76+
15 * MINUTE_IN_SECONDS,
77+
);
78+
4979
/**
5080
* Post meta marker set when remote records were removed because a
5181
* previously public post left public visibility.
@@ -679,6 +709,16 @@ public function on_status_change( string $new_status, string $old_status, \WP_Po
679709
try {
680710
\do_action( 'atmosphere_publishing', $post );
681711

712+
/*
713+
* A status transition is fresh user intent, so it starts a
714+
* new retry budget. Without this, a counter stranded by a
715+
* dead retry event (disconnect cleared the queue, the post
716+
* was trashed mid-ladder, a cron event was lost) would
717+
* silently shrink — or zero out — the ladder of the next
718+
* publish attempt.
719+
*/
720+
\delete_post_meta( $post->ID, self::META_PUBLISH_RETRIES );
721+
682722
if ( $is_publishable ) {
683723
\wp_clear_scheduled_hook( 'atmosphere_delete_post', array( $post->ID ) );
684724
}
@@ -1457,6 +1497,7 @@ static function ( int $post_id ): void {
14571497
? Publisher::update_post( $post )
14581498
: Publisher::publish_post( $post );
14591499
self::log_cron_error( 'publish_post', $post_id, $result );
1500+
self::maybe_schedule_publish_retry( 'atmosphere_publish_post', $post_id, $result );
14601501
if ( ! \is_wp_error( $result ) ) {
14611502
self::clear_visibility_cleanup_marker( $post );
14621503
}
@@ -1481,6 +1522,7 @@ static function ( int $post_id ): void {
14811522
? Publisher::update_post( $post )
14821523
: Publisher::publish_post( $post );
14831524
self::log_cron_error( 'update_post', $post_id, $result );
1525+
self::maybe_schedule_publish_retry( 'atmosphere_update_post', $post_id, $result );
14841526
if ( ! \is_wp_error( $result ) ) {
14851527
self::clear_visibility_cleanup_marker( $post );
14861528
}
@@ -1851,6 +1893,142 @@ public static function log_reconcile_cleanup_error( int $post_id, $result ): voi
18511893
self::log_cron_error( 'reconcile_cleanup', $post_id, $result );
18521894
}
18531895

1896+
/**
1897+
* Re-queue a failed publish/update cron worker with backoff.
1898+
*
1899+
* Mirrors the comment parent-defer pattern ({@see self::defer_for_parent()}):
1900+
* a per-object attempt counter in meta, a bounded ladder, and a
1901+
* one-shot re-schedule of the same hook + args. The worker re-checks
1902+
* post state when the retry fires, so a post that was unpublished or
1903+
* disabled in the meantime routes to cleanup instead of publishing.
1904+
*
1905+
* Success and permanent failures clear the counter so the next
1906+
* fresh save starts with a full retry budget.
1907+
*
1908+
* @param string $hook Cron hook to re-schedule (`atmosphere_publish_post` or `atmosphere_update_post`).
1909+
* @param int $post_id Post ID the worker ran for.
1910+
* @param mixed $result Publisher result: array on success, `WP_Error` on failure.
1911+
*/
1912+
private static function maybe_schedule_publish_retry( string $hook, int $post_id, $result ): void {
1913+
if ( ! \is_wp_error( $result ) || ! self::is_transient_publish_error( $result ) ) {
1914+
\delete_post_meta( $post_id, self::META_PUBLISH_RETRIES );
1915+
return;
1916+
}
1917+
1918+
/**
1919+
* Filters the backoff ladder for transient publish/update failures.
1920+
*
1921+
* One entry per retry, in seconds — the ladder's length IS the
1922+
* retry budget. Return an empty array to disable retries, or a
1923+
* longer array to raise the budget (the same knob covers both,
1924+
* so the delay schedule and the attempt cap cannot contradict
1925+
* each other).
1926+
*
1927+
* @since unreleased
1928+
*
1929+
* @param int[] $delays Retry delays in seconds. Default 60, 300, 900.
1930+
*/
1931+
$delays = \apply_filters( 'atmosphere_publish_retry_delays', self::PUBLISH_RETRY_DELAYS );
1932+
$delays = \array_values(
1933+
\array_filter(
1934+
\array_map( 'intval', (array) $delays ),
1935+
static fn( int $delay ): bool => $delay > 0
1936+
)
1937+
);
1938+
1939+
$attempts = (int) \get_post_meta( $post_id, self::META_PUBLISH_RETRIES, true );
1940+
1941+
if ( $attempts >= \count( $delays ) ) {
1942+
/*
1943+
* Ladder exhausted. Clear the counter so a future fresh save
1944+
* gets a new budget, and leave a breadcrumb — this is the
1945+
* point where a post has definitively failed to reach the
1946+
* PDS despite retries. No breadcrumb when the filter
1947+
* disabled retries outright: "giving up after 0 retries"
1948+
* would misread as a failure of the ladder the operator
1949+
* deliberately switched off.
1950+
*/
1951+
\delete_post_meta( $post_id, self::META_PUBLISH_RETRIES );
1952+
1953+
if ( ! empty( $delays ) ) {
1954+
debug_log(
1955+
\sprintf(
1956+
'%s %d: giving up after %d retries (%s)',
1957+
$hook,
1958+
$post_id,
1959+
$attempts,
1960+
$result->get_error_code()
1961+
)
1962+
);
1963+
}
1964+
return;
1965+
}
1966+
1967+
\update_post_meta( $post_id, self::META_PUBLISH_RETRIES, $attempts + 1 );
1968+
\wp_schedule_single_event(
1969+
\time() + $delays[ $attempts ],
1970+
$hook,
1971+
array( $post_id )
1972+
);
1973+
}
1974+
1975+
/**
1976+
* Whether a publish failure is worth retrying.
1977+
*
1978+
* Retry-by-default with a bounded ladder: a wrongly-retried
1979+
* permanent error costs at most three extra requests, while a
1980+
* wrongly-dropped transient error silently loses the post. Only
1981+
* failures that are deterministic — locally-generated preconditions
1982+
* or a PDS 4xx that will reject the identical payload again — are
1983+
* excluded.
1984+
*
1985+
* @param \WP_Error $error Failure returned by the Publisher.
1986+
* @return bool True when a retry has a chance of succeeding.
1987+
*/
1988+
private static function is_transient_publish_error( \WP_Error $error ): bool {
1989+
$permanent_codes = array(
1990+
'atmosphere_post_not_publishable',
1991+
'atmosphere_not_connected',
1992+
'atmosphere_needs_reauth',
1993+
'atmosphere_missing_tid',
1994+
'atmosphere_invalid_pre_apply_writes_return',
1995+
'atmosphere_invalid_pre_apply_writes_response',
1996+
'atmosphere_invalid_pre_upload_blob_return',
1997+
'atmosphere_decrypt',
1998+
'atmosphere_did_mismatch',
1999+
2000+
/*
2001+
* Never retry a failed thread rollback: the orphan manifest
2002+
* records live partial records on the PDS, and a retried
2003+
* publish would mint fresh TIDs next to them — a duplicate,
2004+
* user-visible copy of the post. This state needs operator
2005+
* attention (see Post::META_ORPHAN_RECORDS), not another
2006+
* attempt.
2007+
*/
2008+
'atmosphere_thread_rollback_failed',
2009+
);
2010+
2011+
if ( \in_array( $error->get_error_code(), $permanent_codes, true ) ) {
2012+
return false;
2013+
}
2014+
2015+
$data = $error->get_error_data();
2016+
$status = \is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 0;
2017+
2018+
/*
2019+
* No status means the request never completed (DNS, TLS,
2020+
* timeout) — the classic transient class. 408/429/5xx are the
2021+
* server-side equivalents. Any other 4xx is a deterministic
2022+
* rejection of this exact payload and would fail identically
2023+
* on every attempt.
2024+
*/
2025+
if ( 0 === $status ) {
2026+
return true;
2027+
}
2028+
2029+
return 408 === $status || 429 === $status || $status >= 500;
2030+
}
2031+
18542032
/**
18552033
* Roll back a successful publish if the comment became ineligible
18562034
* during the in-flight applyWrites.

includes/functions.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,14 @@ function get_cron_hooks(): array {
530530
*/
531531
function clear_scheduled_hooks(): void {
532532
foreach ( get_cron_hooks() as $hook ) {
533-
\wp_clear_scheduled_hook( $hook );
533+
/*
534+
* `wp_unschedule_hook()`, not `wp_clear_scheduled_hook()`: the
535+
* latter only removes events whose args match the given array
536+
* (default: empty), so an argless call would leave every queued
537+
* per-post/per-comment event (`[ $post_id ]`) in place — exactly
538+
* the events that must not fire against a different connection.
539+
*/
540+
\wp_unschedule_hook( $hook );
534541
}
535542
}
536543

@@ -544,7 +551,10 @@ function clear_scheduled_hooks(): void {
544551
*/
545552
function clear_scheduled_hooks_all(): void {
546553
clear_scheduled_hooks();
547-
\wp_clear_scheduled_hook( 'atmosphere_revoke_refresh_token' );
554+
555+
// The revoke event always carries args (the encrypted token payload),
556+
// so it likewise needs the args-agnostic unschedule.
557+
\wp_unschedule_hook( 'atmosphere_revoke_refresh_token' );
548558
}
549559

550560
/**

0 commit comments

Comments
 (0)