PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.17
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.17
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
woocommerce-pos / includes / Sync / Sync_Journal_Purge.php

Sync_Journal_Purge.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.17, at includes/Sync/Sync_Journal_Purge.php

273 lines 10.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WCPOS sync-journal retention service.
4 *
5 * @package WCPOS\WooCommercePOS\Sync
6 */
7
8 namespace WCPOS\WooCommercePOS\Sync;
9
10 /**
11 * Compacts superseded changes and expires old tombstones in bounded batches.
12 *
13 * Pure policy: windows, batching, caps, and scheduling. All table SQL lives
14 * on Sync_Journal and Mutation_Store. Compaction is lossless (only superseded
15 * rows die); tombstone pruning is the one lossy operation and advances the
16 * log's prune watermark so clients can detect a pruned interval. The same
17 * daily run also expires settled mutation rows past their replay window (see
18 * Mutation_Store::prune_settled()).
19 */
20 final class Sync_Journal_Purge {
21 /** Daily cron hook that purges retained sync-journal rows. */
22 const PURGE_HOOK = 'wcpos_sync_journal_purge';
23
24 /** Hard ceiling on deletions per run across both operations. */
25 const MAX_DELETES_PER_RUN = 5000;
26
27 /**
28 * Slice of that ceiling that compaction may never take.
29 *
30 * Compaction runs first and a busy store can supply an unbounded backlog of
31 * superseded rows, so a single shared budget lets compaction starve pruning
32 * forever — and pruning is the only operation that advances the prune
33 * watermark. Pruning still gets whatever compaction leaves unused, so this
34 * is a floor for pruning, not a cap.
35 */
36 const MIN_PRUNE_DELETES_PER_RUN = 1000;
37
38 /**
39 * Slice of the ceiling reserved for mutation-store expiry, for the same
40 * reason pruning has a floor: journal work runs first and a saturated
41 * journal (≥ the full ceiling every 5-minute reschedule) would otherwise
42 * starve mutation expiry indefinitely — regrowing the exact table this
43 * expiry exists to bound.
44 */
45 const MIN_MUTATION_DELETES_PER_RUN = 500;
46
47 /** Default retention for settled (done/applied) non-create mutation rows. */
48 const DEFAULT_SETTLED_RETENTION_DAYS = 7;
49
50 /**
51 * Default retention for settled CREATE mutation rows. Longer than the
52 * general settled window because the mutation row is the only replay
53 * guard for a create whose record was later deleted server-side (see
54 * Mutation_Store::prune_settled()).
55 */
56 const DEFAULT_CREATE_RETENTION_DAYS = 90;
57
58 /** Default retention for failure (poison/blocked) rows: 0 = keep forever. */
59 const DEFAULT_FAILURE_RETENTION_DAYS = 0;
60
61 /**
62 * Change log being purged.
63 *
64 * @var Sync_Journal
65 */
66 private $sync_journal;
67
68 /**
69 * Mutation store whose settled rows are expired by the same run.
70 *
71 * @var Mutation_Store
72 */
73 private $mutation_store;
74
75 /**
76 * Side-effect-free constructor.
77 *
78 * @param Sync_Journal|null $sync_journal Change log to purge.
79 * @param Mutation_Store|null $mutation_store Mutation store to expire.
80 */
81 public function __construct( ?Sync_Journal $sync_journal = null, ?Mutation_Store $mutation_store = null ) {
82 $this->sync_journal = $sync_journal ?? new Sync_Journal();
83 $this->mutation_store = $mutation_store ?? new Mutation_Store();
84 }
85
86 /**
87 * Register the cron callback and ensure the daily event is scheduled.
88 */
89 public function register_hooks(): void {
90 add_action( self::PURGE_HOOK, array( __CLASS__, 'run_purge' ) );
91
92 if ( ! wp_next_scheduled( self::PURGE_HOOK ) ) {
93 wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', self::PURGE_HOOK );
94 }
95 }
96
97 /**
98 * Cron entry point for sync-journal retention.
99 */
100 public static function run_purge(): void {
101 ( new self() )->purge_expired();
102 }
103
104 /**
105 * Purge eligible rows, capped per run; a capped run reschedules itself.
106 */
107 public function purge_expired(): void {
108 $batch = max( 1, min( self::MAX_DELETES_PER_RUN, (int) apply_filters( 'woocommerce_pos_change_log_purge_batch_size', 500 ) ) );
109 $now = time();
110
111 $compaction_hours = max( 0, (int) apply_filters( 'woocommerce_pos_change_log_compaction_window_hours', 24 ) );
112 $compaction_gmt = gmdate( 'Y-m-d H:i:s', $now - $compaction_hours * HOUR_IN_SECONDS );
113 $compaction_cutoff = $this->sync_journal->sequence_at_or_before( $compaction_gmt );
114
115 $tombstone_days = (int) apply_filters( 'woocommerce_pos_change_log_tombstone_retention_days', 90 );
116 $tombstone_gmt = gmdate( 'Y-m-d H:i:s', $now - $tombstone_days * DAY_IN_SECONDS );
117 // Never let a served stream's head regress: clamp each object type's
118 // cutoff below ITS OWN newest row. An expired tombstone can be the newest
119 // row of its type while other types hold the global head above it;
120 // pruning it would drop that stream's head below live client checkpoints,
121 // whose cursor-past-head guard then forces a full resync. This also
122 // preserves the original rationale: an idle store whose last event is an
123 // old tombstone must not see a head regress, and MySQL 5.7 reuses
124 // AUTO_INCREMENT after restart.
125 //
126 // PER OBJECT TYPE, not per lane and not one global minimum.
127 //
128 // One global minimum starves. The streams share an AUTO_INCREMENT space,
129 // so a quiet catalogue's head sits far below the order lane's tombstones,
130 // and the lowest head would leave them unprunable forever — the
131 // unbounded-growth bug the unified journal exists to close.
132 //
133 // A whole-catalogue clamp is too coarse the other way. The sequence-log is
134 // independently readable narrowed to one collection
135 // (`?collection=tax_rates`), so a tax-rate tombstone that is the newest
136 // tax_rate row must survive even when a newer product row holds the
137 // catalogue head — otherwise that stream serves a head below its own
138 // horizon and its clients rebaseline on every poll.
139 //
140 // Per type is the granularity the watermarks already use, and it holds
141 // for any future narrowing of the read surface. It costs at most one
142 // un-prunable tombstone per object type (free#1560).
143 $tombstone_streams = array();
144 if ( $tombstone_days > 0 ) {
145 $age_cutoff = $this->sync_journal->sequence_at_or_before( $tombstone_gmt );
146 foreach ( array_unique( array_merge( array( 'order' ), Sync_Journal::catalogue_object_types() ) ) as $object_type ) {
147 if ( '' === $object_type ) {
148 continue;
149 }
150 $stream_head = $this->sync_journal->head_sequence( array( $object_type ) );
151 $stream_cutoff = $stream_head > 0 ? min( $age_cutoff, $stream_head - 1 ) : $age_cutoff;
152 if ( $stream_cutoff > 0 ) {
153 $tombstone_streams[] = array(
154 'types' => array( $object_type ),
155 'cutoff' => $stream_cutoff,
156 );
157 }
158 }
159 }
160
161 $pruning_active = array() !== $tombstone_streams;
162
163 // Mutation retention windows. The create window never drops below the
164 // general settled window — creates are the riskier class to prune.
165 $settled_days = max( 0, (int) apply_filters( 'woocommerce_pos_sync_mutation_settled_retention_days', self::DEFAULT_SETTLED_RETENTION_DAYS ) );
166 $create_days = max( $settled_days, (int) apply_filters( 'woocommerce_pos_sync_mutation_create_retention_days', self::DEFAULT_CREATE_RETENTION_DAYS ) );
167 $failure_days = max( 0, (int) apply_filters( 'woocommerce_pos_sync_mutation_failure_retention_days', self::DEFAULT_FAILURE_RETENTION_DAYS ) );
168
169 // Journal work runs first, so each active mutation-expiry phase needs a
170 // reserved floor or an earlier saturated phase would starve it every run.
171 $settled_floor = $settled_days > 0 ? self::MIN_MUTATION_DELETES_PER_RUN : 0;
172 $failure_floor = $failure_days > 0 ? self::MIN_MUTATION_DELETES_PER_RUN : 0;
173 $mutation_floor = $settled_floor + $failure_floor;
174
175 $compaction = $this->drain(
176 fn ( int $limit ): int => $this->sync_journal->compact( $compaction_cutoff, $compaction_gmt, $limit ),
177 $batch,
178 self::MAX_DELETES_PER_RUN - ( $pruning_active ? self::MIN_PRUNE_DELETES_PER_RUN : 0 ) - $mutation_floor
179 );
180 $deleted = $compaction['deleted'];
181 $capped = $compaction['capped'];
182
183 if ( $pruning_active ) {
184 // Every type is guaranteed an equal share of the pruning budget, so a
185 // saturated order lane cannot starve catalogue tombstones (or the
186 // reverse). Reserving only the LATER types' shares lets each one
187 // spend whatever the types before it left.
188 $prune_budget = max( 0, self::MAX_DELETES_PER_RUN - $deleted - $mutation_floor );
189 $stream_share = intdiv( $prune_budget, \count( $tombstone_streams ) );
190 $streams_left = \count( $tombstone_streams );
191 $prune_spent = 0;
192 foreach ( $tombstone_streams as $stream ) {
193 --$streams_left;
194 $ceiling = $prune_budget - $prune_spent - $streams_left * $stream_share;
195 if ( $ceiling <= 0 ) {
196 continue;
197 }
198 $pruning = $this->drain(
199 fn ( int $limit ): int => $this->sync_journal->prune_tombstones( $stream['cutoff'], $tombstone_gmt, $limit, $stream['types'] )['deleted'],
200 $batch,
201 $ceiling
202 );
203 $prune_spent += $pruning['deleted'];
204 $capped = $capped || $pruning['capped'];
205 }
206 $deleted += $prune_spent;
207 }
208
209 // Expire settled mutation rows (done/applied) past their replay window.
210 if ( $settled_days > 0 && $deleted < self::MAX_DELETES_PER_RUN ) {
211 $settled_gmt = gmdate( 'Y-m-d H:i:s', $now - $settled_days * DAY_IN_SECONDS );
212 $create_gmt = gmdate( 'Y-m-d H:i:s', $now - $create_days * DAY_IN_SECONDS );
213 $settled = $this->drain(
214 fn ( int $limit ): int => $this->mutation_store->prune_settled( $settled_gmt, $create_gmt, $limit ),
215 $batch,
216 self::MAX_DELETES_PER_RUN - $deleted - $failure_floor
217 );
218 $deleted += $settled['deleted'];
219 $capped = $capped || $settled['capped'];
220 }
221
222 // Failure rows (poison/blocked) are manual-recovery records: pruned
223 // only when a site opts into a window via this filter (0 = keep forever).
224 if ( $failure_days > 0 && $deleted < self::MAX_DELETES_PER_RUN ) {
225 $failure_gmt = gmdate( 'Y-m-d H:i:s', $now - $failure_days * DAY_IN_SECONDS );
226 $failures = $this->drain(
227 fn ( int $limit ): int => $this->mutation_store->prune_failed( $failure_gmt, $limit ),
228 $batch,
229 self::MAX_DELETES_PER_RUN - $deleted
230 );
231 $deleted += $failures['deleted'];
232 $capped = $capped || $failures['capped'];
233 }
234
235 // A capped run means backlog remains — drain it across bounded runs
236 // rather than waiting a day. WP dedupes identical single events
237 // scheduled within ten minutes, so this cannot stack.
238 if ( $capped ) {
239 wp_schedule_single_event( $now + 5 * MINUTE_IN_SECONDS, self::PURGE_HOOK );
240 }
241 }
242
243 /**
244 * Delete in batches until one operation runs dry or spends its ceiling.
245 *
246 * @param callable $delete_batch Receives a row limit, returns rows deleted.
247 * @param int $batch Rows to delete per call.
248 * @param int $ceiling Rows this operation may delete in total.
249 *
250 * @return array{deleted: int, capped: bool} Rows deleted, and whether the ceiling stopped it.
251 */
252 private function drain( callable $delete_batch, int $batch, int $ceiling ): array {
253 $deleted = 0;
254 while ( $deleted < $ceiling ) {
255 $limit = min( $batch, $ceiling - $deleted );
256 $count = (int) $delete_batch( $limit );
257 $deleted += $count;
258
259 if ( $count < $limit ) {
260 return array(
261 'deleted' => $deleted,
262 'capped' => false,
263 );
264 }
265 }
266
267 return array(
268 'deleted' => $deleted,
269 'capped' => $ceiling > 0,
270 );
271 }
272 }
273