PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
← All changes | database/DataBackfills.php +249 -4 1.5.3 → 1.6.5 View file →
@@ -43,8 +43,27 @@
43 43 // Remove after one or two releases once affected installs have upgraded.
44 44 'installment_payments' => [
45 45 'title' => 'Installment Payments Backfill',
46 46 ],
47 + // 2026-07-22 — shipped with the completed-subscription guard in
48 + // Subscription::cancelRemoteSubscription: the EOT flow used to stamp
49 + // next_billing_date with the completion time on completed rows.
50 + // Remove after one or two releases once affected installs have upgraded.
51 + 'completed_next_billing_date' => [
52 + 'title' => 'Completed Subscription Billing Date Cleanup',
53 + ],
54 + // 2026-07-25 — idx_order_addresses_order_id_type has been declared in
55 + // OrderAddressesMigrator::migrated() since 2026-06-06, but migrated()
56 + // only runs on ACTIVATION and a WordPress in-place update never fires
57 + // the activation hook, so stores that updated rather than
58 + // deactivated/reactivated still have fct_order_addresses with nothing
59 + // but its PRIMARY key. Delivered here instead of behind a DB-version
60 + // bump: an index is not a correctness change, so it does not warrant
61 + // forcing the whole version-gated block to re-run on every install.
62 + // Remove after one or two releases once affected installs have upgraded.
63 + 'order_address_index' => [
64 + 'title' => 'Order Address Index',
65 + ],
47 66 ];
48 67 }
49 68
50 69 /**
@@ -51,9 +70,9 @@
51 70 * @return array pending backfill slugs (registered but not completed)
52 71 */
53 72 public static function getPending()
54 73 {
55 - $option = (array)fluent_cart_get_option('_db_migrations', []);
74 + $option = (array)fluent_cart_get_option('_db_migrations', [], false);
56 75 $doneSlugs = array_keys(array_filter((array)Arr::get($option, 'backfills', [])));
57 76
58 77
59 78 return array_values(array_diff(array_keys(self::getRegistry()), $doneSlugs));
@@ -70,8 +89,15 @@
70 89 * returned status is 'running'.
71 90 *
72 91 * @return array ['status' => completed|running|locked, 'completed' => [], 'pending' => []]
73 92 */
93 + /**
94 + * How many times the order-address index DDL may be retried before the slug
95 + * retires. Small on purpose: the only failures worth retrying are transient locks,
96 + * and each retry is an immediate re-post from the browser driver, not a page load.
97 + */
98 + const ORDER_ADDRESS_INDEX_MAX_ATTEMPTS = 3;
99 +
74 100 public static function processPending()
75 101 {
76 102 $pending = self::getPending();
77 103
@@ -116,8 +142,16 @@
116 142 if ($slug === 'installment_payments') {
117 143 return self::repairInstallmentBillTimes();
118 144 }
119 145
146 + if ($slug === 'completed_next_billing_date') {
147 + return self::clearCompletedNextBillingDates();
148 + }
149 +
150 + if ($slug === 'order_address_index') {
151 + return self::ensureOrderAddressIndex();
152 + }
153 +
120 154 // registered slug without a runner — mark done so it can't wedge the
121 155 // queue, but leave a trace since this is a programming error
122 156 fluent_cart_add_log(
123 157 'Data backfill has no runner',
@@ -133,9 +167,9 @@
133 167 }
134 168
135 169 private static function markCompleted($slug)
136 170 {
137 - $option = (array)fluent_cart_get_option('_db_migrations', []);
171 + $option = (array)fluent_cart_get_option('_db_migrations', [], false);
138 172 $backfills = (array)Arr::get($option, 'backfills', []);
139 173 $backfills[$slug] = 'yes';
140 174 $option['backfills'] = $backfills;
141 175
@@ -154,8 +188,83 @@
154 188 );
155 189 }
156 190
157 191 /**
192 + * Apply fct_order_addresses' declared indexes on installs that never ran the
193 + * activation hook.
194 + *
195 + * The DDL itself is NOT written here — Migrators stay the single home for
196 + * schema, so this delegates to OrderAddressesMigrator::migrated(), which owns
197 + * idx_order_addresses_order_id_type and whose addIndexIfNotExists is a no-op
198 + * where activation already applied it. This runner only supplies the delivery
199 + * the activation hook missed.
200 + *
201 + * No cursor: this is one DDL statement, not a row scan. MySQL builds a
202 + * secondary index online (5.6+), so it does not lock the table for writes.
203 + *
204 + * The outcome is CHECKED, not assumed. addIndexIfNotExists() returns void and
205 + * routes through $wpdb->query(), which returns false on a failed DDL rather than
206 + * throwing — so a lock timeout, or a denied ALTER on a restricted grant, would
207 + * otherwise let this report success and retire the slug permanently with no index
208 + * and no trace.
209 + *
210 + * Failure is retried a BOUNDED number of times rather than by returning false
211 + * indefinitely. In this queue false means "budget spent, resume me", and the
212 + * browser driver in resources/admin/bootstrap/app.js re-posts immediately while the
213 + * status stays 'running' — up to 100 times per page load. An unfixable failure
214 + * (no ALTER grant) returned as false would therefore fire 100 doomed ALTERs and
215 + * write 100 log rows on every admin page load. A few attempts are enough to ride
216 + * out a transient lock; past that the slug retires with one clear warning, and the
217 + * index is still declared in the migrator so a later activation re-applies it.
218 + *
219 + * @return bool true when the index exists, the table does not, or the attempt
220 + * budget is spent; false only to earn one more retry
221 + */
222 + private static function ensureOrderAddressIndex()
223 + {
224 + $table = Migrations\OrderAddressesMigrator::$tableName;
225 +
226 + // Nothing to index and nothing to retry — a fresh install creates the table
227 + // with the index already in getSqlSchema().
228 + if (!Schema::hasTable($table)) {
229 + return true;
230 + }
231 +
232 + Migrations\OrderAddressesMigrator::migrated();
233 +
234 + if (Migrations\OrderAddressesMigrator::hasOrderIdTypeIndex()) {
235 + return true;
236 + }
237 +
238 + $cursorKey = '_fluent_cart_order_address_index_attempts';
239 + $attempts = (int) fluent_cart_get_option($cursorKey, 0, false) + 1;
240 + fluent_cart_update_option($cursorKey, $attempts);
241 +
242 + if ($attempts < self::ORDER_ADDRESS_INDEX_MAX_ATTEMPTS) {
243 + return false;
244 + }
245 +
246 + fluent_cart_add_log(
247 + 'Order address index backfill gave up',
248 + 'Could not create ' . Migrations\OrderAddressesMigrator::ORDER_ID_TYPE_INDEX
249 + . ' on ' . $table . ' after ' . $attempts . ' attempts. Check that the database'
250 + . ' user has ALTER permission. Order address lookups will still work, only'
251 + . ' slower; the index is re-applied on the next plugin activation. NOTE: the'
252 + . ' "' . Arr::get(self::getRegistry(), 'order_address_index.title', 'Order Address Index')
253 + . ' completed" entry logged straight after this one means this backfill'
254 + . ' STOPPED RETRYING, not that the index was created — the shared queue logs'
255 + . ' that line for every slug it retires. This warning is the real outcome.',
256 + 'warning',
257 + [
258 + 'module_name' => 'activity',
259 + 'module_id' => 0,
260 + ]
261 + );
262 +
263 + return true;
264 + }
265 +
266 + /**
158 267 * Repair installment subscriptions whose bill_times was stored decremented
159 268 * by the old discount/simulated-trial checkout (completion then fired one
160 269 * installment early and canceled the remote subscription).
161 270 *
@@ -174,9 +283,9 @@
174 283 $chunkSize = 500;
175 284 $maxChunksPerRun = 5;
176 285 $maxRepairsPerRun = 500;
177 286 $chunksProcessed = 0;
178 - $lastId = (int)fluent_cart_get_option('_fluent_cart_installment_repair_cursor', 0);
287 + $lastId = (int)fluent_cart_get_option('_fluent_cart_installment_repair_cursor', 0, false);
179 288 $offsetIds = [];
180 289 $underCollectedIds = [];
181 290 $anomalousIds = [];
182 291 $repairedRows = [];
@@ -409,8 +518,144 @@
409 518 return true;
410 519 }
411 520
412 521 /**
522 + * Clear the stale next_billing_date the pre-guard EOT flow stamped onto
523 + * completed subscriptions (cancelRemoteSubscription used to run its
524 + * effective_from=immediately assignment on completed rows too). Completed
525 + * subscriptions never bill again, so any non-null value here is the bug
526 + * signature — which also makes re-runs idempotent: cleared rows no longer
527 + * match the scan.
528 + *
529 + * @return bool true when the scan reached the end of the table
530 + */
531 + private static function clearCompletedNextBillingDates()
532 + {
533 + // Filterable so the chunk/budget boundary is testable with small
534 + // tables; production keeps the defaults.
535 + $budget = apply_filters('fluent_cart/data_backfills/chunk_budget', [
536 + 'chunk_size' => 500,
537 + 'max_chunks_per_run' => 10,
538 + ], ['slug' => 'completed_next_billing_date']);
539 +
540 + $chunkSize = max(1, (int)Arr::get($budget, 'chunk_size', 500));
541 + $maxChunksPerRun = max(1, (int)Arr::get($budget, 'max_chunks_per_run', 10));
542 + $chunksProcessed = 0;
543 + $lastId = (int)fluent_cart_get_option('_fluent_cart_completed_billing_date_cursor', 0, false);
544 + $clearedIds = [];
545 +
546 + do {
547 + $rows = Subscription::query()
548 + ->select(['id'])
549 + ->where('id', '>', $lastId)
550 + ->where('status', Status::SUBSCRIPTION_COMPLETED)
551 + ->whereNotNull('next_billing_date')
552 + ->orderBy('id', 'ASC')
553 + ->limit($chunkSize)
554 + ->get();
555 +
556 + if ($rows->isEmpty()) {
557 + break;
558 + }
559 +
560 + $ids = [];
561 + foreach ($rows as $row) {
562 + $lastId = $row->id;
563 + $ids[] = $row->id;
564 + }
565 +
566 + // Fires between selection and write — a selected row CAN legitimately
567 + // change state here (reactivation, gateway resync); the UPDATE below
568 + // must re-check status so it never clears a live schedule.
569 + do_action('fluent_cart/data_backfills/chunk_selected', [
570 + 'slug' => 'completed_next_billing_date',
571 + 'ids' => $ids,
572 + ]);
573 +
574 + // status re-checked in the UPDATE so a row that changed between the
575 + // scan and the write can't lose a legitimate billing date
576 + Subscription::query()
577 + ->whereIn('id', $ids)
578 + ->where('status', Status::SUBSCRIPTION_COMPLETED)
579 + ->update(['next_billing_date' => null]);
580 +
581 + // Report only rows the guarded UPDATE actually cleared — every
582 + // selected id had a non-null date, so post-update null + completed
583 + // is the cleared signature; a row the guard skipped keeps its date.
584 + $clearedRows = Subscription::query()
585 + ->select(['id'])
586 + ->whereIn('id', $ids)
587 + ->where('status', Status::SUBSCRIPTION_COMPLETED)
588 + ->whereNull('next_billing_date')
589 + ->get();
590 + foreach ($clearedRows as $clearedRow) {
591 + $clearedIds[] = $clearedRow->id;
592 + }
593 +
594 + // cursor after every chunk — a timeout resumes here, never from id 0
595 + fluent_cart_update_option('_fluent_cart_completed_billing_date_cursor', $lastId);
596 + $chunksProcessed++;
597 +
598 + if ($rows->count() >= $chunkSize && $chunksProcessed >= $maxChunksPerRun) {
599 + self::mergeClearedBillingDateReport($clearedIds);
600 +
601 + return false;
602 + }
603 + } while ($rows->count() >= $chunkSize);
604 +
605 + $report = self::mergeClearedBillingDateReport($clearedIds);
606 +
607 + if ($report) {
608 + fluent_cart_add_log(
609 + 'Completed subscription billing date cleanup completed',
610 + 'Cleared the stale next_billing_date on ' . (int)Arr::get($report, 'cleared_count', 0)
611 + . ' completed subscription(s).',
612 + 'info',
613 + [
614 + 'module_name' => 'subscription',
615 + 'module_id' => 0
616 + ]
617 + );
618 + }
619 +
620 + // done — the cursor has no further use
621 + Meta::query()
622 + ->where('object_type', 'option')
623 + ->where('meta_key', '_fluent_cart_completed_billing_date_cursor')
624 + ->delete();
625 +
626 + return true;
627 + }
628 +
629 + /**
630 + * Accumulate cleared ids into the report option across partial runs —
631 + * id-keyed + deduped, so a replayed chunk can't double-count a subscription.
632 + *
633 + * @return array|null merged report, or null when nothing was ever cleared
634 + */
635 + private static function mergeClearedBillingDateReport($clearedIds)
636 + {
637 + $report = (array)fluent_cart_get_option('_fluent_cart_completed_billing_date_report', [], false);
638 +
639 + if (!$clearedIds && !$report) {
640 + return null;
641 + }
642 +
643 + $report = [
644 + 'repaired_at' => gmdate('Y-m-d H:i:s'),
645 + 'cleared_ids' => array_values(array_unique(array_merge(
646 + (array)Arr::get($report, 'cleared_ids', []),
647 + $clearedIds
648 + ))),
649 + ];
650 + $report['cleared_count'] = count($report['cleared_ids']);
651 +
652 + fluent_cart_update_option('_fluent_cart_completed_billing_date_report', $report);
653 +
654 + return $report;
655 + }
656 +
657 + /**
413 658 * Accumulate results into the report option across partial runs — rows are
414 659 * keyed by subscription id, so a replayed chunk can't duplicate entries.
415 660 *
416 661 * @return array|null merged report, or null when nothing was ever repaired
@@ -416,9 +661,9 @@
416 661 * @return array|null merged report, or null when nothing was ever repaired
417 662 */
418 663 private static function mergeRepairReport($repairedRows, $offsetIds, $underCollectedIds, $anomalousIds)
419 664 {
420 - $report = (array)fluent_cart_get_option('_fluent_cart_installment_repair_report', []);
665 + $report = (array)fluent_cart_get_option('_fluent_cart_installment_repair_report', [], false);
421 666
422 667 if (!$repairedRows && !$anomalousIds && !$report) {
423 668 return null;
424 669 }