feat(runs): state machine, idempotent resume, reaper extension, retention
Pipeline v2 Phase 1, commit 4/4. Run_Stage_State persists per-run per-stage state keyed run_id + stage + agent_role + item_key (item_key scopes articles because one run_id spans a whole batch; agent_role is the Phase-2 quorum dimension, schema-only). Done stages snapshot output and are reused on re-entry — writer stages via Content_Generator::set_run_item, the review via its stored verdict, a published item skipped outright — never re-run, never re-charged. Post creation is keyed by _prautoblogger_run_id + new _prautoblogger_idea_hash (check-before-insert in Publisher) so retries cannot duplicate posts. Run-level research/analysis checkpoints + done/failed lifecycle marks ride Pipeline_Runner/Executor (incl. orphan-abort + budget-abort paths). Run_Reaper rides the EXISTING #19 daily cron: stages stuck running >2x expected stage wall-clock and runs silent >2x expected run wall-clock -> failed (filterable), open stages reaped, queryable as 'incomplete'. Retention: request_json + stage output payloads NULLed after R days, R from the new Audit Payload Retention setting (default constant 14, 0 = keep forever, no literal at point of use). Audit_Writer records editorial verdicts (run_decisions) and per-article kept sources (run_sources) for new runs. Under-300: status-transient/summary helpers extracted verbatim to Pipeline_Status (Pipeline_Runner 271; removed the worker's duplicate). Agent-Session: pipeline-v2-phase1-engineer-20260612
This commit is contained in:
115
tests/unit/Core/RunReaperTest.php
Normal file
115
tests/unit/Core/RunReaperTest.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Run_Reaper.
|
||||
*
|
||||
* Locks the retention contract (R days from the SETTING, 0 = keep
|
||||
* forever) and the stuck-sweep SQL shapes (2x expected wall-clock,
|
||||
* running-only).
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class RunReaperTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
|
||||
*/
|
||||
private $wpdb;
|
||||
|
||||
/**
|
||||
* Captured SQL passed through $wpdb->query().
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private array $queries = array();
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->queries = array();
|
||||
// Local mock: the reaper also uses get_col(), which the shared
|
||||
// BaseTestCase mock does not declare.
|
||||
$this->wpdb = $this->getMockBuilder( \stdClass::class )
|
||||
->addMethods( array( 'prepare', 'get_var', 'get_results', 'get_col', 'insert', 'query', 'get_row', 'update' ) )
|
||||
->getMock();
|
||||
$this->wpdb->prefix = 'wp_';
|
||||
$this->wpdb->insert_id = 0;
|
||||
$this->wpdb->last_error = '';
|
||||
$GLOBALS['wpdb'] = $this->wpdb;
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
static function ( $sql, ...$args ) {
|
||||
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
|
||||
}
|
||||
);
|
||||
Functions\when( 'apply_filters' )->returnArg( 2 );
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retention 0 (or negative) means "keep forever": the pruner must not
|
||||
* issue any UPDATE.
|
||||
*/
|
||||
public function test_zero_retention_keeps_payloads_forever(): void {
|
||||
$this->stub_get_option(
|
||||
array(
|
||||
'prautoblogger_request_json_retention_days' => 0,
|
||||
)
|
||||
);
|
||||
$this->wpdb->method( 'get_var' )->willReturn( null ); // No state tables.
|
||||
$this->wpdb->method( 'get_col' )->willReturn( array() );
|
||||
$this->wpdb->expects( $this->never() )->method( 'query' );
|
||||
|
||||
$stats = \PRAutoBlogger_Run_Reaper::reap();
|
||||
$this->assertSame( 0, $stats['payloads_pruned'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* With the default setting the pruner NULLs request_json older than R
|
||||
* days on the generation log (and stage payloads when that table
|
||||
* exists). The sweeps only touch 'running'/open rows.
|
||||
*/
|
||||
public function test_reap_prunes_and_sweeps_with_setting_backed_window(): void {
|
||||
$this->stub_get_option(
|
||||
array(
|
||||
'prautoblogger_request_json_retention_days' => PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS,
|
||||
)
|
||||
);
|
||||
// Both state tables exist.
|
||||
$this->wpdb->method( 'get_var' )->willReturnCallback(
|
||||
static function ( $sql ) {
|
||||
if ( false !== strpos( (string) $sql, 'run_stages' ) ) {
|
||||
return 'wp_prautoblogger_run_stages';
|
||||
}
|
||||
return 'wp_prautoblogger_runs';
|
||||
}
|
||||
);
|
||||
$this->wpdb->method( 'get_col' )->willReturn( array() ); // No stuck runs.
|
||||
$this->wpdb->method( 'query' )->willReturnCallback(
|
||||
function ( $sql ) {
|
||||
$this->queries[] = (string) $sql;
|
||||
return 2;
|
||||
}
|
||||
);
|
||||
|
||||
$stats = \PRAutoBlogger_Run_Reaper::reap();
|
||||
|
||||
$all_sql = implode( "\n", $this->queries );
|
||||
$this->assertStringContainsString( 'request_json = NULL', $all_sql );
|
||||
$this->assertStringContainsString( 'meta_json = NULL', $all_sql );
|
||||
$this->assertStringContainsString( "status = 'failed'", $all_sql );
|
||||
$this->assertStringContainsString( "WHERE status = 'running' AND updated_at <", $all_sql );
|
||||
$this->assertSame( 4, $stats['payloads_pruned'] ); // 2 rows x 2 tables.
|
||||
}
|
||||
}
|
||||
140
tests/unit/Core/RunStageStateTest.php
Normal file
140
tests/unit/Core/RunStageStateTest.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Run_Stage_State.
|
||||
*
|
||||
* Locks the idempotency-key contract (item scoping for multi-article
|
||||
* runs), the sticky-done upsert semantics, and the self-healing
|
||||
* no-table degradation.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
|
||||
class RunStageStateTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
|
||||
*/
|
||||
private $wpdb;
|
||||
|
||||
/**
|
||||
* Captured SQL passed through $wpdb->query().
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
private array $queries = array();
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->queries = array();
|
||||
$this->wpdb = $this->create_mock_wpdb();
|
||||
$GLOBALS['wpdb'] = $this->wpdb;
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
static function ( $sql, ...$args ) {
|
||||
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
|
||||
}
|
||||
);
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
\PRAutoBlogger_Run_Stage_State::flush_cache();
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Two different ideas in the same run must get different item keys
|
||||
* (one run_id spans all N articles of a batch run); the same idea must
|
||||
* hash stably across processes (queue serialization round-trip).
|
||||
*/
|
||||
public function test_item_keys_scope_articles_within_a_run(): void {
|
||||
$idea_a = new \PRAutoBlogger_Article_Idea(
|
||||
array(
|
||||
'topic' => 'BPC-157 dosing',
|
||||
'article_type' => 'guide',
|
||||
'suggested_title' => 'BPC-157 Dosing Guide',
|
||||
'summary' => 's',
|
||||
)
|
||||
);
|
||||
$idea_b = new \PRAutoBlogger_Article_Idea(
|
||||
array(
|
||||
'topic' => 'TB-500 storage',
|
||||
'article_type' => 'question',
|
||||
'suggested_title' => 'How To Store TB-500',
|
||||
'summary' => 's',
|
||||
)
|
||||
);
|
||||
$idea_a_again = new \PRAutoBlogger_Article_Idea( $idea_a->to_array() );
|
||||
|
||||
$key_a = \PRAutoBlogger_Run_Stage_State::item_key_for_idea( $idea_a );
|
||||
$key_b = \PRAutoBlogger_Run_Stage_State::item_key_for_idea( $idea_b );
|
||||
|
||||
$this->assertNotSame( $key_a, $key_b );
|
||||
$this->assertSame( $key_a, \PRAutoBlogger_Run_Stage_State::item_key_for_idea( $idea_a_again ) );
|
||||
$this->assertStringStartsWith( 'idea:', $key_a );
|
||||
$this->assertLessThanOrEqual( 64, strlen( $key_a ) );
|
||||
$this->assertSame(
|
||||
\PRAutoBlogger_Run_Stage_State::idea_hash( $idea_a ),
|
||||
substr( $key_a, 5 )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* start() upserts must never demote a done stage and must bump attempt
|
||||
* on re-entry; done() must be sticky.
|
||||
*/
|
||||
public function test_start_upsert_protects_done_status(): void {
|
||||
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
|
||||
$this->wpdb->method( 'query' )->willReturnCallback(
|
||||
function ( $sql ) {
|
||||
$this->queries[] = (string) $sql;
|
||||
return 1;
|
||||
}
|
||||
);
|
||||
|
||||
\PRAutoBlogger_Run_Stage_State::start( 'run-1', 'draft', '', 'idea:abc' );
|
||||
|
||||
$sql = implode( "\n", $this->queries );
|
||||
$this->assertStringContainsString( 'ON DUPLICATE KEY UPDATE', $sql );
|
||||
$this->assertStringContainsString( "IF(status = 'done', attempt, attempt + 1)", $sql );
|
||||
$this->assertStringContainsString( "IF(status = 'done', 'done', 'running')", $sql );
|
||||
}
|
||||
|
||||
/**
|
||||
* fail() and fail_open_for_item() must never touch done rows.
|
||||
*/
|
||||
public function test_fail_paths_exclude_done_rows(): void {
|
||||
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
|
||||
$this->wpdb->method( 'query' )->willReturnCallback(
|
||||
function ( $sql ) {
|
||||
$this->queries[] = (string) $sql;
|
||||
return 1;
|
||||
}
|
||||
);
|
||||
|
||||
\PRAutoBlogger_Run_Stage_State::fail( 'run-1', 'draft', '', 'idea:abc' );
|
||||
\PRAutoBlogger_Run_Stage_State::fail_open_for_item( 'run-1', 'idea:abc' );
|
||||
|
||||
$this->assertStringContainsString( "status != 'done'", $this->queries[0] );
|
||||
$this->assertStringContainsString( "status IN ('pending','running')", $this->queries[1] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Without the table (half-migrated site) every method degrades to a
|
||||
* harmless no-op/false/null — never a fatal.
|
||||
*/
|
||||
public function test_no_table_degrades_to_noops(): void {
|
||||
$this->wpdb->method( 'get_var' )->willReturn( null ); // SHOW TABLES miss.
|
||||
$this->wpdb->expects( $this->never() )->method( 'query' );
|
||||
|
||||
\PRAutoBlogger_Run_Stage_State::start( 'run-1', 'draft' );
|
||||
\PRAutoBlogger_Run_Stage_State::done( 'run-1', 'draft', '', '', 'output' );
|
||||
\PRAutoBlogger_Run_Stage_State::fail( 'run-1', 'draft' );
|
||||
$this->assertFalse( \PRAutoBlogger_Run_Stage_State::is_done( 'run-1', 'draft' ) );
|
||||
$this->assertNull( \PRAutoBlogger_Run_Stage_State::get_output( 'run-1', 'draft' ) );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user