Files
2meet-data-optimizer-hivepr…/includes/class-tmdo-listing-stats.php
T
wpdev 763f705ac1
Tests / PHP Lint (pull_request) Successful in 6s
Tests / Unit Tests (pull_request) Successful in 53s
Tests / Integration Tests (pull_request) Successful in 1m13s
fix(2meet-data-optimizer-hivepress-addon): 0.5.3 view-count flush keeps flat row and meta cache in sync
flush_views_to_postmeta() adds warm-zone views back with a direct UPDATE,
which the Hook Bus never sees: under dual_write/shadow_read the
hp_listing_core.hp_view_count column drifted after every hourly batch and
the (Redis) object cache kept the old count. After the UPDATE, drop the
post's meta cache and, while native is still the source of truth, rebuild
the entity's flat row with migrate_entity().
2026-10-07 10:37:57 +08:00

297 lines
9.8 KiB
PHP

<?php
/**
* Listing stats integration for Zone B view counting.
*
* @package TMDO
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Zone B/D integration — Listing Statistics & Archival.
*
* Zone B (Warm): Tracks per-listing page view counts with a 24-hour TTL.
* - Reads from wpdo_warm first; falls back to postmeta if no warm entry.
* - Increments on each front-end singular hp_listing page load (non-admin, non-bot).
* - Keeps postmeta clean by flushing accumulated counts via cron (wpdo_flush_views).
*
* Zone D (Archive): Archives hot-zone fields for expired listings.
* - Triggered by the existing wpdo_archive_sweep cron (daily).
* - Finds listings where hp_expired_time < now and expired > TMDO_ARCHIVE_DAYS ago.
* - Archives hp_price, hp_featured, hp_verified, hp_expired_time with gzip compression.
*/
class TMDO_Listing_Stats {
/** Meta key used for the warm-zone view counter. */
const VIEW_KEY = 'wpdo_views';
/** TTL for warm-zone view entries (24 hours). */
const VIEW_TTL = DAY_IN_SECONDS;
/** How many days after expiry before archiving hot fields. */
const ARCHIVE_DAYS = 30;
/** Hot-zone meta keys to archive for expired listings. */
const ARCHIVE_KEYS = array( 'hp_price', 'hp_featured', 'hp_verified', 'hp_expired_time' );
/**
* Register all hooks.
*/
public static function register_hooks(): void {
// Zone B: increment view count on front-end singular listing pages.
add_action( 'wp', array( __CLASS__, 'maybe_increment_view' ) );
// Zone B: flush accumulated warm-zone counts to postmeta (hourly).
add_action( 'wpdo_flush_views', array( __CLASS__, 'flush_views_to_postmeta' ) );
// Zone D: archive expired listing fields (daily cron via wpdo_archive_sweep).
add_action( 'wpdo_archive_sweep', array( __CLASS__, 'archive_expired_listings' ), 20 );
}
/**
* Schedule the flush cron if not already registered.
*
* Called from TMDO_Core::schedule_cron().
*/
public static function schedule_cron(): void {
if ( ! wp_next_scheduled( 'wpdo_flush_views' ) ) {
wp_schedule_event( time(), 'hourly', 'wpdo_flush_views' );
}
}
// ── Zone B: View Counter ─────────────────────────────────────────────────
/**
* Increment the warm-zone view counter for the current listing page.
*
* Only fires on front-end, singular hp_listing pages, for non-bot requests.
*/
public static function maybe_increment_view(): void {
if ( is_admin() || ! is_singular( 'hp_listing' ) ) {
return;
}
// Skip common bots by checking for empty or known bot user-agents.
$ua = sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) );
if ( empty( $ua ) || preg_match( '/bot|crawl|slurp|spider|mediapartners/i', $ua ) ) {
return;
}
$post_id = (int) get_the_ID();
if ( $post_id <= 0 ) {
return;
}
self::increment_view( $post_id );
}
/**
* Increment the warm-zone view count for a listing.
*
* @param int $post_id Listing post ID.
*/
public static function increment_view( int $post_id ): void {
TMDO_Zone_Warm::increment( $post_id, self::VIEW_KEY, 1, self::VIEW_TTL );
}
/**
* Read the view count for a listing.
*
* Returns warm-zone value if available; falls back to postmeta hp_view_count.
*
* @param int $post_id Listing post ID.
* @return int
*/
public static function get_view_count( int $post_id ): int {
$warm = TMDO_Zone_Warm::get( $post_id, self::VIEW_KEY );
if ( null !== $warm ) {
return (int) $warm;
}
return (int) get_post_meta( $post_id, 'hp_view_count', true );
}
/**
* Flush all warm-zone view counts to postmeta and reset warm entries.
*
* Called hourly by wpdo_flush_views cron.
*
* @return int Number of listings flushed.
*/
public static function flush_views_to_postmeta(): int {
global $wpdb;
$table = TMDO_Zone_Warm::table();
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT post_id, meta_value FROM `{$table}` WHERE meta_key = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
self::VIEW_KEY
),
ARRAY_A
);
if ( ! $rows ) {
return 0;
}
$flushed = 0;
foreach ( $rows as $row ) {
$post_id = (int) $row['post_id'];
$new_views = (int) $row['meta_value'];
// Atomic increment: avoids the read-modify-write race that could lose
// concurrent view increments arriving between our batch-read and the write.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- WPDO.AntiEAV.no-direct-postmeta-update: view-count sync, no meta API equivalent for atomic add.
$updated = $wpdb->query(
$wpdb->prepare(
"UPDATE `{$wpdb->postmeta}` SET meta_value = meta_value + %d WHERE post_id = %d AND meta_key = 'hp_view_count'",
$new_views,
$post_id
)
);
// Seed the key when it does not exist yet.
if ( ! $updated ) {
add_post_meta( $post_id, 'hp_view_count', $new_views, true );
} else {
self::resync_view_count( $post_id );
}
// Remove the warm entry after flush.
TMDO_Zone_Warm::delete( $post_id, self::VIEW_KEY );
++$flushed;
}
return $flushed;
}
/**
* The atomic UPDATE above bypasses the meta API: the object cache (persistent
* under Redis) keeps the old count, and the Hook Bus never sees the write, so in
* dual_write / shadow_read the flat column drifts by every flushed batch
* (drift-check diff on hp_view_count each night). Drop the cached meta and
* rebuild the entity's flat row from native — only while native is the source
* of truth (aeav_only keeps flat authoritative).
*
* @param int $post_id Listing post ID.
*/
private static function resync_view_count( int $post_id ): void {
wp_cache_delete( $post_id, 'post_meta' );
if ( ! class_exists( 'TMDO_Mode_Manager' ) || ! class_exists( 'TMDO_Entity_Registry' ) || ! class_exists( 'TMDO_Entity_Migration_Engine' ) ) {
return;
}
if ( ! TMDO_Mode_Manager::writes_to_flat( 'post' ) || ! TMDO_Mode_Manager::writes_to_eav( 'post' ) ) {
return;
}
// hp_view_count lives in more than one group (hp_listing_core, hp_request_core):
// get_field() without a post type returns whichever registered last. Rebuild only
// the groups that hold the key and apply to this post's type.
$type = (string) get_post_type( $post_id );
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
$types = TMDO_Entity_Registry::get_group_post_types( 'post', $group );
if ( ! in_array( 'hp_view_count', TMDO_Entity_Registry::get_group_keys( 'post', $group ), true ) || ( $types && ! in_array( $type, $types, true ) ) ) {
continue;
}
TMDO_Entity_Migration_Engine::migrate_entity( 'post', $group, $post_id );
if ( class_exists( 'TMDO_Cache_Orchestrator' ) ) {
TMDO_Cache_Orchestrator::invalidate( 'post', $post_id, $group );
}
}
}
// ── Zone D: Expired Listing Archival ─────────────────────────────────────
/**
* Archive hot-zone fields for listings that expired ARCHIVE_DAYS ago.
*
* Reads hp_expired_time from postmeta (or hot zone via Sync Bridge).
* Archives ARCHIVE_KEYS with gzip compression into wpdo_archive.
*
* @param int $limit Max listings to process per run.
* @return int Number of listing fields archived.
*/
public static function archive_expired_listings( int $limit = 200 ): int {
global $wpdb;
$cutoff = (int) ( time() - self::ARCHIVE_DAYS * DAY_IN_SECONDS );
// Find published listings with hp_expired_time before cutoff.
$listings = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT pm.post_id
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE pm.meta_key = 'hp_expired_time'
AND pm.meta_value > '0'
AND CAST(pm.meta_value AS UNSIGNED) < %d
AND p.post_type = 'hp_listing'
AND p.post_status IN ('publish','private')
LIMIT %d",
$cutoff,
$limit
)
);
if ( ! $listings ) {
return 0;
}
$listing_ids = array_map( 'intval', $listings );
$id_list = implode( ',', $listing_ids );
$key_list = implode( ',', array_fill( 0, count( self::ARCHIVE_KEYS ), '%s' ) );
// Batch-fetch all relevant postmeta rows in one query.
$meta_rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT meta_id, post_id, meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id IN ({$id_list}) AND meta_key IN ({$key_list})", // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $wpdb->postmeta is core. phpcs:ignore WPDO.AntiEAV.no-direct-postmeta-select -- Archive sweep: must read postmeta to migrate to Zone D.
...self::ARCHIVE_KEYS
),
ARRAY_A
);
// Build lookup: $meta_map[ post_id ][ meta_key ] = [ meta_id, meta_value ].
$meta_map = array();
foreach ( $meta_rows as $mr ) {
$meta_map[ (int) $mr['post_id'] ][ $mr['meta_key'] ] = array(
'meta_id' => (int) $mr['meta_id'],
'meta_value' => $mr['meta_value'],
);
}
$archived = 0;
foreach ( $listing_ids as $post_id ) {
foreach ( self::ARCHIVE_KEYS as $meta_key ) {
if ( ! isset( $meta_map[ $post_id ][ $meta_key ] ) ) {
continue;
}
$entry = $meta_map[ $post_id ][ $meta_key ];
$meta_value = $entry['meta_value'];
if ( '' === $meta_value || false === $meta_value ) {
continue;
}
TMDO_Zone_Archive::archive(
$post_id,
'hp_listing',
$meta_key,
(string) $meta_value,
$entry['meta_id'],
true // compress.
);
++$archived;
}
}
return $archived;
}
/**
* Get archive statistics for expired listings.
*
* @return array{total_rows: int, compressed_rows: int, post_types: array}
*/
public static function stats(): array {
return TMDO_Zone_Archive::stats();
}
}