Skip to content
43 changes: 43 additions & 0 deletions wordpress/tests/durable/journal-cas-unit.php
Original file line number Diff line number Diff line change
Expand Up @@ -235,4 +235,47 @@ function fresh_db(): Fake_Journal_Db { return new Fake_Journal_Db(); }
eq( $db->read_row( 'plan-F', 31 )['state'], 'PENDING', 'F failed CAS wrote nothing' );
echo "#296 F caller failure contract: PASS ($assertions assertions)\n";

// G. #297 applied_at evidence: strict calendar/clock validity, not shape only.
// The shape-only regex accepted month 19, February 31 and hour 24. The shared
// helper backs both worker reconciliation and verifier observe(); downstream
// NEEDS_REVIEW fail-closed behavior for NULL/zero-date rows is already proven
// by the jobs integration cases, which need a real database.
function stamp_row( $applied_at ) {
$row = array( 'applied_at' => $applied_at );
if ( null === $applied_at ) { unset( $row['applied_at'] ); }
return $row;
}
foreach ( array( '2026-10-16 17:29:30', '2024-02-29 12:00:00', '2026-01-31 23:59:59', '2026-04-30 00:00:00', '2026-10-15 14:00:01' ) as $valid ) {
eq( J::has_valid_applied_at( stamp_row( $valid ) ), true, 'G accepts ' . $valid );
}
foreach ( array(
'missing key' => null,
'empty string' => '',
'zero-date' => '0000-00-00 00:00:00',
'month 13' => '2026-13-01 00:00:00',
'month 00' => '2026-00-10 00:00:00',
'month 19 day 39 hour 29' => '2026-19-39 29:59:59',
'Feb 29 non-leap' => '2023-02-29 12:00:00',
'Feb 31' => '2026-02-31 12:00:00',
'day 00' => '2026-01-00 12:00:00',
'day 32' => '2026-01-32 12:00:00',
'hour 24' => '2026-04-15 24:00:00',
'hour 29' => '2026-10-16 29:00:00',
'minute 60' => '2026-10-16 12:60:00',
'second 60' => '2026-10-16 12:00:60',
'trailing space' => '2026-10-16 17:29:30 ',
'trailing char' => '2026-10-16 17:29:30x',
'leading space' => ' 2026-10-16 17:29:30',
'date only' => '2026-10-16',
'time only' => '17:29:30',
'slashes' => '2026/10/16 17:29:30',
'iso T' => '2026-10-16T17:29:30',
'dmy' => '16-10-2026 17:29:30',
'garbage' => 'not-a-timestamp',
) as $label => $invalid ) {
eq( J::has_valid_applied_at( stamp_row( $invalid ) ), false, 'G rejects ' . $label );
}
eq( J::has_valid_applied_at( array( 'applied_at' => null ) ), false, 'G rejects explicit null' );
echo "#297 G applied_at calendar/clock validity: PASS ($assertions assertions)\n";

echo "#296 journal compare-and-set unit matrix: PASS ($assertions assertions total)\n";
172 changes: 172 additions & 0 deletions wordpress/tests/jobs/integration.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
use WriteLeash\Price_Selection_Spec as Selection;
use WriteLeash\Safety_Policy as Policy;
use WriteLeash\Woo_Price_Planner as Planner;
use WriteLeash\Undo_Repository as URepo;
use WriteLeash\Undo_Worker as UWorker;
use WriteLeash\Undo_Item_State as UItem;
use WriteLeash\Undo_Schema as USchema;

