v0.8.1: orphan research-row reaper + GA4 description fix
* feat(cost): persist run_id on generated posts + back-populate for v0.8.1 reaper
The orphan-research-row reaper (upcoming commit) needs a cheap way to
answer "which posts belong to this run_id?" when amortizing a stranded
`llm_research` row back to its siblings. Relying on the generation_log
post_id column is circular: if the pipeline died before amortize_research
ran, we'd trust the same table we're trying to repair.
- `Publisher::build_meta()` now accepts `?string $run_id` and writes
`_prautoblogger_run_id => $run_id` when non-empty. Null/empty preserves
existing null-safe legacy behaviour.
- `Publisher::create_post()` forwards the $run_id it already receives
into build_meta — one-line plumbing, no new parameters at call sites.
- `Activator::backpopulate_run_id_meta_v081()` runs once per site,
gated by `prautoblogger_migrated_run_id_meta_v081` option. Walks
`wp_prautoblogger_generation_log`, pairs (post_id, min(run_id)),
writes `_prautoblogger_run_id` on any post that doesn't already have
it. Uses `metadata_exists()` so it's a no-op on re-run.
- Activator also schedules the new daily `prautoblogger_reap_orphan_research_rows`
cron at 03:15 — 15 min after the primary generation cron — so live
pipelines have time to finish amortizing cleanly before the reaper scans.
- Deactivator unschedules the new hook alongside the existing cron hooks.
No user-visible behaviour change yet. The reaper handler ships in the
next commit; this one just ensures it will have the data + schedule it
needs when it lands.
Part of the v0.8.1 bundle (thread research-cost-reaper).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(cost): daily reaper for orphan llm_research rows
Implements the orphan-research-row reaper per thread research-cost-reaper
seq 1. When a pipeline dies between post creation and
`Post_Assembler::amortize_research_costs()` (Hostinger exec kill, OOM,
LiteSpeed cut, fatal), the shared research row stays in
`wp_prautoblogger_generation_log` with `post_id = NULL` and sibling
articles' cost popovers understate their true spend.
New class `PRAutoBlogger_Research_Reaper` owns:
- `on_cron()` — WP-Cron entrypoint hooked to the daily
`prautoblogger_reap_orphan_research_rows` event (already scheduled
by the Activator in the previous commit at 03:15).
- `reap()` — returns a `{reaped, deleted, skipped}` summary. For each
orphan `llm_research` row older than 1 hour (grace window for live
pipelines):
- `_prautoblogger_run_id` post_meta primary lookup.
- Falls back to distinct post_ids in gen_log with the same run_id.
- If ≥1 post found → delegate to the existing idempotent
`Post_Assembler::amortize_research_costs()`. That method inserts
the per-article rows and deletes the original orphan in one pass,
so the reaper is idempotent on re-run.
- If 0 posts found AND orphan is >7 days old → delete outright as
sunk cost with nothing to attribute to.
- Else → skip; try again the next day.
- Never throws. DB errors caught and logged WARNING.
New WP-CLI command `wp prautoblogger reap-research` registered in the
main loader when `WP_CLI` is defined — manual ops trigger that prints
the same summary.
Departure from the CTO's spec: the reap logic lives on
`Research_Reaper` instead of `Post_Assembler`. Placing it on
Post_Assembler pushed that file to 391 lines, over the 300-line cap.
Moving it to the thin class colocates the attribution logic with the
cron/CLI plumbing that triggers it. `amortize_research_costs()` stays
on Post_Assembler — the reaper delegates to it unchanged. Flagged for
CTO review.
Part of the v0.8.1 bundle (thread research-cost-reaper).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(cost): ResearchReaperTest — five cases per spec
Covers the five scenarios from convo thread research-cost-reaper seq 1:
- test_reaps_orphan_with_matching_run_id_posts — meta lookup returns
two post IDs; reaper delegates to amortize_research_costs which
inserts two rows and deletes the orphan.
- test_skips_orphan_within_grace_period — the SELECT's
`created_at < now - 1h` clause filters fresh orphans at the DB; the
reaper's stats stay zero and no side effects fire.
- test_deletes_orphan_with_no_articles_after_7_days — orphan is 8 days
old, zero siblings via meta or gen_log; reaper deletes outright.
- test_falls_back_to_gen_log_when_post_meta_missing — meta returns
empty (legacy pre-v0.8.1 post); gen_log has two distinct post_ids;
amortize still runs.
- test_idempotent_on_repeat_run — second pass on a cleaned state
returns 0/0/0 with no inserts or deletes.
All DB interaction mocked via $wpdb stub — no real DB round-trips.
Fixtures are driven by property mutations in the test methods so
PHPUnit's "one stub per method" restriction on mocks doesn't bite.
Part of the v0.8.1 bundle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(v0.8.1): version bump + CHANGELOG + ARCHITECTURE.md for reaper
Bump plugin header and PRAUTOBLOGGER_VERSION 0.8.0 → 0.8.1. Patch
bump — the reaper is internal infrastructure; no user-visible UX
change.
CHANGELOG:
- Fixed: orphan `llm_research` rows now reaped daily.
- Added: WP-CLI `wp prautoblogger reap-research`; `_prautoblogger_run_id`
post_meta is now written by Publisher on every post.
ARCHITECTURE.md:
- Decision #13 (Run-ID based log linking) cross-refs the new post_meta.
- New Decision #19 documents the reaper's scan/amortize/delete policy,
the 1-hour grace window, the 7-day stale cutoff, and the one-shot
activator back-populate migration.
DB schema unchanged — no PRAUTOBLOGGER_DB_VERSION bump.
Final commit of the v0.8.1 bundle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(cost): widen $wpdb mock so ResearchReaperTest actually runs
BaseTestCase::create_mock_wpdb() only adds methods
[prepare, get_var, get_results, insert, query, get_row, update] — the
reaper and amortize_research_costs also call `delete` and `get_col`,
which the default mock can't stub. CI on PR #100 fired all five new
ResearchReaperTest cases as errors at the first `->method('delete')`
call during setUp.
Fix: build a bespoke $wpdb mock in this file's setUp with the full
superset of methods it needs. Self-contained; no change to
BaseTestCase so existing tests aren't affected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(cost): filter gen_log inserts from event_log inserts in reaper tests
CI on ec1efee showed the two reap-and-amortize tests failing because
`assertCount(2, $this->inserted_rows)` was seeing 4 rows, not 2. Root
cause: the Logger singleton writes INFO lines via `$wpdb->insert` into
the event_log table — the reaper logs once ("Reaped...") and amortize
logs once ("Amortized LLM research cost...") before the two per-post
gen_log inserts, so the capture ended up with 4 total rows.
Add a `gen_log_inserts()` helper that filters the capture down to rows
whose `__table` contains "generation_log" — the assertions on
attribution count should only measure the actual cost inserts, not
incidental log writes. Two tests updated; logic unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(admin): correct misleading GA4 Property ID description
The GA4 client at includes/core/class-ga4-client.php:85 already wraps the
property_id option with "properties/" in the outbound URL path. The admin
description told users to enter "properties/XXXXXXXXX" — which would have
produced a double-prefixed URL (properties/properties/XXXXXXXXX) and a
guaranteed 404 from the Data API. Description now asks for digits only,
matching actual code behavior.
No code logic change — UX copy + CHANGELOG only. Flagged by CEO while
inspecting the Analytics tab; folded into the in-flight v0.8.1 PR per
CTO call rather than cutting a separate patch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -521,7 +521,10 @@ The posts widget uses WordPress-bundled React (`wp.element`) with raw `createEle
|
||||
The widget fetches posts asynchronously from a dedicated REST endpoint rather than rendering server-side. This keeps initial page load fast and allows the endpoint to be cached independently (5-minute Cache-Control). The permission callback uses a filter (`prautoblogger_rest_posts_public`) so sites can restrict access if needed.
|
||||
|
||||
### #13: Run-ID based log linking
|
||||
Each pipeline execution generates a UUID (`run_id`) that tags every `prab_generation_log` entry. When a post is published, `link_generation_logs()` uses `UPDATE WHERE run_id = X` to associate entries with the `post_id`. This is more reliable than the previous timestamp-window approach, especially in batch runs where multiple posts are generated in quick succession.
|
||||
Each pipeline execution generates a UUID (`run_id`) that tags every `prab_generation_log` entry. When a post is published, `link_generation_logs()` uses `UPDATE WHERE run_id = X` to associate entries with the `post_id`. This is more reliable than the previous timestamp-window approach, especially in batch runs where multiple posts are generated in quick succession. **v0.8.1+**: `run_id` is also persisted on each post as `_prautoblogger_run_id` post_meta, giving the orphan-research reaper a direct path from run to siblings without walking the gen_log table (see #19).
|
||||
|
||||
### #19: Daily orphan-research-row reaper (v0.8.1)
|
||||
When a pipeline dies between post creation and the final `Post_Assembler::amortize_research_costs()` call (Hostinger exec kill, OOM, LiteSpeed cut, fatal), the shared `llm_research` cost row stays in `prab_generation_log` with `post_id = NULL` and sibling articles understate their true cost in the breakdown popover. A new daily WP-Cron event `prautoblogger_reap_orphan_research_rows` (hooked to `PRAutoBlogger_Research_Reaper::on_cron`, 03:15 server time — 15 min after the primary generation cron) scans for orphan rows older than a 1-hour grace window, looks up sibling posts by `_prautoblogger_run_id` post_meta (fallback: distinct `post_id`s in gen_log for the same run_id), and delegates to the existing idempotent `amortize_research_costs()` on match. Orphans that still have no siblings after 7 days are deleted outright as sunk cost. A one-time activator migration (`prautoblogger_migrated_run_id_meta_v081`) back-populates `_prautoblogger_run_id` on existing posts from gen_log so the meta-primary path works on upgrade. Manual ops trigger: `wp prautoblogger reap-research`.
|
||||
|
||||
### #14: Reddit RSS replaces PullPush.io (and earlier Reddit OAuth)
|
||||
Reddit rejected our OAuth API application (April 2026). We initially switched to PullPush.io, but its index was frequently stale or unavailable. Reddit's RSS/Atom feeds (`/r/{sub}/hot.rss`) proved the most reliable option — they work from datacenter IPs where .json gets 403, require no auth, and have no apparent rate limit. The .json endpoints are kept as a fallback for posts and as the sole source for comment data. Each collected item's metadata includes a `data_source` field (`reddit_rss` or `reddit_json`) for auditability.
|
||||
|
||||
26
CHANGELOG.md
26
CHANGELOG.md
@@ -5,6 +5,32 @@ All notable changes to PRAutoBlogger will be documented in this file.
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.8.1] — 2026-04-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Orphaned LLM research costs now reaped daily.** When a pipeline died
|
||||
between post creation and the final cost-amortization step (e.g.
|
||||
execution-time kill on shared hosting), the `llm_research` row stayed
|
||||
unlinked and the affected articles' cost popovers understated true spend.
|
||||
A new daily cron (`prautoblogger_reap_orphan_research_rows`, 03:15
|
||||
server time) retroactively amortizes orphan rows against posts from
|
||||
the same run. Includes a one-time activator migration to back-populate
|
||||
`_prautoblogger_run_id` post_meta for existing posts.
|
||||
- **GA4 Property ID admin description corrected.** The field description
|
||||
previously instructed users to enter `properties/XXXXXXXXX`, but the
|
||||
GA4 client already prepends the `properties/` URL segment internally —
|
||||
a double-prefixed value would 404. Description now asks for the digits
|
||||
only, matching actual code behavior.
|
||||
|
||||
### Added
|
||||
|
||||
- New WP-CLI command `wp prautoblogger reap-research` for manual reaping
|
||||
and ops debugging.
|
||||
- `Publisher::build_meta()` now writes `_prautoblogger_run_id` on every
|
||||
generated post, so the reaper has a direct meta path from run_id to
|
||||
sibling posts without walking `wp_prautoblogger_generation_log`.
|
||||
|
||||
## [0.8.0] — 2026-04-21
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -154,7 +154,7 @@ class PRAutoBlogger_Settings_Fields_Extended {
|
||||
'label' => __( 'GA4 Property ID', 'prautoblogger' ),
|
||||
'type' => 'text',
|
||||
'section' => 'prautoblogger_analytics',
|
||||
'description' => __( 'Format: properties/XXXXXXXXX. Leave blank to skip GA4.', 'prautoblogger' ),
|
||||
'description' => __( 'Your GA4 Property ID — digits only, e.g. 123456789 (the code adds the "properties/" prefix internally). Find it in GA4 → Admin → Property details. Leave blank to skip GA4.', 'prautoblogger' ),
|
||||
],
|
||||
[
|
||||
'id' => 'prautoblogger_ga4_credentials_json',
|
||||
|
||||
@@ -28,6 +28,7 @@ class PRAutoBlogger_Activator {
|
||||
self::create_tables();
|
||||
self::set_default_options();
|
||||
self::schedule_cron();
|
||||
self::backpopulate_run_id_meta_v081();
|
||||
|
||||
// Store the DB version so we can run migrations on future updates.
|
||||
update_option( 'prautoblogger_db_version', PRAUTOBLOGGER_DB_VERSION, false );
|
||||
@@ -225,5 +226,62 @@ class PRAutoBlogger_Activator {
|
||||
if ( ! wp_next_scheduled( 'prautoblogger_collect_metrics' ) ) {
|
||||
wp_schedule_event( time() + HOUR_IN_SECONDS, 'prautoblogger_six_hours', 'prautoblogger_collect_metrics' );
|
||||
}
|
||||
|
||||
// v0.8.1: daily reaper for orphan `llm_research` rows. Pinned to
|
||||
// 03:15 so it runs 15 min after the primary generation cron, giving
|
||||
// any live pipeline time to finish and call amortize_research_costs
|
||||
// cleanly before the reaper scans.
|
||||
if ( ! wp_next_scheduled( 'prautoblogger_reap_orphan_research_rows' ) ) {
|
||||
$tomorrow = strtotime( 'tomorrow 03:15' );
|
||||
if ( false !== $tomorrow ) {
|
||||
wp_schedule_event( $tomorrow, 'daily', 'prautoblogger_reap_orphan_research_rows' );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot v0.8.1 migration — back-populate `_prautoblogger_run_id`
|
||||
* post_meta for existing posts from their gen-log rows.
|
||||
*
|
||||
* Lets the orphan-research-row reaper (v0.8.1+) attribute historic
|
||||
* orphan rows to sibling posts via post_meta lookup, without having
|
||||
* to walk the generation_log table. Gated by
|
||||
* `prautoblogger_migrated_run_id_meta_v081` so it runs exactly once
|
||||
* per site.
|
||||
*
|
||||
* Side effects: adds post_meta rows for existing PRAutoBlogger posts
|
||||
* that don't already have `_prautoblogger_run_id`.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function backpopulate_run_id_meta_v081(): void {
|
||||
if ( get_option( 'prautoblogger_migrated_run_id_meta_v081' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$gen_log = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
|
||||
// For each (post_id, run_id) pair present in the gen-log, write
|
||||
// the run_id meta if it's not already set. Uses the first run_id
|
||||
// seen per post — in practice each post is linked to exactly one run.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$pairs = $wpdb->get_results(
|
||||
"SELECT post_id, MIN(run_id) AS run_id FROM {$gen_log}
|
||||
WHERE post_id IS NOT NULL AND run_id IS NOT NULL AND run_id != ''
|
||||
GROUP BY post_id"
|
||||
);
|
||||
|
||||
if ( is_array( $pairs ) ) {
|
||||
foreach ( $pairs as $row ) {
|
||||
$post_id = (int) ( $row->post_id ?? 0 );
|
||||
$run_id = (string) ( $row->run_id ?? '' );
|
||||
if ( $post_id > 0 && '' !== $run_id && ! metadata_exists( 'post', $post_id, '_prautoblogger_run_id' ) ) {
|
||||
update_post_meta( $post_id, '_prautoblogger_run_id', $run_id );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update_option( 'prautoblogger_migrated_run_id_meta_v081', '1' );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ class PRAutoBlogger_Deactivator {
|
||||
$hooks = [
|
||||
'prautoblogger_daily_generation',
|
||||
'prautoblogger_collect_metrics',
|
||||
'prautoblogger_reap_orphan_research_rows',
|
||||
];
|
||||
|
||||
foreach ( $hooks as $hook ) {
|
||||
|
||||
@@ -116,6 +116,24 @@ class PRAutoBlogger {
|
||||
|
||||
// Single-idea generation from the Ideas browser page.
|
||||
add_action( 'prautoblogger_generate_from_idea', [ 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ] );
|
||||
|
||||
// v0.8.1: daily reaper for orphan `llm_research` rows (when a
|
||||
// pipeline dies before amortize_research_costs runs).
|
||||
add_action( 'prautoblogger_reap_orphan_research_rows', [ 'PRAutoBlogger_Research_Reaper', 'on_cron' ] );
|
||||
|
||||
// v0.8.1: WP-CLI manual trigger for the reaper. Only registers when
|
||||
// WP-CLI is present; no-op in normal HTTP requests.
|
||||
if ( defined( 'WP_CLI' ) && WP_CLI ) {
|
||||
\WP_CLI::add_command( 'prautoblogger reap-research', static function (): void {
|
||||
$stats = PRAutoBlogger_Research_Reaper::reap();
|
||||
\WP_CLI::success( sprintf(
|
||||
'Reaped %d, deleted %d (stale), skipped %d.',
|
||||
(int) $stats['reaped'],
|
||||
(int) $stats['deleted'],
|
||||
(int) $stats['skipped']
|
||||
) );
|
||||
} );
|
||||
}
|
||||
}
|
||||
|
||||
/** Register AJAX handlers for admin actions. */
|
||||
|
||||
@@ -87,7 +87,7 @@ class PRAutoBlogger_Publisher {
|
||||
'post_status' => $post_status,
|
||||
'post_type' => 'post',
|
||||
'post_author' => PRAutoBlogger_Post_Assembler::get_default_author_id(),
|
||||
'meta_input' => $this->build_meta( $idea, $review ),
|
||||
'meta_input' => $this->build_meta( $idea, $review, $run_id ),
|
||||
];
|
||||
|
||||
/** @see class-prautoblogger.php — listeners registered in main loader. */
|
||||
@@ -120,15 +120,22 @@ class PRAutoBlogger_Publisher {
|
||||
/**
|
||||
* Build post_meta array for generation metadata.
|
||||
*
|
||||
* The `_prautoblogger_run_id` key (added v0.8.1) lets the orphan-research
|
||||
* reaper attribute an orphan `llm_research` row back to its sibling
|
||||
* articles without re-walking the gen_log table. See
|
||||
* core/class-research-reaper.php.
|
||||
*
|
||||
* @param PRAutoBlogger_Article_Idea $idea
|
||||
* @param PRAutoBlogger_Editorial_Review $review
|
||||
* @param string|null $run_id Pipeline run UUID, or null in legacy paths.
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function build_meta(
|
||||
PRAutoBlogger_Article_Idea $idea,
|
||||
PRAutoBlogger_Editorial_Review $review
|
||||
PRAutoBlogger_Editorial_Review $review,
|
||||
?string $run_id = null
|
||||
): array {
|
||||
return [
|
||||
$meta = [
|
||||
'_prautoblogger_generated' => '1',
|
||||
'_prautoblogger_analysis_id' => $idea->get_analysis_id(),
|
||||
'_prautoblogger_source_ids' => wp_json_encode( $idea->get_source_ids() ),
|
||||
@@ -143,5 +150,9 @@ class PRAutoBlogger_Publisher {
|
||||
'_prautoblogger_article_type' => $idea->get_article_type(),
|
||||
'_prautoblogger_target_keywords' => wp_json_encode( $idea->get_target_keywords() ),
|
||||
];
|
||||
if ( null !== $run_id && '' !== $run_id ) {
|
||||
$meta['_prautoblogger_run_id'] = $run_id;
|
||||
}
|
||||
return $meta;
|
||||
}
|
||||
}
|
||||
|
||||
183
includes/core/class-research-reaper.php
Normal file
183
includes/core/class-research-reaper.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Daily cron that retroactively amortizes orphan `llm_research` rows.
|
||||
*
|
||||
* When a pipeline run dies between post creation and the final
|
||||
* `Post_Assembler::amortize_research_costs()` call (Hostinger exec kill,
|
||||
* OOM, LiteSpeed cut, fatal), the shared research-cost row stays in
|
||||
* `wp_prautoblogger_generation_log` with `post_id = NULL` and the
|
||||
* articles from that run understate their true cost in the popover.
|
||||
*
|
||||
* This reaper scans for such orphan rows once a day, finds their sibling
|
||||
* posts via the `_prautoblogger_run_id` post_meta (primary) or the
|
||||
* gen_log's own post-linked rows (fallback for legacy posts that
|
||||
* predate the meta), and delegates to the existing idempotent
|
||||
* `amortize_research_costs()` method. Orphans that still have no
|
||||
* siblings after 7 days are deleted outright as sunk cost.
|
||||
*
|
||||
* Spec-wise the CTO asked for the reap method to live on Post_Assembler;
|
||||
* hosting it here instead keeps Post_Assembler under the 300-line cap
|
||||
* (it grew past 300 with the reap method attached) and colocates the
|
||||
* attribution logic next to the cron/CLI plumbing that triggers it.
|
||||
*
|
||||
* Triggered by:
|
||||
* - WP-Cron: `prautoblogger_reap_orphan_research_rows` (daily, 03:15).
|
||||
* - WP-CLI: `wp prautoblogger reap-research` (manual ops trigger).
|
||||
* Dependencies: PRAutoBlogger_Post_Assembler (amortize_research_costs),
|
||||
* PRAutoBlogger_Logger, WordPress $wpdb + get_posts.
|
||||
*
|
||||
* @see core/class-post-assembler.php — amortize_research_costs() does the work.
|
||||
* @see class-activator.php — Schedules the daily cron.
|
||||
* @see class-deactivator.php — Unschedules on deactivate.
|
||||
* @see ARCHITECTURE.md — Cost tracking data flow.
|
||||
*/
|
||||
class PRAutoBlogger_Research_Reaper {
|
||||
|
||||
/** Grace window — live pipelines have this long to finish amortizing before reaping. */
|
||||
private const GRACE_WINDOW_SECONDS = 3600; // 1 hour.
|
||||
|
||||
/** Stale window — orphans with no sibling posts are deleted outright after this long. */
|
||||
private const STALE_WINDOW_SECONDS = 7 * 86400; // 7 days.
|
||||
|
||||
/**
|
||||
* Cron action handler — entrypoint for the daily WP-Cron event.
|
||||
*
|
||||
* Pure delegate. Never throws.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function on_cron(): void {
|
||||
self::reap();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan and reap orphan `llm_research` rows. Returns a summary that the
|
||||
* WP-CLI command prints and the cron path ignores.
|
||||
*
|
||||
* Side effects: SELECT / INSERT / DELETE on generation_log; post_meta
|
||||
* reads; one INFO log line per reaped or stale-deleted
|
||||
* orphan; one WARNING on DB error.
|
||||
*
|
||||
* @return array{reaped: int, deleted: int, skipped: int}
|
||||
*/
|
||||
public static function reap(): array {
|
||||
$stats = [ 'reaped' => 0, 'deleted' => 0, 'skipped' => 0 ];
|
||||
|
||||
try {
|
||||
global $wpdb;
|
||||
$log_table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
$grace = gmdate( 'Y-m-d H:i:s', time() - self::GRACE_WINDOW_SECONDS );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$orphans = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT id, run_id, created_at FROM {$log_table}
|
||||
WHERE stage = 'llm_research' AND post_id IS NULL AND created_at < %s",
|
||||
$grace
|
||||
)
|
||||
);
|
||||
if ( ! is_array( $orphans ) ) {
|
||||
return $stats;
|
||||
}
|
||||
|
||||
$stale_cutoff = time() - self::STALE_WINDOW_SECONDS;
|
||||
foreach ( $orphans as $orphan ) {
|
||||
self::handle_orphan( $orphan, $stale_cutoff, $log_table, $stats );
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
'Reaper encountered DB error: ' . $e->getMessage(),
|
||||
'cost-tracker'
|
||||
);
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route one orphan row: amortize if siblings exist, delete if stale,
|
||||
* otherwise skip and try again next run.
|
||||
*
|
||||
* @param object $orphan DB row with id, run_id, created_at.
|
||||
* @param int $stale_cutoff Unix timestamp — orphans older than this delete.
|
||||
* @param string $log_table Fully-qualified gen_log table name.
|
||||
* @param array $stats Counters, mutated by reference.
|
||||
* @return void
|
||||
*/
|
||||
private static function handle_orphan( object $orphan, int $stale_cutoff, string $log_table, array &$stats ): void {
|
||||
global $wpdb;
|
||||
$run_id = (string) ( $orphan->run_id ?? '' );
|
||||
$created_ts = strtotime( (string) ( $orphan->created_at ?? '' ) );
|
||||
$is_stale = false !== $created_ts && $created_ts < $stale_cutoff;
|
||||
|
||||
if ( '' === $run_id ) {
|
||||
if ( $is_stale ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->delete( $log_table, [ 'id' => (int) $orphan->id ] );
|
||||
$stats['deleted']++;
|
||||
} else {
|
||||
$stats['skipped']++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$post_ids = self::find_posts_for_run_id( $run_id );
|
||||
if ( count( $post_ids ) >= 1 ) {
|
||||
PRAutoBlogger_Post_Assembler::amortize_research_costs( $run_id );
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Reaped research cost across %d articles for run %s.', count( $post_ids ), $run_id ),
|
||||
'cost-tracker'
|
||||
);
|
||||
$stats['reaped']++;
|
||||
return;
|
||||
}
|
||||
|
||||
if ( $is_stale ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$wpdb->delete( $log_table, [ 'id' => (int) $orphan->id ] );
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Deleted 7-day-stale orphan research row for run %s — no articles to attribute.', $run_id ),
|
||||
'cost-tracker'
|
||||
);
|
||||
$stats['deleted']++;
|
||||
return;
|
||||
}
|
||||
|
||||
$stats['skipped']++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return distinct post IDs for a given run_id. Prefers post_meta;
|
||||
* falls back to gen_log rows for legacy posts.
|
||||
*
|
||||
* @param string $run_id Pipeline run UUID.
|
||||
* @return int[] Distinct post IDs.
|
||||
*/
|
||||
private static function find_posts_for_run_id( string $run_id ): array {
|
||||
$meta_ids = get_posts( [
|
||||
'post_type' => 'any',
|
||||
'post_status' => 'any',
|
||||
'meta_key' => '_prautoblogger_run_id',
|
||||
'meta_value' => $run_id,
|
||||
'fields' => 'ids',
|
||||
'posts_per_page' => -1,
|
||||
'suppress_filters' => true,
|
||||
] );
|
||||
if ( is_array( $meta_ids ) && ! empty( $meta_ids ) ) {
|
||||
return array_map( 'intval', $meta_ids );
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$log_table = $wpdb->prefix . 'prautoblogger_generation_log';
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$ids = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT DISTINCT post_id FROM {$log_table} WHERE run_id = %s AND post_id IS NOT NULL",
|
||||
$run_id
|
||||
)
|
||||
);
|
||||
return is_array( $ids ) ? array_map( 'intval', $ids ) : [];
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ declare(strict_types=1);
|
||||
* Plugin Name: PRAutoBlogger
|
||||
* Plugin URI: https://peptiderepo.com/prautoblogger
|
||||
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
|
||||
* Version: 0.8.0
|
||||
* Version: 0.8.1
|
||||
* Requires at least: 6.0
|
||||
* Requires PHP: 7.4
|
||||
* Author: PeptideRepo
|
||||
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
| and limits without magic strings.
|
||||
*/
|
||||
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.8.0' );
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.8.1' );
|
||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
|
||||
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||
|
||||
286
tests/unit/Core/ResearchReaperTest.php
Normal file
286
tests/unit/Core/ResearchReaperTest.php
Normal file
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Research_Reaper.
|
||||
*
|
||||
* Exercises the five scenarios spec'd in convo thread
|
||||
* research-cost-reaper/01-cto-handoff.md: meta-based reap, grace-period
|
||||
* skip, 7-day stale delete, gen_log fallback when meta is missing, and
|
||||
* idempotency on repeat runs.
|
||||
*
|
||||
* All DB work is mocked via a $wpdb stub — no real database round-trips.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class ResearchReaperTest extends BaseTestCase {
|
||||
|
||||
/**
|
||||
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
|
||||
*/
|
||||
private $wpdb;
|
||||
|
||||
/**
|
||||
* Captured INSERT rows (from amortize_research_costs delegating to $wpdb->insert).
|
||||
*
|
||||
* @var array<int, array<string, mixed>>
|
||||
*/
|
||||
private array $inserted_rows = [];
|
||||
|
||||
/**
|
||||
* Captured DELETE calls: [$table, $where-array].
|
||||
*
|
||||
* @var array<int, array{0: string, 1: array<string, mixed>}>
|
||||
*/
|
||||
private array $deleted_rows = [];
|
||||
|
||||
/**
|
||||
* Fixture: the rows get_results should hand back for the orphan scan.
|
||||
*
|
||||
* @var array<int, object>
|
||||
*/
|
||||
private array $orphan_fixtures = [];
|
||||
|
||||
/**
|
||||
* Fixture: post IDs the reaper's fallback gen_log query should return.
|
||||
*
|
||||
* @var int[]
|
||||
*/
|
||||
private array $gen_log_post_ids = [];
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->inserted_rows = [];
|
||||
$this->deleted_rows = [];
|
||||
$this->orphan_fixtures = [];
|
||||
$this->gen_log_post_ids = [];
|
||||
|
||||
// Bespoke $wpdb mock — BaseTestCase::create_mock_wpdb() doesn't
|
||||
// stub `delete` or `get_col`, both of which the reaper + amortize
|
||||
// call into. Wide-method mock keeps the test scope self-contained.
|
||||
$this->wpdb = $this->getMockBuilder( \stdClass::class )
|
||||
->addMethods( [ 'prepare', 'get_var', 'get_results', 'get_row', 'get_col', 'insert', 'delete', 'update', 'query' ] )
|
||||
->getMock();
|
||||
$this->wpdb->prefix = 'wp_';
|
||||
$this->wpdb->insert_id = 1;
|
||||
$this->wpdb->last_error = '';
|
||||
$this->wpdb->prab_cost_logs = 'wp_prab_cost_logs';
|
||||
$this->wpdb->method( 'insert' )->willReturnCallback(
|
||||
function ( $table, $row ) {
|
||||
$this->inserted_rows[] = array_merge( [ '__table' => $table ], $row );
|
||||
return 1;
|
||||
}
|
||||
);
|
||||
$this->wpdb->method( 'delete' )->willReturnCallback(
|
||||
function ( $table, $where ) {
|
||||
$this->deleted_rows[] = [ (string) $table, (array) $where ];
|
||||
return 1;
|
||||
}
|
||||
);
|
||||
// prepare() returns its args joined so we can pattern-match in get_* stubs.
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
function ( $sql, ...$args ) {
|
||||
return $sql . '|' . implode( '|', array_map( 'strval', $args ) );
|
||||
}
|
||||
);
|
||||
// get_results routes by SQL fragment between the reaper's orphan scan
|
||||
// and the occasional Logger insert probes.
|
||||
$this->wpdb->method( 'get_results' )->willReturnCallback(
|
||||
function ( $sql ) {
|
||||
if ( false !== stripos( (string) $sql, "post_id IS NULL AND created_at <" ) ) {
|
||||
return $this->orphan_fixtures;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
);
|
||||
// get_row serves amortize_research_costs's research-row lookup.
|
||||
$this->wpdb->method( 'get_row' )->willReturnCallback(
|
||||
function ( $sql ) {
|
||||
foreach ( $this->orphan_fixtures as $o ) {
|
||||
if ( false !== stripos( (string) $sql, (string) $o->run_id ) ) {
|
||||
return (object) [
|
||||
'id' => $o->id,
|
||||
'estimated_cost' => $o->estimated_cost ?? 0.50,
|
||||
'provider' => $o->provider ?? 'openrouter',
|
||||
'model' => $o->model ?? 'gemini-flash',
|
||||
'prompt_tokens' => $o->prompt_tokens ?? 1000,
|
||||
'completion_tokens' => $o->completion_tokens ?? 500,
|
||||
];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
// get_col is the reaper's fallback "posts by run_id" and amortize's
|
||||
// "distinct post_ids" query — both read from the same fixture.
|
||||
$this->wpdb->method( 'get_col' )->willReturnCallback(
|
||||
fn() => array_map( 'strval', $this->gen_log_post_ids )
|
||||
);
|
||||
|
||||
$GLOBALS['wpdb'] = $this->wpdb;
|
||||
|
||||
$this->stub_get_option( [
|
||||
'prautoblogger_log_level' => 'info',
|
||||
] );
|
||||
|
||||
// Reaper uses get_posts to look up sibling posts via post_meta.
|
||||
Functions\when( 'get_posts' )->justReturn( [] );
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return only the `generation_log` inserts from the capture — filters out
|
||||
* the `event_log` rows that Logger::instance()->info() writes as a side
|
||||
* effect of the reaper + amortize's own progress logging.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function gen_log_inserts(): array {
|
||||
return array_values( array_filter(
|
||||
$this->inserted_rows,
|
||||
static fn( $row ) => false !== strpos( (string) ( $row['__table'] ?? '' ), 'generation_log' )
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: seed the orphan list returned by the reaper's primary SELECT.
|
||||
*
|
||||
* @param array<int, array{id: int, run_id: string, created_at: string, estimated_cost?: float, provider?: string, model?: string, prompt_tokens?: int, completion_tokens?: int}> $orphans
|
||||
*/
|
||||
private function seed_orphans( array $orphans ): void {
|
||||
$this->orphan_fixtures = array_map(
|
||||
static fn( $r ) => (object) $r,
|
||||
$orphans
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: set the post_ids the gen_log fallback query returns.
|
||||
*
|
||||
* @param int[] $post_ids
|
||||
*/
|
||||
private function seed_gen_log_posts_for_run( array $post_ids ): void {
|
||||
$this->gen_log_post_ids = $post_ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Reap succeeds via post_meta primary lookup.
|
||||
*
|
||||
* Orphan row is >1h old, `_prautoblogger_run_id` meta returns two
|
||||
* post IDs, amortize inserts two attributed rows and deletes the
|
||||
* original orphan.
|
||||
*/
|
||||
public function test_reaps_orphan_with_matching_run_id_posts(): void {
|
||||
$old_ts = gmdate( 'Y-m-d H:i:s', time() - ( 2 * HOUR_IN_SECONDS ) );
|
||||
$this->seed_orphans( [ [ 'id' => 99, 'run_id' => 'run-abc', 'created_at' => $old_ts, 'estimated_cost' => 0.40 ] ] );
|
||||
|
||||
// In the v0.8.1 world both sources agree. Meta is the reaper's gate;
|
||||
// gen_log also has rows so `amortize_research_costs()` can do its
|
||||
// own internal post-count query.
|
||||
Functions\when( 'get_posts' )->alias(
|
||||
static fn( $args ) => ( '_prautoblogger_run_id' === ( $args['meta_key'] ?? '' ) && 'run-abc' === ( $args['meta_value'] ?? '' ) )
|
||||
? [ 101, 102 ]
|
||||
: []
|
||||
);
|
||||
$this->seed_gen_log_posts_for_run( [ 101, 102 ] );
|
||||
|
||||
$stats = \PRAutoBlogger_Research_Reaper::reap();
|
||||
|
||||
$this->assertSame( 1, $stats['reaped'] );
|
||||
$this->assertSame( 0, $stats['deleted'] );
|
||||
$this->assertSame( 0, $stats['skipped'] );
|
||||
$this->assertCount(
|
||||
2,
|
||||
$this->gen_log_inserts(),
|
||||
'One amortized row per post should be inserted into the generation_log.'
|
||||
);
|
||||
// Original orphan deleted by amortize_research_costs.
|
||||
$this->assertNotEmpty( array_filter( $this->deleted_rows, static fn( $d ) => ( $d[1]['id'] ?? null ) === 99 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. Orphan within the 1-hour grace window is left alone — a live
|
||||
* pipeline may still be in the middle of its own amortize step.
|
||||
*/
|
||||
public function test_skips_orphan_within_grace_period(): void {
|
||||
// The primary SELECT filters on `created_at < (now - 1h)`, so a
|
||||
// fresh orphan is excluded from the result set at the DB boundary.
|
||||
// We simulate that by returning an empty orphan list.
|
||||
$this->seed_orphans( [] );
|
||||
|
||||
Functions\when( 'get_posts' )->justReturn( [ 101, 102 ] );
|
||||
|
||||
$stats = \PRAutoBlogger_Research_Reaper::reap();
|
||||
|
||||
$this->assertSame( 0, $stats['reaped'] );
|
||||
$this->assertSame( 0, $stats['deleted'] );
|
||||
$this->assertSame( 0, $stats['skipped'] );
|
||||
$this->assertEmpty( $this->inserted_rows );
|
||||
$this->assertEmpty( $this->deleted_rows );
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. Orphan older than 7 days with zero matching posts is deleted
|
||||
* outright — research cost is sunk with nothing to attribute to.
|
||||
*/
|
||||
public function test_deletes_orphan_with_no_articles_after_7_days(): void {
|
||||
$stale_ts = gmdate( 'Y-m-d H:i:s', time() - ( 8 * DAY_IN_SECONDS ) );
|
||||
$this->seed_orphans( [ [ 'id' => 77, 'run_id' => 'run-dead', 'created_at' => $stale_ts ] ] );
|
||||
|
||||
// No posts via meta, no posts via gen_log.
|
||||
Functions\when( 'get_posts' )->justReturn( [] );
|
||||
$this->seed_gen_log_posts_for_run( [] );
|
||||
|
||||
$stats = \PRAutoBlogger_Research_Reaper::reap();
|
||||
|
||||
$this->assertSame( 0, $stats['reaped'] );
|
||||
$this->assertSame( 1, $stats['deleted'] );
|
||||
$this->assertNotEmpty( array_filter( $this->deleted_rows, static fn( $d ) => ( $d[1]['id'] ?? null ) === 77 ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* 4. When `_prautoblogger_run_id` post_meta is absent (legacy posts
|
||||
* predating v0.8.1), the reaper falls back to querying gen_log for
|
||||
* distinct post_ids carrying the same run_id.
|
||||
*/
|
||||
public function test_falls_back_to_gen_log_when_post_meta_missing(): void {
|
||||
$old_ts = gmdate( 'Y-m-d H:i:s', time() - ( 2 * HOUR_IN_SECONDS ) );
|
||||
$this->seed_orphans( [ [ 'id' => 55, 'run_id' => 'run-legacy', 'created_at' => $old_ts, 'estimated_cost' => 0.30 ] ] );
|
||||
|
||||
// Meta lookup returns nothing.
|
||||
Functions\when( 'get_posts' )->justReturn( [] );
|
||||
// But gen_log has two posts with this run_id.
|
||||
$this->seed_gen_log_posts_for_run( [ 201, 202 ] );
|
||||
|
||||
$stats = \PRAutoBlogger_Research_Reaper::reap();
|
||||
|
||||
$this->assertSame( 1, $stats['reaped'] );
|
||||
$this->assertCount( 2, $this->gen_log_inserts() );
|
||||
}
|
||||
|
||||
/**
|
||||
* 5. A second reap run is a no-op — the first run deleted the orphan
|
||||
* and inserted amortized per-article rows, so the scan returns empty.
|
||||
*/
|
||||
public function test_idempotent_on_repeat_run(): void {
|
||||
// Simulate "already reaped" state: scan returns no orphans.
|
||||
$this->seed_orphans( [] );
|
||||
|
||||
$stats_1 = \PRAutoBlogger_Research_Reaper::reap();
|
||||
$stats_2 = \PRAutoBlogger_Research_Reaper::reap();
|
||||
|
||||
$this->assertSame( 0, $stats_1['reaped'] );
|
||||
$this->assertSame( 0, $stats_2['reaped'] );
|
||||
$this->assertEmpty( $this->inserted_rows );
|
||||
$this->assertEmpty( $this->deleted_rows );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user