| | |
| | | do_action(BASE.'activation'); |
| | | error_log('Action done!'); |
| | | error_log('Checking custom tables...'); |
| | | Queue::defineTables(); |
| | | CustomTable::ensureTables(); |
| | | error_log('Dashboard is setup: '.print_r(JVB()->dashboard(), true)); |
| | | |
| | |
| | | nav,nav ol,nav ul{--padding:0 1rem;--wrap:nowrap;font-family:var(--heading)}nav,nav a,nav li,nav ol,nav ul{display:flex;flex-direction:var(--dir,row);justify-content:var(--justify,flex-start);align-items:var(--align,center);gap:var(--gap,0);flex-wrap:var(--wrap,nowrap);height:var(--btn,3rem);max-width:100%;padding:0;margin:0}nav li{width:100%;--justify:center;max-inline-size:none;padding:0;list-style:none}nav a,nav button{--justify:center;width:100%;white-space:nowrap;text-transform:uppercase;border-radius:0;background-color:transparent;text-decoration:none}nav a{padding:var(--padding)}nav .toggle{aspect-ratio:1;border:1px solid var(--base);color:var(--contrast)}nav .current a,nav a.current,nav a:focus,nav a:focus:visited,nav button:focus{background-color:var(--action-0);color:var(--action-contrast)}.toggle .icon-caret-down{transform:rotate(0);transition:transform var(--trans-base)}.open>.row>.toggle .icon-caret-down,.open>.toggle .icon-caret-down{transform:rotate(900deg)}.has-submenu{position:relative}ul.submenu{--dir:column;height:max-content;position:absolute;top:100%;right:0;max-height:0;transform:scaleY(0);transform-origin:top;width:100%;min-width:max-content;background-color:rgba(var(--base-rgb),var(--op-3));border:2px solid rgba(var(--base-rgb),var(--op-3));transition:max-height var(--trans-base),transform var(--trans-base);box-shadow:var(--shdw-none);overflow:hidden}.submenu li{background-color:rgba(var(--base-rgb),var(--op-6));border:1px solid var(--base-50)}.submenu a{height:var(--chipchip)}.open>ul.submenu{transform:scaleY(1);max-height:1000%;box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.screen-reader-text{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}nav a:focus:not(:focus-visible){outline:0}nav a:focus-visible{outline:1px solid var(--action-0);outline-offset:1px}nav.always{--dir:column;--justify:flex-end;position:fixed;bottom:0;right:0;width:var(--btn);z-index:var(--z-10)}nav.always.open{width:100vw;height:100vh;padding-bottom:var(--btn_);background-color:rgba(var(--base-rgb),var(--op-6));backdrop-filter:blur(5px);z-index:var(--z-10)}nav.always>ul{z-index:1;--dir:column;--align:center;--justify:flex-start;--gap:0;height:100%;max-height:100%;position:relative;right:-300vw;width:100vw;padding:1rem 0 0;overflow:hidden auto;transition:right var(--trans-base)}nav.always.open>ul{right:0}nav.always li{--wrap:wrap;--dir:row;height:max-content;--justify:flex-start;background-color:rgba(var(--base-rgb),var(--op-6))}nav.always a{padding:1rem;--justify:center;max-width:calc(100% - var(--btn))}nav.always .has-submenu>a{z-index:var(--z-3)}nav.always .has-submenu>button{width:var(--btn)}nav.always .submenu{position:relative;padding-right:4rem;top:0;border:1px solid var(--action-0);background-color:rgba(var(--contrast-rgb),var(--op-1))}nav.always .submenu li{background-color:rgba(var(--base-rgb),var(--op-3))}nav.always>.toggle{position:fixed;bottom:0;right:0;width:var(--btn);height:var(--btn);background-color:var(--base);color:var(--contrast);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);transition:width var(--trans-base)}nav.always>.toggle:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.always.open>.toggle{width:100%;background-color:rgba(var(--base-rgb),var(--op-6));backdrop-filter:blur(5px);z-index:1000000}nav.always.open>.toggle:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.always button .icon-x,nav.always.open button .icon-list{display:none}nav.always button .icon-list,nav.always.open button .icon-x{display:block;--w:32px}@media (min-width:768px){nav.always>ul{padding-top:var(--btn)}}nav#breadcrumbs{height:max-content;--wrap:wrap;--gap:0;width:max-content;max-width:var(--full);position:absolute;background-color:rgba(var(--base-rgb),var(--op-4));font-size:var(--txt-x-small);padding:.125em;z-index:var(--z-5)}nav#breadcrumbs ol{height:max-content;--wrap:wrap}nav#breadcrumbs li{width:max-content;height:var(--chip);--wrap:nowrap}nav#breadcrumbs li::after{content:'/';color:var(--contrast-200);padding:0 .25rem}nav#breadcrumbs li:last-of-type::after{display:none}nav#breadcrumbs a{height:var(--chip)}nav#breadcrumbs a,nav#breadcrumbs span{padding:0 .125rem;color:var(--contrast);text-transform:none}nav#breadcrumbs a:focus{background-color:transparent;color:var(--action-0)}nav.fixed{position:fixed;left:0;box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-9)}nav.fixed.bottom{bottom:0;width:calc(100% - var(--btn))}nav.fixed ul{--justify:space-between;width:100%;background-color:var(--base);padding:0 .25rem}nav.fixed li{flex:1}nav.fixed a{--gap:1rem;--w:var(--chip_);color:var(--contrast);font-size:var(--txt-x-small)}@media (min-width:768px){nav.fixed a{font-size:var(--txt-medium)}}nav.on-this-page{--justify:space-between;position:fixed;bottom:0;left:0;width:100vw;z-index:var(--z-5);background-color:rgba(var(--base-rgb),var(--op-45));max-width:none}body:has(nav.fixed) nav.on-this-page{bottom:var(--btn)}nav.on-this-page button{order:3;padding:0 1rem;width:max-content;aspect-ratio:unset}nav.on-this-page.open button{order:0}nav.on-this-page ul{width:100%;--gap:0}nav.on-this-page a{padding:0}nav.on-this-page .active a{background-color:rgba(var(--base-rgb),var(--op-6));color:var(--action-contrast)}nav.on-this-page #back-to-top span{display:none}nav.on-this-page .active a{background-color:var(--action-0);color:var(--action-contrast)}nav.letters,nav.letters a,nav.letters li,nav.letters ul{height:var(--chip)}nav.letters li{max-width:calc(7.69% - 2px)}nav.letters ul{--wrap:wrap}@media (min-width:768px){nav.letters,nav.letters ul{height:var(--chip)}nav.letters ul{--wrap:nowrap}nav.letters li{max-width:none}nav.letters a{padding:.25rem .66rem}}nav.index{--justify:space-between;--padding:0;background-color:rgba(var(--base-rgb),var(--op-6))}nav.index ul{width:100%}nav.index li{flex-shrink:0;transform:scaleX(0);max-width:0;overflow:hidden}nav.index li.active,nav.index li.adj{transform:scaleX(1);width:calc(100% - var(--btn_));flex-shrink:1;max-width:none}nav.index li:first-of-type{flex-shrink:1;transform:scaleX(1);order:9999;width:var(--btn);height:var(--btn);max-width:none}@media (max-width:767px){.index li.adj{transform:scaleX(0);max-width:0}}nav.index a{border-bottom:4px solid transparent}nav.index .active a{border-color:var(--action-0);color:var(--contrast)}nav.index.open{--dir:column-reverse;height:var(--maxHeight);width:100%;--align:flex-end;background-color:rgba(var(--base-rgb),var(--op-6));backdrop-filter:blur(5px);z-index:var(--z-10)}nav.index.open li{width:100%;height:var(--btn);max-width:100%!important;transform:scaleX(1);overflow:visible}nav.index.open a{--justify:flex-end;padding:0 2rem 0 0;background-color:transparent}nav.condensed,nav.condensed a,nav.condensed li,nav.condensed ul{height:max-content;width:max-content;--wrap:wrap;min-height:var(--chip)}nav.condensed{--gap:0 .25rem;width:100%}nav.condensed li+li::before{content:'·';padding:0 .25em}nav.condensed a{font-size:var(--txt-x-small);padding:0 .25rem;text-transform:none;border-bottom:2px solid transparent}nav .current a,nav a.current,nav a:focus,nav a:focus:visited,nav button:focus{background-color:transparent;color:var(--contrast);border-color:var(--action-0)}ul.socials{--dir:row;height:max-content;--gap:.5rem;--justify:stretch;--wrap:nowrap;overflow:auto hidden;touch-action:pan-x}.always ul.socials{width:100%}ul.socials a{padding:.5rem;max-width:none}ul.socials .icon{margin:0}nav.tabs{padding-bottom:2px;touch-action:pan-x pan-y;--wrap:nowrap;overflow:auto hidden}nav.tabs button.active{cursor:default}nav.tabs button{font-family:var(--heading);font-size:var(--txt-x-small);border-bottom:4px solid transparent}nav.tabs button.active,nav.tabs button.active:hover{background-color:var(--action-0);color:var(--action-contrast);border-color:var(--base)}.tab-content nav.tabs button{height:var(--chip_);padding:.25rem .75rem;min-height:0}.tab-content h2{margin:0 0 .5rem}.tab-content nav.tabs{height:max-content;background-color:var(--base);--gap:0}.tab-content .tab-content nav.tabs{background-color:var(--base-100)}.tab-content .tab-content .tab-content nav.tabs{background-color:var(--base-200)}.tab-content nav.tabs button.active h2{color:var(--action-0)}nav.menu a{padding:.5rem .66rem}nav.share{height:max-content;margin:1rem 0}nav.share ul{overflow:visible}nav.share h4{display:inline-block;width:max-content;margin:.25rem .5rem .25rem 0;font-size:var(--txt-x-small)}.wp-site-blocks>header,body>header{--dir:row;--justify:space-between;position:sticky;top:0;left:0;right:0;height:var(--btn);width:100vw;display:flex;align-items:center;padding:0 .5rem;background-color:var(--base);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-9)}.wp-site-blocks>header img{width:var(--btn)}.dashboard-nav{width:100%}nav.filters{--dir:row;overflow:auto hidden}nav.filters .filter{width:auto;padding:.25rem .75rem}nav.term-navigation:has([hidden]){display:none} |
| | | nav,nav ol,nav ul{--padding:0 1rem;--wrap:nowrap;font-family:var(--heading)}nav,nav a,nav li,nav ol,nav ul{display:flex;flex-direction:var(--dir,row);justify-content:var(--justify,flex-start);align-items:var(--align,center);gap:var(--gap,0);flex-wrap:var(--wrap,nowrap);height:var(--btn,3rem);max-width:100%;padding:0;margin:0}nav li{width:100%;--justify:center;max-inline-size:none;padding:0;list-style:none}nav a,nav button{--justify:center;width:100%;white-space:nowrap;text-transform:uppercase;border-radius:0;background-color:transparent;text-decoration:none}nav a{padding:var(--padding)}nav .toggle{aspect-ratio:1;border:1px solid var(--base);color:var(--contrast)}nav .current a,nav a.current,nav a:focus,nav a:focus:visited,nav button:focus{background-color:var(--action-0);color:var(--action-contrast)}.toggle .icon-caret-down{transform:rotate(0);transition:transform var(--trans-base)}.open>.row>.toggle .icon-caret-down,.open>.toggle .icon-caret-down{transform:rotate(900deg)}.has-submenu{position:relative}ul.submenu{--dir:column;height:max-content;position:absolute;top:100%;right:0;max-height:0;transform:scaleY(0);transform-origin:top;width:100%;min-width:max-content;background-color:rgba(var(--base-rgb),var(--op-3));border:2px solid rgba(var(--base-rgb),var(--op-3));transition:max-height var(--trans-base),transform var(--trans-base);box-shadow:var(--shdw-none);overflow:hidden}.submenu li{background-color:rgba(var(--base-rgb),var(--op-6));border:1px solid var(--base-50)}.submenu a{height:var(--chipchip)}.open>ul.submenu{transform:scaleY(1);max-height:1000%;box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw)}.screen-reader-text{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}nav a:focus:not(:focus-visible){outline:0}nav a:focus-visible{outline:1px solid var(--action-0);outline-offset:1px}nav.always{--dir:column;--justify:flex-end;position:fixed;bottom:0;right:0;width:var(--btn);z-index:var(--z-10)}nav.always.open{width:100vw;height:100vh;padding-bottom:var(--btn_);background-color:rgba(var(--base-rgb),var(--op-6));backdrop-filter:blur(5px);z-index:var(--z-10)}nav.always>ul{z-index:1;--dir:column;--align:center;--justify:flex-start;--gap:0;height:100%;max-height:100%;position:relative;right:-300vw;width:100vw;padding:var(--btn) 0 0;overflow:hidden auto;transition:right var(--trans-base)}nav.always.open>ul{right:0}nav.always li{--wrap:wrap;--dir:row;height:max-content;--justify:flex-start;background-color:rgba(var(--base-rgb),var(--op-6))}nav.always a{padding:1rem;--justify:center;max-width:calc(100% - var(--btn))}nav.always .has-submenu>a{z-index:var(--z-3)}nav.always .has-submenu>button{width:var(--btn)}nav.always .submenu{position:relative;padding-right:4rem;top:0;border:1px solid var(--action-0);background-color:rgba(var(--contrast-rgb),var(--op-1))}nav.always .submenu li{background-color:rgba(var(--base-rgb),var(--op-3))}nav.always>.toggle{position:fixed;bottom:0;right:0;width:var(--btn);height:var(--btn);background-color:var(--base);color:var(--contrast);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);transition:width var(--trans-base)}nav.always>.toggle:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.always.open>.toggle{width:100%;background-color:rgba(var(--base-rgb),var(--op-6));backdrop-filter:blur(5px);z-index:1000000}nav.always.open>.toggle:hover{background-color:var(--action-0);color:var(--action-contrast)}nav.always button .icon-x,nav.always.open button .icon-list{display:none}nav.always button .icon-list,nav.always.open button .icon-x{display:block;--w:32px}nav#breadcrumbs{height:max-content;--wrap:wrap;--gap:0;width:max-content;max-width:var(--full);position:absolute;background-color:rgba(var(--base-rgb),var(--op-4));font-size:var(--txt-x-small);padding:.125em;z-index:var(--z-5)}nav#breadcrumbs ol{height:max-content;--wrap:wrap}nav#breadcrumbs li{width:max-content;height:var(--chip);--wrap:nowrap}nav#breadcrumbs li::after{content:'/';color:var(--contrast-200);padding:0 .25rem}nav#breadcrumbs li:last-of-type::after{display:none}nav#breadcrumbs a{height:var(--chip)}nav#breadcrumbs a,nav#breadcrumbs span{padding:0 .125rem;color:var(--contrast);text-transform:none}nav#breadcrumbs a:focus{background-color:transparent;color:var(--action-0)}nav.fixed{position:fixed;left:0;box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-9)}nav.fixed.bottom{bottom:0;width:calc(100% - var(--btn))}nav.fixed ul{--justify:space-between;width:100%;background-color:var(--base);padding:0 .25rem}nav.always.fixed>ul{padding-top:var(--btn)}nav.fixed li{flex:1}nav.fixed a{--gap:1rem;--w:var(--chip_);color:var(--contrast);font-size:var(--txt-x-small)}@media (min-width:768px){nav.fixed a{font-size:var(--txt-medium)}}nav.on-this-page{--justify:space-between;position:fixed;bottom:0;left:0;width:100vw;z-index:var(--z-5);background-color:rgba(var(--base-rgb),var(--op-45));max-width:none}body:has(nav.fixed) nav.on-this-page{bottom:var(--btn)}nav.on-this-page button{order:3;padding:0 1rem;width:max-content;aspect-ratio:unset}nav.on-this-page.open button{order:0}nav.on-this-page ul{width:100%;--gap:0}nav.on-this-page a{padding:0}nav.on-this-page .active a{background-color:rgba(var(--base-rgb),var(--op-6));color:var(--action-contrast)}nav.on-this-page #back-to-top span{display:none}nav.on-this-page .active a{background-color:var(--action-0);color:var(--action-contrast)}nav.letters,nav.letters a,nav.letters li,nav.letters ul{height:var(--chip)}nav.letters li{max-width:calc(7.69% - 2px)}nav.letters ul{--wrap:wrap}@media (min-width:768px){nav.letters,nav.letters ul{height:var(--chip)}nav.letters ul{--wrap:nowrap}nav.letters li{max-width:none}nav.letters a{padding:.25rem .66rem}}nav.index{--justify:space-between;--padding:0;background-color:rgba(var(--base-rgb),var(--op-6))}nav.index ul{width:100%}nav.index li{flex-shrink:0;transform:scaleX(0);max-width:0;overflow:hidden}nav.index li.active,nav.index li.adj{transform:scaleX(1);width:calc(100% - var(--btn_));flex-shrink:1;max-width:none}nav.index li:first-of-type{flex-shrink:1;transform:scaleX(1);order:9999;width:var(--btn);height:var(--btn);max-width:none}@media (max-width:767px){.index li.adj{transform:scaleX(0);max-width:0}}nav.index a{border-bottom:4px solid transparent}nav.index .active a{border-color:var(--action-0);color:var(--contrast)}nav.index.open{--dir:column-reverse;height:var(--maxHeight);width:100%;--align:flex-end;background-color:rgba(var(--base-rgb),var(--op-6));backdrop-filter:blur(5px);z-index:var(--z-10)}nav.index.open li{width:100%;height:var(--btn);max-width:100%!important;transform:scaleX(1);overflow:visible}nav.index.open a{--justify:flex-end;padding:0 2rem 0 0;background-color:transparent}nav.condensed,nav.condensed a,nav.condensed li,nav.condensed ul{height:max-content;width:max-content;--wrap:wrap;min-height:var(--chip)}nav.condensed{--gap:0 .25rem;width:100%}nav.condensed li+li::before{content:'·';padding:0 .25em}nav.condensed a{font-size:var(--txt-x-small);padding:0 .25rem;text-transform:none;border-bottom:2px solid transparent}nav .current a,nav a.current,nav a:focus,nav a:focus:visited,nav button:focus{background-color:transparent;color:var(--contrast);border-color:var(--action-0)}ul.socials{--dir:row;height:max-content;--gap:.5rem;--justify:stretch;--wrap:nowrap;overflow:auto hidden;touch-action:pan-x}.always ul.socials{width:100%}ul.socials a{padding:.5rem;max-width:none}ul.socials .icon{margin:0}nav.tabs{padding-bottom:2px;touch-action:pan-x pan-y;--wrap:nowrap;overflow:auto hidden}nav.tabs button.active{cursor:default}nav.tabs button{font-family:var(--heading);font-size:var(--txt-x-small);border-bottom:4px solid transparent}nav.tabs button.active,nav.tabs button.active:hover{background-color:var(--action-0);color:var(--action-contrast);border-color:var(--base)}.tab-content nav.tabs button{height:var(--chip_);padding:.25rem .75rem;min-height:0}.tab-content h2{margin:0 0 .5rem}.tab-content nav.tabs{height:max-content;background-color:var(--base);--gap:0}.tab-content .tab-content nav.tabs{background-color:var(--base-100)}.tab-content .tab-content .tab-content nav.tabs{background-color:var(--base-200)}.tab-content nav.tabs button.active h2{color:var(--action-0)}nav.menu a{padding:.5rem .66rem}nav.share{height:max-content;margin:1rem 0}nav.share ul{overflow:visible}nav.share h4{display:inline-block;width:max-content;margin:.25rem .5rem .25rem 0;font-size:var(--txt-x-small)}.wp-site-blocks>header,body>header{--dir:row;--justify:space-between;position:sticky;top:0;left:0;right:0;height:var(--btn);width:100vw;display:flex;align-items:center;padding:0 .5rem;background-color:var(--base);box-shadow:rgba(var(--base-rgb),var(--op-45)) var(--shdw);z-index:var(--z-9)}.wp-site-blocks>header img{width:var(--btn)}.dashboard-nav{width:100%}nav.filters{--dir:row;overflow:auto hidden}nav.filters .filter{width:auto;padding:.25rem .75rem}nav.term-navigation:has([hidden]){display:none} |
| | |
| | | |
| | | public function addDashboardSection(string $content, string $page):string |
| | | { |
| | | if ($page !== 'jvb-seo') { |
| | | if ($page !== 'SEO') { |
| | | return $content; |
| | | } |
| | | ob_start(); |
| | |
| | | $this->renderStyles(); |
| | | } |
| | | } |
| | | private function renderStyles(): void |
| | | { |
| | | jvbInlineStyles('forms'); |
| | | } |
| | | |
| | | public function renderProperty(string $property, ?string $value, mixed $class):void |
| | | { |
| | |
| | | // delete_option(BASE.'do_these_once'); |
| | | //Ensure we have the option starting with BASE |
| | | $option = jvbCheckBase($option); |
| | | // delete_option($option); |
| | | // delete_option(BASE.'do_these_once'); |
| | | $options = get_option(BASE.'do_these_once', []); |
| | | // delete_option($option); |
| | | if (!array_key_exists($option, $options)) {// Prevent concurrent runs |
| | |
| | | $result = $this->sendEmail($payload); |
| | | |
| | | if ($result === true) { |
| | | error_log('================================ Email sent! ================================'); |
| | | // Prevent default wp_mail from sending |
| | | add_filter('pre_wp_mail', '__return_true'); |
| | | do_action('postmark_email_sent', $args, $payload); |
| | | } else { |
| | | error_log('=-======================[POSTMARK]Something went wrong... ================================'); |
| | | // Log failure but allow fallback to default mail |
| | | do_action('postmark_email_failed', $args, $result); |
| | | |
| | |
| | | } |
| | | try { |
| | | $response = $this->postRequest('email', $payload); |
| | | |
| | | error_log('================================ POSTMARK RESPONSE: ================================'.print_r($response, true)); |
| | | if (is_wp_error($response)) { |
| | | return $response; |
| | | } |
| | |
| | | require(JVB_DIR . '/inc/managers/SEO/render/Thing/Place/AdministrativeArea/AdministrativeArea.php'); |
| | | require(JVB_DIR . '/inc/managers/SEO/render/Thing/Place/AdministrativeArea/City.php'); |
| | | require(JVB_DIR . '/inc/managers/SEO/render/Thing/Place/AdministrativeArea/Country.php'); |
| | | require(JVB_DIR . '/inc/managers/SEO/render/Thing/Place/AdministrativeArea/State.php'); |
| | |
| | | add_action('init', 'jvbRegisterScripts', 5); |
| | | |
| | | function jvbRegisterScripts() { |
| | | $version = '1.1.5'; |
| | | $version = '1.1.55'; |
| | | $strategy = [ |
| | | 'strategy' => 'defer', |
| | | 'in_footer' => true |
| | |
| | | <?php |
| | | namespace JVBase\managers\queue; |
| | | use wpdb; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | class Locker |
| | | { |
| | | private string $lockKey; |
| | | private int $timeout; |
| | | protected wpdb $wpdb; |
| | | private ?string $token = null; |
| | | |
| | | public function __construct(string $key = 'queue', int $timeout = 0) |
| | | public function __construct(string $key = 'queue', int $timeout = 60) |
| | | { |
| | | $this->lockKey = BASE . $key . '_lock'; |
| | | $this->timeout = $timeout; |
| | | global $wpdb; |
| | | $this->wpdb = $wpdb; |
| | | } |
| | | |
| | | /** |
| | | * Execute callback with lock, auto-release after |
| | | */ |
| | | public function withLock(callable $callback): void |
| | | { |
| | | $acquired = $this->wpdb->get_var( |
| | | $this->wpdb->prepare( |
| | | 'SELECT GET_LOCK(%s, %d)', |
| | | $this->lockKey, |
| | | $this->timeout |
| | | ) |
| | | ); |
| | | |
| | | if ((int) $acquired !== 1) { |
| | | // Lock already held — just exit quietly |
| | | return; |
| | | } |
| | | if (!$this->acquire()) return; |
| | | |
| | | try { |
| | | $callback(); |
| | |
| | | } |
| | | } |
| | | |
| | | public function unlock():void |
| | | private function acquire(): bool |
| | | { |
| | | $this->wpdb->get_var( |
| | | $this->wpdb->prepare( |
| | | 'SELECT RELEASE_LOCK(%s)', |
| | | $this->lockKey |
| | | ) |
| | | ); |
| | | $this->token = bin2hex(random_bytes(8)); |
| | | return (bool) wp_cache_add($this->lockKey, $this->token, 'locks', $this->timeout); |
| | | } |
| | | |
| | | public function unlock(): void |
| | | { |
| | | $current = wp_cache_get($this->lockKey, 'locks'); |
| | | if ($current === $this->token) { |
| | | wp_cache_delete($this->lockKey, 'locks'); |
| | | } |
| | | $this->token = null; |
| | | } |
| | | } |
| | |
| | | |
| | | public function run(): void |
| | | { |
| | | if (get_transient(BASE.'queue_running')) { |
| | | return; |
| | | } |
| | | set_transient(BASE.'queue_running', true, 60); |
| | | if (!$this->hasAdequateResources()) { |
| | | error_log('[Processor] Insufficient resources to start processing'); |
| | | return; |
| | | } |
| | | |
| | | $ops = $this->storage->fetchRunnable(); |
| | | if (empty($ops)) { |
| | | return; |
| | | } |
| | | foreach ($ops as $op) { |
| | | if ($op->state === 'completed') { |
| | | return; |
| | | $op = null; |
| | | $this->storage->withTransaction(function() use (&$op) { |
| | | $candidates = $this->storage->fetchRunnable(); |
| | | foreach ($candidates as $candidate) { |
| | | if ($candidate->state === 'completed') continue; |
| | | if (!$this->dependenciesSatisfied($candidate)) continue; |
| | | if ($this->storage->markProcessing($candidate->id)) { |
| | | $op = $candidate; |
| | | break; |
| | | } |
| | | } |
| | | if (!$this->dependenciesSatisfied($op)) { |
| | | continue; |
| | | } |
| | | if (!$this->storage->markProcessing($op->id)) { |
| | | continue; |
| | | } |
| | | $this->processOne($op); |
| | | usleep(10000); |
| | | } |
| | | }); |
| | | |
| | | if (!$op) return; |
| | | |
| | | $this->processOne($op); |
| | | usleep(10000); |
| | | |
| | | $this->storage->invalidateQueueCache(); |
| | | } |
| | | |
| | | |
| | | private function processOne(Operation $op): void |
| | | { |
| | | if (get_transient(BASE.$op->id)) { |
| | | return; |
| | | } |
| | | set_transient(BASE.$op->id, true, 500); |
| | | $progress = new Progress($op); |
| | | |
| | | $executor = $this->registry->getExecutor($op->type) ?? $this->defaultExecutor; |
| | | $op->startedAt = current_time('mysql'); |
| | | $op->state = 'processing'; |
| | | |
| | | $this->storage->saveProgress($op); |
| | | |
| | | try { |
| | |
| | | |
| | | public function __construct() |
| | | { |
| | | $this->defineTables(); |
| | | $this->storage = new Storage(); |
| | | $this->registry = new TypeRegistry(); |
| | | $this->locker = new Locker(); |
| | |
| | | |
| | | add_action('jvb_process_queue', [$this, 'checkQueue']); |
| | | add_action('jvb_queue_maintenance', [$this, 'maintenance']); |
| | | add_action('jvb_daily_snapshot', [$this->storage, 'snapshotDaily']); |
| | | |
| | | if (!wp_next_scheduled('jvb_process_queue')) { |
| | | wp_schedule_event(time(), 'every-minute', 'jvb_process_queue'); |
| | |
| | | if (!wp_next_scheduled('jvb_queue_maintenance')) { |
| | | wp_schedule_event(time(), 'hourly', 'jvb_queue_maintenance'); |
| | | } |
| | | if (!wp_next_scheduled('jvb_daily_snapshot')) { |
| | | // Schedule for next 3am |
| | | $next3am = strtotime('tomorrow 3am', current_time('timestamp')); |
| | | wp_schedule_event($next3am, 'daily', 'jvb_daily_snapshot'); |
| | | } |
| | | |
| | | jvb_register_do_once('queue_admin_action_registered', [$this, 'registerAdminAction']); |
| | | add_filter(BASE.'admin_action_filter', [$this, 'adminActionFilter'], 10, 3); |
| | | } |
| | | |
| | | public static function defineTables():void |
| | | { |
| | | $queue = CustomTable::for('_operation_queue'); |
| | | $queue->setColumns([ |
| | | 'id' => 'VARCHAR(64) NOT NULL', |
| | | 'type' => 'VARCHAR(50) NOT NULL', |
| | | 'user_id' => $queue->getUserIDType().' NOT NULL', |
| | | |
| | | 'request_data' => 'JSON NOT NULL CHECK (JSON_VALID(request_data))', |
| | | |
| | | 'total_items' => 'INT(11) NOT NULL DEFAULT 1', |
| | | 'processed_items' => 'INT(11) DEFAULT 0', |
| | | 'failed_items' => 'JSON', |
| | | |
| | | 'priority' => 'ENUM(\'high\',\'normal\',\'low\') DEFAULT \'normal\'', |
| | | 'state' => 'ENUM(\'pending\', \'scheduled\', \'processing\', \'completed\') DEFAULT \'pending\'', |
| | | 'outcome' => 'ENUM(\'pending\', \'success\',\'partial\',\'merged\',\'failed\',\'failed_permanent\') DEFAULT \'pending\'', |
| | | |
| | | 'retries' => 'INT(11) DEFAULT 0', |
| | | 'last_error_hash'=> 'CHAR(32) DEFAULT NULL', |
| | | 'error_message' => 'TEXT', |
| | | |
| | | 'scheduled_at' => 'DATETIME DEFAULT NULL', |
| | | 'started_at' => 'DATETIME DEFAULT CURRENT_TIMESTAMP', |
| | | 'completed_at' => 'DATETIME DEFAULT NULL', |
| | | |
| | | 'metadata' => 'JSON DEFAULT NULL', |
| | | 'result' => 'JSON', |
| | | 'dependencies' => 'JSON', |
| | | 'merged_into' => 'VARCHAR(64) DEFAULT NULL', |
| | | |
| | | 'user_dismissed'=> 'tinyint(1) DEFAULT 0', |
| | | 'created_at' => 'DATETIME DEFAULT CURRENT_TIMESTAMP', |
| | | 'updated_at' => 'DATETIME DEFAULT CURRENT_TIMESTAMP', |
| | | ]); |
| | | |
| | | $queue->setKeys([ |
| | | ['key' => 'PRIMARY', 'value' => '(`id`)'], |
| | | '`idx_run_queue` (`state`, `priority`, `scheduled_at`)', |
| | | '`idx_user_ops` (`user_id`, `state`)', |
| | | '`idx_user_type_pending` (`user_id`, `type`, `state`)', |
| | | '`idx_completed_at` (`completed_at`)', |
| | | '`idx_processing_stuck` (`state`, `started_at`)' |
| | | ]); |
| | | |
| | | $queue->defineTable(); |
| | | |
| | | $stats = CustomTable::for('stats__operation_queue'); |
| | | $stats->setColumns([ |
| | | 'id' => 'BIGINT unsigned AUTO_INCREMENT', |
| | | 'date' => 'DATE NOT NULL', |
| | | 'type' => 'VARCHAR(50) NOT NULL', |
| | | |
| | | 'total_operations' => 'INT NOT NULL DEFAULT 0', |
| | | 'successful_operations' => 'INT NOT NULL DEFAULT 0', |
| | | 'partial_operations' => 'INT NOT NULL DEFAULT 0', |
| | | 'failed_operations' => 'INT NOT NULL DEFAULT 0', |
| | | 'failed_permanent_operations'=> 'INT NOT NULL DEFAULT 0', |
| | | 'total_items_processed' => 'INT NOT NULL DEFAULT 0', |
| | | |
| | | 'average_duration' => 'FLOAT DEFAULT NULL', |
| | | 'max_duration' => 'INT DEFAULT NULL', |
| | | 'peak_queue_size' => 'INT NOT NULL DEFAULT 0', |
| | | 'peak_memory_usage' => 'INT DEFAULT NULL', |
| | | 'peak_cpu_usage' => 'FLOAT DEFAULT NULL', |
| | | |
| | | 'created_at' => 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP', |
| | | ]); |
| | | |
| | | $stats->setKeys([ |
| | | ['key' => 'PRIMARY', 'value' => '(`id`)'], |
| | | ['key' => 'UNIQUE', 'value' => '(`date`, `type`)'], |
| | | '`date_idx` (`date`)', |
| | | '`type_idx` (`type`)' |
| | | ]); |
| | | $stats->defineTable(); |
| | | } |
| | | |
| | | /** |
| | | * Access type registry for registering operation configs |
| | | */ |
| | |
| | | } |
| | | |
| | | use JVBase\managers\Cache; |
| | | use JVBase\managers\CustomTable; |
| | | use LogicException; |
| | | |
| | | class Storage |
| | | { |
| | | private \wpdb $wpdb; |
| | | private string $table; |
| | | private CustomTable $table; |
| | | private CustomTable $stats; |
| | | private Cache $cache; |
| | | |
| | | private const CACHE_QUEUE_INFO = 'queue_info'; |
| | | |
| | | public function __construct() |
| | | { |
| | | global $wpdb; |
| | | $this->wpdb = $wpdb; |
| | | $this->table = $wpdb->prefix . BASE . '_operation_queue'; |
| | | $this->defineTables(); |
| | | $this->cache = Cache::for('queue', DAY_IN_SECONDS); |
| | | } |
| | | |
| | | public function hasProcessingOperations(): bool |
| | | { |
| | | return (bool) $this->wpdb->get_var( |
| | | "SELECT 1 FROM {$this->table} WHERE state = 'processing' LIMIT 1" |
| | | ); |
| | | return (bool) $this->table->queryVar("SELECT 1 FROM {table} WHERE state = 'processing' LIMIT 1"); |
| | | } |
| | | |
| | | public function fetchRunnable(int $offset = 0): array |
| | | { |
| | | $now = current_time('mysql'); |
| | | |
| | | $rows = $this->wpdb->get_results( |
| | | $this->wpdb->prepare(" |
| | | SELECT * |
| | | FROM {$this->table} |
| | | WHERE state IN ('pending', 'scheduled') |
| | | AND scheduled_at <= %s |
| | | ORDER BY |
| | | FIELD(priority, 'high', 'normal', 'low'), |
| | | scheduled_at |
| | | LIMIT 10 OFFSET %d |
| | | FOR UPDATE SKIP LOCKED |
| | | ", $now, $offset) |
| | | ); |
| | | $rows = $this->table->queryResults(" |
| | | SELECT * FROM {table} |
| | | WHERE state IN ('pending', 'scheduled') |
| | | AND scheduled_at <= %s |
| | | ORDER BY FIELD(priority, 'high', 'normal', 'low'), scheduled_at |
| | | LIMIT 10 OFFSET %d |
| | | FOR UPDATE SKIP LOCKED |
| | | ", [$now, $offset]); |
| | | |
| | | $total = count($rows); |
| | | foreach ($rows as $row) { |
| | |
| | | public function markProcessing(string $id): bool |
| | | { |
| | | $now = current_time('mysql'); |
| | | |
| | | $affected = $this->wpdb->query($this->wpdb->prepare(" |
| | | UPDATE {$this->table} |
| | | SET state = 'processing', started_at = %s, updated_at = %s |
| | | WHERE id = %s AND state IN ('pending', 'scheduled') |
| | | ", $now, $now, $id)); |
| | | $affected = $this->table->query(" |
| | | UPDATE {table} |
| | | SET state = 'processing', started_at = %s, updated_at = %s |
| | | WHERE id = %s AND state IN ('pending', 'scheduled') |
| | | ", [$now, $now, $id]); |
| | | |
| | | if ($affected > 0) { |
| | | $op = $this->find($id); |
| | | if ($op) { |
| | | $this->invalidateUser($op->userId); |
| | | } |
| | | if ($op) $this->invalidateUser($op->userId); |
| | | } |
| | | |
| | | return $affected > 0; |
| | | } |
| | | |
| | | public function save(Operation $op): bool |
| | | { |
| | | $data = [ |
| | | $result = $this->table->update([ |
| | | 'request_data' => json_encode($op->requestData), |
| | | 'total_items' => $op->totalItems, |
| | | 'processed_items' => $op->processedItems, |
| | |
| | | 'retries' => $op->retries, |
| | | 'last_error_hash' => $op->lastErrorHash, |
| | | 'error_message' => $op->errorMessage, |
| | | 'scheduled_at' => $op->scheduledAt, |
| | | 'started_at' => $op->startedAt, |
| | | 'completed_at' => $op->completedAt, |
| | | 'metadata' => json_encode($op->metadata), |
| | | 'result' => $op->result ? json_encode($op->result) : null, |
| | | 'dependencies' => json_encode($op->dependencies), |
| | | 'merged_into' => $op->merged_into, |
| | | 'user_dismissed' => $op->userDismissed ? 1 : 0, |
| | | 'updated_at' => current_time('mysql'), |
| | | ]; |
| | | ], |
| | | ['id' => $op->id] |
| | | ); |
| | | |
| | | $result = $this->wpdb->update($this->table, $data, ['id' => $op->id]); |
| | | |
| | | if ($result !== false) { |
| | | $this->invalidateUser($op->userId); |
| | | } |
| | | |
| | | if ($result !== false) $this->invalidateUser($op->userId); |
| | | return $result !== false; |
| | | } |
| | | |
| | |
| | | * @param Operation $op |
| | | * @return bool |
| | | */ |
| | | public function saveProgress(Operation $op): bool { |
| | | global $wpdb; |
| | | |
| | | $table = $this->table; |
| | | |
| | | $data = [ |
| | | public function saveProgress(Operation $op): bool |
| | | { |
| | | $result = $this->table->update([ |
| | | 'processed_items' => $op->processedItems ?? 0, |
| | | 'failed_items' => $op->failedItems ? json_encode($op->failedItems) : null, |
| | | 'metadata' => ($op->metadata) ? json_encode($op->metadata) : null, |
| | | 'result' => ($op->result) ? json_encode($op->result) :null, |
| | | 'updated_at' => current_time('mysql'), |
| | | ]; |
| | | 'metadata' => $op->metadata ? json_encode($op->metadata) : null, |
| | | 'result' => $op->result ? json_encode($op->result) : null, |
| | | ], ['id' => $op->id, 'state' => 'processing']); // state guard preserved |
| | | |
| | | // IMPORTANT: never touch terminal state |
| | | $where = [ |
| | | 'id' => $op->id, |
| | | 'state' => 'processing', |
| | | ]; |
| | | |
| | | $updated = $wpdb->update($table, $data, $where); |
| | | |
| | | if ($updated === false) { |
| | | error_log('[Storage::saveProgress] DB error: ' . $wpdb->last_error); |
| | | return false; |
| | | } |
| | | |
| | | |
| | | if ($result === false) return false; |
| | | $this->invalidateUser($op->userId); |
| | | |
| | | return true; |
| | | } |
| | | |
| | |
| | | * @param Operation $op |
| | | * @return bool |
| | | */ |
| | | public function saveFinal(Operation $op): bool { |
| | | global $wpdb; |
| | | |
| | | if (($op->state?? null) !== 'completed') { |
| | | public function saveFinal(Operation $op): bool |
| | | { |
| | | if ($op->state !== 'completed') { |
| | | throw new LogicException('saveFinal called without completed state'); |
| | | } |
| | | |
| | | $table = $this->table; |
| | | $result = $this->table->update([ |
| | | 'state' => 'completed', |
| | | 'outcome' => $op->outcome ?? 'success', |
| | | 'processed_items' => $op->processedItems ?? 0, |
| | | 'failed_items' => $op->failedItems ? json_encode($op->failedItems) : null, |
| | | 'result' => isset($op->result) ? wp_json_encode($op->result) : null, |
| | | 'completed_at' => $op->completedAt ?? current_time('mysql'), |
| | | ], ['id' => $op->id, 'state' => 'processing']); // hard guard preserved |
| | | |
| | | $data = [ |
| | | 'state' => 'completed', |
| | | 'outcome' => $op->outcome?? 'success', |
| | | 'processed_items'=> $op->processedItems ?? 0, |
| | | 'failed_items' => $op->failedItems ? json_encode($op->failedItems) : null, |
| | | 'result' => isset($op->result) ? wp_json_encode($op->result) : null, |
| | | 'completed_at' => $op->completedAt ?? current_time('mysql'), |
| | | 'updated_at' => current_time('mysql'), |
| | | ]; |
| | | if ($result === 0) return true; // already completed, not an error |
| | | if ($result === false) return false; |
| | | |
| | | // HARD GUARD: cannot overwrite completed |
| | | $where = [ |
| | | 'id' => $op->id, |
| | | 'state' => 'processing', |
| | | ]; |
| | | |
| | | $updated = $wpdb->update($table, $data, $where); |
| | | |
| | | if ($updated === 0) { |
| | | return true; |
| | | } |
| | | |
| | | if ($updated === false) { |
| | | error_log('[Storage::saveFinal] DB error: ' . $wpdb->last_error); |
| | | return false; |
| | | } |
| | | $this->invalidateQueueCache(); |
| | | $this->invalidateUser($op->userId); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | public function insert(Operation $op): bool |
| | | { |
| | | $result = $this->wpdb->insert($this->table, [ |
| | | $result = $this->table->insert([ |
| | | 'id' => $op->id, |
| | | 'type' => $op->type, |
| | | 'user_id' => $op->userId, |
| | |
| | | 'state' => $op->state, |
| | | 'outcome' => $op->outcome, |
| | | 'retries' => 0, |
| | | 'last_error_hash' => null, |
| | | 'error_message' => null, |
| | | 'scheduled_at' => $op->scheduledAt ?? current_time('mysql'), |
| | | 'started_at' => null, |
| | | 'completed_at' => null, |
| | | 'metadata' => json_encode($op->metadata), |
| | | 'result' => null, |
| | | 'dependencies' => json_encode($op->dependencies), |
| | | 'user_dismissed' => 0, |
| | | 'merged_into' => null, |
| | | 'created_at' => current_time('mysql'), |
| | | 'updated_at' => current_time('mysql'), |
| | | ]); |
| | | |
| | | if ($result) { |
| | |
| | | |
| | | public function find(string $id): ?Operation |
| | | { |
| | | $row = $this->wpdb->get_row($this->wpdb->prepare( |
| | | "SELECT * FROM {$this->table} WHERE id = %s", |
| | | $id |
| | | )); |
| | | |
| | | $row = $this->table->get(['id' => $id]); |
| | | return $row ? $this->rowToOperation($row) : null; |
| | | } |
| | | |
| | | public function findMergeable(string $type, int $userId, array $criteria = []): ?Operation |
| | | { |
| | | $sql = "SELECT * FROM {$this->table} |
| | | WHERE type = %s AND user_id = %d AND state IN ('pending', 'scheduled')"; |
| | | $sql = "SELECT * FROM {table} WHERE type = %s AND user_id = %d AND state IN ('pending', 'scheduled')"; |
| | | $params = [$type, $userId]; |
| | | |
| | | foreach ($criteria as $key => $value) { |
| | | if ($value === null) { |
| | | continue; |
| | | } |
| | | if ($value === null) continue; |
| | | $sql .= " AND JSON_UNQUOTE(JSON_EXTRACT(request_data, %s)) = %s"; |
| | | $params[] = '$.' . $key; |
| | | $params[] = (string) $value; |
| | | } |
| | | |
| | | $sql .= " ORDER BY created_at DESC LIMIT 1"; |
| | | |
| | | $row = $this->wpdb->get_row($this->wpdb->prepare($sql, ...$params)); |
| | | |
| | | $this->invalidateUser($userId); |
| | | |
| | | return $row ? $this->rowToOperation($row) : null; |
| | | $rows = $this->table->queryResults($sql, $params); |
| | | return !empty($rows) ? $this->rowToOperation($rows[0]) : null; |
| | | } |
| | | |
| | | public function getUserOperations(int $userId, array $filters = []): array |
| | |
| | | // Order by state priority, then created_at |
| | | $orderBy = $filters['order_by'] ?? "FIELD(state, 'processing', 'pending', 'scheduled', 'completed'), created_at DESC"; |
| | | |
| | | $limit = $filters['limit'] ?? 50; |
| | | $params[] = $limit; |
| | | $params[] = $filters['limit'] ?? 50; |
| | | |
| | | $rows = $this->wpdb->get_results($this->wpdb->prepare( |
| | | "SELECT * FROM {$this->table} WHERE " . implode(' AND ', $where) . |
| | | " ORDER BY {$orderBy} LIMIT %d", |
| | | ...$params |
| | | )); |
| | | $rows = $this->table->queryResults( |
| | | "SELECT * FROM {table} WHERE " . implode(' AND ', $where) . " ORDER BY {$orderBy} LIMIT %d", |
| | | $params |
| | | ); |
| | | |
| | | return array_map([$this, 'rowToOperation'], $rows ?: []); |
| | | } |
| | |
| | | public function getQueueInfo(): array |
| | | { |
| | | $cached = $this->cache->get(self::CACHE_QUEUE_INFO); |
| | | if ($cached !== false) { |
| | | return $cached; |
| | | } |
| | | if ($cached !== false) return $cached; |
| | | |
| | | $now = current_time('mysql'); |
| | | $row = $this->wpdb->get_row($this->wpdb->prepare(" |
| | | SELECT |
| | | COUNT(*) as total, |
| | | SUM(IF(state IN ('pending', 'processing'), 1, 0)) as active, |
| | | SUM(IF(state = 'scheduled' AND scheduled_at <= %s, 1, 0)) as ready_scheduled |
| | | FROM {$this->table} |
| | | WHERE state IN ('pending', 'processing', 'scheduled') |
| | | ", $now)); |
| | | $row = $this->table->queryResults(" |
| | | SELECT COUNT(*) as total, |
| | | SUM(IF(state IN ('pending', 'processing'), 1, 0)) as active, |
| | | SUM(IF(state = 'scheduled' AND scheduled_at <= %s, 1, 0)) as ready_scheduled |
| | | FROM {table} |
| | | WHERE state IN ('pending', 'processing', 'scheduled') |
| | | ", [$now]); |
| | | $row = $row[0] ?? null; |
| | | |
| | | $info = [ |
| | | 'total' => (int) ($row->total ?? 0), |
| | |
| | | public function getQueueStatus(): array |
| | | { |
| | | $now = current_time('mysql'); |
| | | $rows = $this->table->queryResults(" |
| | | SELECT state, COUNT(*) as count, |
| | | SUM(IF(state = 'scheduled' AND scheduled_at <= %s, 1, 0)) as ready |
| | | FROM {table} GROUP BY state |
| | | ", [$now]); |
| | | |
| | | $rows = $this->wpdb->get_results($this->wpdb->prepare(" |
| | | SELECT |
| | | state, |
| | | COUNT(*) as count, |
| | | SUM(IF(state = 'scheduled' AND scheduled_at <= %s, 1, 0)) as ready |
| | | FROM {$this->table} |
| | | GROUP BY state |
| | | ", $now), OBJECT_K); |
| | | |
| | | $indexed = array_column($rows, null, 'state'); |
| | | return [ |
| | | 'pending' => (int) ($rows['pending']->count ?? 0), |
| | | 'scheduled' => (int) ($rows['scheduled']->count ?? 0), |
| | | 'scheduled_ready' => (int) ($rows['scheduled']->ready ?? 0), |
| | | 'processing' => (int) ($rows['processing']->count ?? 0), |
| | | 'completed' => (int) ($rows['completed']->count ?? 0), |
| | | 'pending' => (int) ($indexed['pending']->count ?? 0), |
| | | 'scheduled' => (int) ($indexed['scheduled']->count ?? 0), |
| | | 'scheduled_ready' => (int) ($indexed['scheduled']->ready ?? 0), |
| | | 'processing' => (int) ($indexed['processing']->count ?? 0), |
| | | 'completed' => (int) ($indexed['completed']->count ?? 0), |
| | | ]; |
| | | } |
| | | |
| | | public function getUserStats(int $userId): array |
| | | { |
| | | $rows = $this->wpdb->get_results($this->wpdb->prepare(" |
| | | SELECT state, outcome, COUNT(*) as count |
| | | FROM {$this->table} |
| | | WHERE user_id = %d |
| | | GROUP BY state, outcome |
| | | ", $userId)); |
| | | $rows = $this->table->queryResults( |
| | | "SELECT state, outcome, COUNT(*) as count FROM {table} WHERE user_id = %d GROUP BY state, outcome", |
| | | [$userId] |
| | | ); |
| | | |
| | | $stats = [ |
| | | 'pending' => 0, |
| | |
| | | |
| | | public function dismiss(string $id): bool |
| | | { |
| | | $result = $this->wpdb->update( |
| | | $this->table, |
| | | ['user_dismissed' => 1, 'updated_at' => current_time('mysql')], |
| | | ['id' => $id] |
| | | ); |
| | | |
| | | return $result !== false; |
| | | return $this->table->update(['user_dismissed' => 1], ['id' => $id]) !== false; |
| | | } |
| | | |
| | | /** |
| | |
| | | public function delete(string $id): bool |
| | | { |
| | | $op = $this->find($id); |
| | | $userId = $op?->userId; |
| | | |
| | | $result = $this->wpdb->delete($this->table, ['id' => $id]); |
| | | |
| | | if ($result && $userId) { |
| | | $this->invalidateUser($userId); |
| | | } |
| | | |
| | | $result = $this->table->delete(['id' => $id]); |
| | | if ($result && $op) $this->invalidateUser($op->userId); |
| | | return $result !== false; |
| | | } |
| | | |
| | |
| | | { |
| | | Cache::for($userId.'_queue')->flush(); |
| | | } |
| | | public function getLastError(): string |
| | | { |
| | | return $this->wpdb->last_error; |
| | | } |
| | | |
| | | public function withTransaction(callable $callback): mixed |
| | | { |
| | | $this->wpdb->query('START TRANSACTION'); |
| | | |
| | | $this->table->startTransaction(); |
| | | try { |
| | | $result = $callback(); |
| | | $this->wpdb->query('COMMIT'); |
| | | $this->table->commit(); |
| | | return $result; |
| | | } catch (\Throwable $e) { |
| | | $this->wpdb->query('ROLLBACK'); |
| | | $this->table->rollback(); |
| | | error_log('[Storage] Transaction rolled back: ' . $e->getMessage()); |
| | | throw $e; |
| | | } |
| | |
| | | return $this->withTransaction(function () use ($stuckMinutes) { |
| | | $cutoff = date('Y-m-d H:i:s', strtotime("-{$stuckMinutes} minutes")); |
| | | |
| | | // Get IDs first for logging |
| | | $stuckIds = $this->wpdb->get_col($this->wpdb->prepare(" |
| | | SELECT id FROM {$this->table} |
| | | WHERE state = 'processing' |
| | | AND started_at < %s |
| | | FOR UPDATE |
| | | ", $cutoff)); |
| | | $stuckIds = $this->table->queryResults( |
| | | "SELECT id FROM {table} WHERE state = 'processing' AND started_at < %s FOR UPDATE", |
| | | [$cutoff] |
| | | ); |
| | | $stuckIds = array_column($stuckIds, 'id'); |
| | | |
| | | if (empty($stuckIds)) { |
| | | return 0; |
| | | } |
| | | if (empty($stuckIds)) return 0; |
| | | |
| | | // Reset them |
| | | $affected = $this->wpdb->query($this->wpdb->prepare(" |
| | | UPDATE {$this->table} |
| | | SET state = 'scheduled', |
| | | scheduled_at = %s, |
| | | retries = retries + 1, |
| | | updated_at = %s |
| | | WHERE id IN (" . implode(',', array_fill(0, count($stuckIds), '%s')) . ") |
| | | ", |
| | | date('Y-m-d H:i:s', time() + 60), |
| | | current_time('mysql'), |
| | | ...$stuckIds |
| | | )); |
| | | |
| | | // Optional: Log to audit table |
| | | // $this->logStuckReset($stuckIds); |
| | | |
| | | return (int) $affected; |
| | | $placeholders = implode(',', array_fill(0, count($stuckIds), '%s')); |
| | | return (int) $this->table->query( |
| | | "UPDATE {table} SET state = 'scheduled', scheduled_at = %s, retries = retries + 1, updated_at = %s WHERE id IN ({$placeholders})", |
| | | array_merge([date('Y-m-d H:i:s', time() + 60), current_time('mysql')], $stuckIds) |
| | | ); |
| | | }); |
| | | } |
| | | |
| | |
| | | public function replaceDependency(string $fromId, string $toId): int |
| | | { |
| | | return $this->withTransaction(function () use ($fromId, $toId) { |
| | | |
| | | // Only affect pending/scheduled operations |
| | | $affected = $this->wpdb->query($this->wpdb->prepare(" |
| | | UPDATE {$this->table} |
| | | SET dependencies = REPLACE(dependencies, %s, %s), |
| | | updated_at = %s |
| | | WHERE state IN ('pending', 'scheduled') |
| | | AND dependencies LIKE %s |
| | | ", |
| | | '"' . $fromId . '"', |
| | | '"' . $toId . '"', |
| | | current_time('mysql'), |
| | | '%"' . $fromId . '"%' |
| | | )); |
| | | |
| | | return (int) $affected; |
| | | return (int) $this->table->query( |
| | | "UPDATE {table} SET dependencies = REPLACE(dependencies, %s, %s), updated_at = %s WHERE state IN ('pending', 'scheduled') AND dependencies LIKE %s", |
| | | ['"'.$fromId.'"', '"'.$toId.'"', current_time('mysql'), '%"'.$fromId.'"%'] |
| | | ); |
| | | }); |
| | | } |
| | | |
| | | public function defineTables():void |
| | | { |
| | | $queue = CustomTable::for('_operation_queue'); |
| | | $queue->setColumns([ |
| | | 'id' => 'VARCHAR(64) NOT NULL', |
| | | 'type' => 'VARCHAR(50) NOT NULL', |
| | | 'user_id' => $queue->getUserIDType().' NOT NULL', |
| | | |
| | | 'request_data' => 'JSON NOT NULL CHECK (JSON_VALID(request_data))', |
| | | |
| | | 'total_items' => 'INT(11) NOT NULL DEFAULT 1', |
| | | 'processed_items' => 'INT(11) DEFAULT 0', |
| | | 'failed_items' => 'JSON', |
| | | |
| | | 'priority' => 'ENUM(\'high\',\'normal\',\'low\') DEFAULT \'normal\'', |
| | | 'state' => 'ENUM(\'pending\', \'scheduled\', \'processing\', \'completed\') DEFAULT \'pending\'', |
| | | 'outcome' => 'ENUM(\'pending\', \'success\',\'partial\',\'merged\',\'failed\',\'failed_permanent\') DEFAULT \'pending\'', |
| | | |
| | | 'retries' => 'INT(11) DEFAULT 0', |
| | | 'last_error_hash'=> 'CHAR(32) DEFAULT NULL', |
| | | 'error_message' => 'TEXT', |
| | | |
| | | 'scheduled_at' => 'DATETIME DEFAULT NULL', |
| | | 'started_at' => 'DATETIME DEFAULT CURRENT_TIMESTAMP', |
| | | 'completed_at' => 'DATETIME DEFAULT NULL', |
| | | |
| | | 'metadata' => 'JSON DEFAULT NULL', |
| | | 'result' => 'JSON', |
| | | 'dependencies' => 'JSON', |
| | | 'merged_into' => 'VARCHAR(64) DEFAULT NULL', |
| | | |
| | | 'user_dismissed'=> 'tinyint(1) DEFAULT 0', |
| | | 'created_at' => 'DATETIME DEFAULT CURRENT_TIMESTAMP', |
| | | 'updated_at' => 'DATETIME DEFAULT CURRENT_TIMESTAMP', |
| | | ]); |
| | | |
| | | $queue->setKeys([ |
| | | ['key' => 'PRIMARY', 'value' => '(`id`)'], |
| | | '`idx_run_queue` (`state`, `priority`, `scheduled_at`)', |
| | | '`idx_user_ops` (`user_id`, `state`)', |
| | | '`idx_user_type_pending` (`user_id`, `type`, `state`)', |
| | | '`idx_completed_at` (`completed_at`)', |
| | | '`idx_processing_stuck` (`state`, `started_at`)' |
| | | ]); |
| | | |
| | | $queue->defineTable(); |
| | | $this->table = $queue; |
| | | |
| | | $stats = CustomTable::for('stats__operation_queue'); |
| | | $stats->setColumns([ |
| | | 'id' => 'BIGINT unsigned AUTO_INCREMENT', |
| | | 'date' => 'DATE NOT NULL', |
| | | 'type' => 'VARCHAR(50) NOT NULL', |
| | | |
| | | // Only store what can't be queried from the main table later |
| | | 'peak_queue_size' => 'INT NOT NULL DEFAULT 0', |
| | | 'peak_memory_bytes' => 'BIGINT DEFAULT NULL', |
| | | |
| | | // Snapshot totals for post-purge historical view |
| | | 'total_operations' => 'INT NOT NULL DEFAULT 0', |
| | | 'successful_operations' => 'INT NOT NULL DEFAULT 0', |
| | | 'failed_permanent_operations' => 'INT NOT NULL DEFAULT 0', |
| | | 'total_items_processed' => 'INT NOT NULL DEFAULT 0', |
| | | |
| | | 'created_at' => 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP', |
| | | 'updated_at' => 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP', |
| | | ]); |
| | | |
| | | $stats->setKeys([ |
| | | ['key' => 'PRIMARY', 'value' => '(`id`)'], |
| | | ['key' => 'UNIQUE', 'value' => '(`date`, `type`)'], |
| | | '`date_idx` (`date`)', |
| | | '`type_idx` (`type`)' |
| | | ]); |
| | | $stats->defineTable(); |
| | | |
| | | $this->stats = $stats; |
| | | } |
| | | |
| | | public function snapshotDaily(): void |
| | | { |
| | | $today = current_time('Y-m-d'); |
| | | $types = $this->table->queryResults( |
| | | "SELECT DISTINCT type FROM {table} WHERE DATE(completed_at) = %s", |
| | | [$today] |
| | | ); |
| | | |
| | | foreach ($types as $row) { |
| | | $stats = $this->table->queryResults(" |
| | | SELECT |
| | | COUNT(*) as total, |
| | | SUM(outcome = 'success') as successful, |
| | | SUM(outcome = 'failed_permanent') as failed_permanent, |
| | | SUM(processed_items) as items_processed |
| | | FROM {table} |
| | | WHERE type = %s AND DATE(completed_at) = %s |
| | | ", [$row->type, $today]); |
| | | |
| | | $s = $stats[0] ?? null; |
| | | if (!$s) continue; |
| | | |
| | | $this->stats->table->query(" |
| | | INSERT INTO {table} (date, type, total_operations, successful_operations, failed_permanent_operations, total_items_processed) |
| | | VALUES (%s, %s, %d, %d, %d, %d) |
| | | ON DUPLICATE KEY UPDATE |
| | | total_operations = VALUES(total_operations), |
| | | successful_operations = VALUES(successful_operations), |
| | | failed_permanent_operations = VALUES(failed_permanent_operations), |
| | | total_items_processed = VALUES(total_items_processed), |
| | | updated_at = NOW() |
| | | ", [$today, $row->type, $s->total, $s->successful, $s->failed_permanent, $s->items_processed]); |
| | | } |
| | | } |
| | | } |
| | |
| | | */ |
| | | protected string $type; |
| | | /** |
| | | * @var string the full slug, with BASE |
| | | * @var ?string the full slug, with BASE |
| | | */ |
| | | protected string $slug; |
| | | protected ?string $slug; |
| | | |
| | | protected string $contentType; |
| | | protected Item $item; |
| | |
| | | protected Validator $validator; |
| | | protected Sanitizer $sanitizer; |
| | | protected array $fields; |
| | | protected WP_Post|WP_Term|WP_User|null $wpObject; |
| | | protected WP_Post|WP_Term|WP_User|false|null $wpObject; |
| | | protected int|string $ID; |
| | | protected MetaTypeManager $typeManager; |
| | | protected static array $instances = ['post' => [],'term' => [], 'user'=>[],'options'=>[]]; |
| | |
| | | |
| | | |
| | | |
| | | $registrar = Registrar::getInstance($this->slug); |
| | | $registrar = !is_null($this->slug) ? Registrar::getInstance($this->slug) : false; |
| | | $fields = $registrar ? $registrar->getFields() : []; |
| | | $meta = match($type) { |
| | | 'post' => get_post_meta($id), |
| | |
| | | } catch (\Exception $e) { |
| | | return new WP_Error('validation_failed', 'Data validation error: ' . $e->getMessage()); |
| | | } |
| | | |
| | | if (array_key_exists('success', $processed_data) && $processed_data['success'] === false) { |
| | | return $processed_data; |
| | | } |