global $wpdb, $work, $assertions, $hook_log, $items_table, $jobs_table;
$assertions = 0;
Expand Down Expand Up @@ -512,6 +516,174 @@ function limits( int $items, int $seconds = 20 ): array { return array( 'max_ite
marker( 'kill between checkpoints ' . $point . ': durable PENDING, resume once, one net price change' );
}

// ---------------------------------------------------------------------------
// #297 live-worker ambiguous commit: the mutator returns NEEDS_REVIEW while
// the worker stays alive, after a durable journal APPLIED. Journal truth plus
// binding is adopted with the journal applied_at; the Woo save never replays.
// ---------------------------------------------------------------------------
$f = job_fixture( array( 'changing' => 2 ) );
$job_id = (int) $f['job']['id'];
$live_ids = array_values( $f['ids'] );
$live_a = $live_ids[0];
$live_b = $live_ids[1];
approves( $f );
Worker::queue_job( $job_id );
$live = run_worker( array( 'job_id' => $job_id, 'mode' => 'run', 'limits' => limits( 2 ), 'mutator_checkpoint' => 'AFTER_COMMIT_BEFORE_RESPONSE', 'mutator_fault' => 'throw' ) );
$journal_a = journal_row( $f['plan']->data()['plan_id'], $live_a );
$journal_b = journal_row( $f['plan']->data()['plan_id'], $live_b );
eq( $journal_a['state'], 'APPLIED', 'live fault journal durable APPLIED A' );
eq( $journal_b['state'], 'APPLIED', 'live fault journal durable APPLIED B' );
ok( '' !== ( $journal_a['evidence'] ?? '' ), 'live fault journal evidence intact A' );
$live_items = item_map( $job_id );
eq( $live_items[ $live_a ]['state'], IState::APPLIED, 'live fault item adopted APPLIED A' );
eq( $live_items[ $live_b ]['state'], IState::APPLIED, 'live fault item adopted APPLIED B' );
eq( $live_items[ $live_a ]['reason'], 'DURABLE_RECONCILED', 'live fault typed adoption reason' );
eq( $live_items[ $live_a ]['applied_at'], $journal_a['applied_at'], 'live fault applied_at is the journal applied_at' );
eq( saves( $live_a ), 1, 'live fault no second save A' );
eq( saves( $live_b ), 1, 'live fault no second save B' );
eq( fresh_price( $live_a ), '80.00', 'live fault planned target A' );
eq( $live['status'], JState::COMPLETED, 'live fault job completes without review' );
assert_counts( $job_id, 'live-ambiguous-adopt' );
marker( 'live ambiguous commit: bound APPLIED adopted with journal applied_at, zero replay' );

// A reconciled APPLIED item stays eligible for conditional Undo, while a
// newer external price on the sibling still conflicts instead of overwriting.
$p = wc_get_product( $live_b );
$p->set_regular_price( '70.00' );
$p->save();
$live_external_saves = saves( $live_b );
wp_set_current_user( 1 );
$live_op = URepo::initiate( $job_id, 1 );
$live_undo = UWorker::run( (int) $live_op['id'], limits( 5 ), true );
global $wpdb;
$undo_a = URepo::read_item( $wpdb, $job_id, $live_a );
$undo_b = URepo::read_item( $wpdb, $job_id, $live_b );
eq( $undo_a['state'], UItem::UNDONE, 'reconciled item undo eligible and restored' );
eq( Decimal::parse( fresh_price( $live_a ) ), '100', 'reconciled item restored to pre-apply price' );
eq( $undo_b['state'], UItem::CONFLICT, 'newer external price still conflicts' );
eq( fresh_price( $live_b ), '70.00', 'newer external price never overwritten' );
eq( saves( $live_b ), $live_external_saves, 'conflicting undo performs zero restores' );
marker( 'reconciled APPLIED feeds conditional Undo; newer price conflicts' );
// The next cache profile reuses job IDs after mid-file table recreation while
// Undo tables persist: remove only this block's Undo operation/items, exactly
// like the migration fixture cleanup at the end of this file.
eq( $wpdb->delete( USchema::items_table( $wpdb ), array( 'undo_id' => (int) $live_op['id'], 'plan_id' => $f['plan']->data()['plan_id'] ) ), 2, 'cleanup this reconciliation fixture Undo items' );
eq( $wpdb->delete( USchema::operations_table( $wpdb ), array( 'id' => (int) $live_op['id'], 'plan_id' => $f['plan']->data()['plan_id'] ) ), 1, 'cleanup this reconciliation fixture Undo operation' );

// ---------------------------------------------------------------------------
// #297 fault before commit: the rolled-back attempt is never adopted as
// APPLIED and never saves twice; an explicit retry still applies once.
// ---------------------------------------------------------------------------
$f = job_fixture( array( 'changing' => 1 ) );
$job_id = (int) $f['job']['id'];
$id = $f['ids'][0];
approves( $f );
Worker::queue_job( $job_id );
$pre = run_worker( array( 'job_id' => $job_id, 'mode' => 'run', 'limits' => limits( 2 ), 'mutator_checkpoint' => 'AFTER_JOURNAL_BEFORE_COMMIT', 'mutator_fault' => 'throw' ) );
eq( journal_row( $f['plan']->data()['plan_id'], $id )['state'], 'PENDING', 'pre-commit fault never durable APPLIED' );
$pre_item = item_map( $job_id )[ $id ];
eq( $pre_item['state'], IState::PENDING, 'pre-commit fault item stays retryable PENDING' );
ok( IState::APPLIED !== $pre_item['state'], 'pre-commit fault never falsely adopted' );
eq( saves( $id ), 1, 'pre-commit fault single rolled-back save' );
eq( fresh_price( $id ), '100.00', 'pre-commit fault keeps the original price' );
$wpdb->query( $wpdb->prepare( "UPDATE $items_table SET next_attempt_after=NULL WHERE job_id=%d AND product_id=%d", $job_id, $id ) );
$retry = run_worker( array( 'job_id' => $job_id, 'mode' => 'run', 'limits' => limits( 2 ), 'manual' => true ) );
eq( $retry['status'], JState::COMPLETED, 'retry after pre-commit fault completes' );
eq( saves( $id ), 2, 'retry writes exactly once more after a rolled-back save' );
eq( fresh_price( $id ), '80.00', 'retry writes the planned target once' );
eq( journal_row( $f['plan']->data()['plan_id'], $id )['state'], 'APPLIED', 'retry durable APPLIED' );
marker( 'pre-commit fault: no false adoption, explicit retry applies once' );

// ---------------------------------------------------------------------------
// #297 journal binding mismatch under a live worker: APPLIED in the journal
// but corrupt material stays review, with zero new mutation.
// ---------------------------------------------------------------------------
$f = job_fixture( array( 'changing' => 1 ) );
$job_id = (int) $f['job']['id'];
$id = $f['ids'][0];
approves( $f );
Worker::queue_job( $job_id );
$clean = run_worker( array( 'job_id' => $job_id, 'mode' => 'run', 'limits' => limits( 2 ) ) );
eq( $clean['status'], JState::COMPLETED, 'binding fixture applies clean' );
eq( saves( $id ), 1, 'binding fixture one save' );
// Corrupt the material the binding check covers, then stage a live worker
// attempt against the same row by returning its item to PENDING and its job
// to PAUSED in the DB (staging only; no state-machine transition is used).
$journal_table = Journal::table( $wpdb );
eq( (int) $wpdb->update( $journal_table, array( 'target_price' => '79.99' ), array( 'plan_id' => $f['plan']->data()['plan_id'], 'product_id' => $id ) ), 1, 'binding mismatch staged' );
eq( (int) $wpdb->update( $items_table, array( 'state' => IState::PENDING, 'claim_token' => '', 'claim_generation' => 0 ), array( 'job_id' => $job_id, 'product_id' => $id ) ), 1, 'live attempt staged' );
eq( (int) $wpdb->update( $jobs_table, array( 'status' => JState::PAUSED ), array( 'id' => $job_id ) ), 1, 'staged job resumable' );
$mis = run_worker( array( 'job_id' => $job_id, 'mode' => 'run', 'limits' => limits( 2 ), 'manual' => true ) );
$mis_item = item_map( $job_id )[ $id ];
eq( $mis_item['state'], IState::NEEDS_REVIEW, 'binding mismatch stays review' );
eq( saves( $id ), 1, 'binding mismatch zero new saves' );
eq( fresh_price( $id ), '80.00', 'binding mismatch keeps the applied price' );
eq( journal_row( $f['plan']->data()['plan_id'], $id )['state'], 'APPLIED', 'binding mismatch leaves the journal row alone' );
marker( 'binding mismatch under live worker: review, zero mutation' );

// ---------------------------------------------------------------------------
// #297 commit() itself throws after the durable write took effect: the worker
// cannot know the outcome from the exception alone, so it must reconcile from
// the journal instead of blindly retrying the mutation.
// ---------------------------------------------------------------------------
$f = job_fixture( array( 'changing' => 1 ) );
$job_id = (int) $f['job']['id'];
$id = $f['ids'][0];
approves( $f );
Worker::queue_job( $job_id );
$amb_worker = start_worker( array( 'job_id' => $job_id, 'mode' => 'run', 'limits' => limits( 2 ), 'commit_checkpoint' => 'AFTER_COMMIT_BEFORE_RETURN', 'commit_fault' => 'throw-ambiguous' ) );
$amb = finish_worker( $amb_worker );
$amb_barrier = file_get_contents( $amb_worker['spec']['barrier'] );
ok( str_starts_with( (string) $amb_barrier, 'AFTER_COMMIT_BEFORE_RETURN:' ), 'commit fault fired after durable COMMIT' );
$amb_journal = journal_row( $f['plan']->data()['plan_id'], $id );
eq( $amb_journal['state'], 'APPLIED', 'thrown commit left the durable APPLIED row' );
$amb_item = item_map( $job_id )[ $id ];
eq( $amb_item['state'], IState::APPLIED, 'thrown commit reconciled to APPLIED' );
eq( $amb_item['reason'], 'DURABLE_RECONCILED', 'thrown commit typed adoption reason' );
eq( $amb_item['applied_at'], $amb_journal['applied_at'], 'thrown commit applied_at is the journal applied_at' );
eq( saves( $id ), 1, 'thrown commit never re-saved' );
eq( fresh_price( $id ), '80.00', 'thrown commit kept the planned target' );
eq( $amb['status'], JState::COMPLETED, 'thrown commit job completes without review' );
assert_counts( $job_id, 'commit-throws-ambiguous' );
marker( 'commit() throws after durable write: journal reconciliation, zero replay' );

// ---------------------------------------------------------------------------
// #297 fail closed on a bound APPLIED row without timestamp evidence: a NULL
// applied_at (missing) and a zero-date applied_at (empty) both stay
// NEEDS_REVIEW with zero new mutation. Valid APPLIED rows still reconcile,
// as proven by every adoption case above.
// ---------------------------------------------------------------------------
foreach ( array( 'null' => null, 'zero-date' => '0000-00-00 00:00:00' ) as $variant => $stamp ) {
$f = job_fixture( array( 'changing' => 1 ) );
$job_id = (int) $f['job']['id'];
$id = $f['ids'][0];
approves( $f );
Worker::queue_job( $job_id );
$setup = run_worker( array( 'job_id' => $job_id, 'mode' => 'run', 'limits' => limits( 2 ) ) );
eq( $setup['status'], JState::COMPLETED, 'timestamp fixture applies clean ' . $variant );
eq( saves( $id ), 1, 'timestamp fixture one save ' . $variant );
$journal_table = Journal::table( $wpdb );
$sql_mode = null;
if ( 'zero-date' === $variant ) {
// Strict mode refuses the empty timestamp; relax this session only to
// stage the storable empty shape, then restore immediately.
$sql_mode = $wpdb->get_var( 'SELECT @@SESSION.sql_mode' );
$wpdb->query( "SET SESSION sql_mode=''" );
}
eq( (int) $wpdb->update( $journal_table, array( 'applied_at' => $stamp ), array( 'plan_id' => $f['plan']->data()['plan_id'], 'product_id' => $id ) ), 1, 'timestamp evidence removed ' . $variant );
if ( 'zero-date' === $variant ) { $wpdb->query( $wpdb->prepare( 'SET SESSION sql_mode=%s', (string) $sql_mode ) ); }
eq( journal_row( $f['plan']->data()['plan_id'], $id )['state'], 'APPLIED', 'timestamp case keeps APPLIED state ' . $variant );
eq( (int) $wpdb->update( $items_table, array( 'state' => IState::PENDING, 'claim_token' => '', 'claim_generation' => 0 ), array( 'job_id' => $job_id, 'product_id' => $id ) ), 1, 'live attempt staged ' . $variant );
eq( (int) $wpdb->update( $jobs_table, array( 'status' => JState::PAUSED ), array( 'id' => $job_id ) ), 1, 'staged job resumable ' . $variant );
$ts = run_worker( array( 'job_id' => $job_id, 'mode' => 'run', 'limits' => limits( 2 ), 'manual' => true ) );
$ts_item = item_map( $job_id )[ $id ];
eq( $ts_item['state'], IState::NEEDS_REVIEW, 'missing timestamp stays review ' . $variant );
eq( saves( $id ), 1, 'missing timestamp zero new saves ' . $variant );
eq( fresh_price( $id ), '80.00', 'missing timestamp keeps the applied price ' . $variant );
eq( journal_row( $f['plan']->data()['plan_id'], $id )['state'], 'APPLIED', 'missing timestamp leaves the journal row alone ' . $variant );
marker( 'missing applied_at (' . $variant . '): fail closed to NEEDS_REVIEW, zero mutation' );
}

// ---------------------------------------------------------------------------
// Transient read refusal at the job boundary: durable PENDING, bounded retry.
// The retry re-runs the frozen precondition, so a newer edit wins as CONFLICT.
Expand Down
23 changes: 23 additions & 0 deletions wordpress/tests/jobs/worker.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
$barrier = (string) ( $spec['mutator_barrier'] ?? $spec['barrier'] );
$release = (string) ( $spec['mutator_release'] ?? $spec['release'] );
file_put_contents( $barrier, $point . ':' . (int) $GLOBALS['wpdb']->dbh->thread_id );
if ( 'throw' === ( $spec['mutator_fault'] ?? '' ) ) { throw new RuntimeException( 'controlled-mutator-fault' ); }
if ( 'wait' === ( $spec['mutator_fault'] ?? '' ) ) {
$deadline = microtime( true ) + 30;
while ( ! is_file( $release ) ) {
Expand All @@ -42,6 +43,28 @@
}
}, 10, 3 );
}
// #297 commit-level fault: the hook fires inside Price_Apply_Connection::commit()
// after the durable COMMIT succeeded but before ownership is released. A throw
// here models a lost acknowledgement with the write already durable; the
// worker cannot know the outcome from the exception alone.
// The commit transport is shared (claims, setup, mutations), so the fault
// arms only once the mutator reaches AFTER_JOURNAL_BEFORE_COMMIT: the next
// commit on this connection is then provably the item commit, never a claim.
if ( ! empty( $spec['commit_checkpoint'] ) ) {
add_action( 'writeleash_price_apply_checkpoint', static function ( $point ) use ( $spec ) {
if ( 'AFTER_JOURNAL_BEFORE_COMMIT' !== $point ) { return; }
// One-shot: the next transport commit is provably the item commit, so
// the fault must disarm itself to leave later claim/finish commits alone.
$fault = null;
$fault = static function ( $cpoint ) use ( $spec, &$fault ) {
if ( $cpoint !== $spec['commit_checkpoint'] ) { return; }
remove_action( 'writeleash_price_commit_checkpoint', $fault, 10 );
file_put_contents( $spec['barrier'], $cpoint . ':' . (int) $GLOBALS['wpdb']->dbh->thread_id );
if ( 'throw-ambiguous' === ( $spec['commit_fault'] ?? '' ) ) { throw new \WriteLeash\Price_Apply_Error( 'AMBIGUOUS_COMMIT' ); }
};
add_action( 'writeleash_price_commit_checkpoint', $fault, 10, 2 );
}, 10, 3 );
}
// Public Woo factory filter staging a transient read refusal for one product,
// exactly as the #209 durable probe does; never a mutator/production mock.
if ( ! empty( $spec['class_fault'] ) ) {
Expand Down
Loading
Loading