PluginProbe
ووسلام – همگام سازی ووکامرس و باسلام / 1.10.19
ووسلام – همگام سازی ووکامرس و باسلام v1.10.19
1.10.19 1.10.20 1.10.18 1.10.17 1.10.15 1.10.14 1.10.13 1.10.12 1.10.10 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 1.10.2 1.10.1 1.10.0 1.9.2 1.9.1 1.9.0 1.8.8 1.8.5 1.8.6 All 53 releases
← All changes | JobManager.php +289 -230 1.8.51.10.19 View file →
@@ -1,256 +1,315 @@
1 -<?php
2 -
3 -namespace SyncBasalam;
4 -
5 -defined('ABSPATH') || exit;
6 -
7 -class JobManager
8 -{
9 - private $jobManagerTableName;
10 -
11 - private const ALLOWED_COLUMNS = [
12 - 'id', 'job_type', 'status', 'payload',
13 - 'attempts', 'max_attempts', 'retry_after',
14 - 'started_at', 'created_at', 'failed_at', 'error_message',
15 - ];
16 -
17 - function __construct()
18 - {
19 - global $wpdb;
20 - $this->jobManagerTableName = $wpdb->prefix . 'sync_basalam_job_manager';
21 - }
22 -
23 - public function createJob($jobType, $status = 'pending', $payload = null, $maxAttempts = 3)
24 - {
25 - global $wpdb;
26 -
27 - return $wpdb->insert(
1 +<?php
2 +
3 +namespace SyncBasalam;
4 +
5 +defined('ABSPATH') || exit;
6 +
7 +class JobManager
8 +{
9 + private $jobManagerTableName;
10 +
11 + private const ALLOWED_COLUMNS = [
12 + 'id', 'job_type', 'status', 'payload',
13 + 'attempts', 'max_attempts', 'retry_after',
14 + 'started_at', 'created_at', 'failed_at', 'error_message',
15 + ];
16 +
17 + function __construct()
18 + {
19 + global $wpdb;
20 + $this->jobManagerTableName = $wpdb->prefix . 'sync_basalam_job_manager';
21 + }
22 +
23 + public function createJob($jobType, $status = 'pending', $payload = null, $maxAttempts = 3)
24 + {
25 + global $wpdb;
26 +
27 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table; no object cache for these operational queries.
28 + $result = $wpdb->insert(
28 29 $this->jobManagerTableName,
29 30 array(
30 31 'job_type' => $jobType,
31 32 'status' => $status,
32 - 'payload' => $payload,
33 - 'attempts' => 0,
34 - 'max_attempts' => $maxAttempts,
33 + 'payload' => $payload,
34 + 'attempts' => 0,
35 + 'max_attempts' => $maxAttempts,
35 36 'created_at' => time(),
36 37 )
37 38 );
38 - }
39 39
40 - public function getNextEligibleJob(string $jobType): ?object
41 - {
42 - global $wpdb;
43 -
44 - return $wpdb->get_row($wpdb->prepare(
45 - "SELECT * FROM {$this->jobManagerTableName}
46 - WHERE job_type = %s
47 - AND status = 'pending'
48 - AND (retry_after IS NULL OR retry_after <= %d)
49 - ORDER BY id ASC
50 - LIMIT 1",
51 - $jobType,
52 - time()
53 - ));
54 - }
55 -
56 - public function getJob($where = array())
57 - {
58 - global $wpdb;
59 -
60 - if (empty($where)) return null;
61 -
62 - $conditions = [];
63 - $values = [];
64 -
65 - foreach ($where as $column => $value) {
66 - if (!in_array($column, self::ALLOWED_COLUMNS, true)) {
67 - throw new \InvalidArgumentException("Invalid column: {$column}");
68 - }
69 - $conditions[] = "{$column} = %s";
70 - $values[] = $value;
40 + if ($result !== false) {
41 + do_action('sync_basalam_job_created', $jobType, $status, $payload);
71 42 }
72 43
73 - $sql = "SELECT * FROM {$this->jobManagerTableName} WHERE " . implode(" AND ", $conditions) . " LIMIT 1";
74 -
75 - return $wpdb->get_row($wpdb->prepare($sql, $values));
44 + return $result;
76 45 }
77 -
78 - public function getCountJobs($where = array())
46 +
47 + public function getNextEligibleJob(string $jobType): ?object
79 48 {
80 49 global $wpdb;
81 -
82 - if (empty($where)) return 0;
83 -
84 - $conditions = [];
85 - $values = [];
86 -
87 - foreach ($where as $column => $value) {
88 - if (!in_array($column, self::ALLOWED_COLUMNS, true)) {
89 - throw new \InvalidArgumentException("Invalid column: {$column}");
90 - }
91 - if (is_array($value)) {
92 - $placeholders = array_fill(0, count($value), '%s');
93 - $conditions[] = "{$column} IN (" . implode(',', $placeholders) . ")";
94 - $values = array_merge($values, $value);
95 - } else {
96 - $conditions[] = "{$column} = %s";
97 - $values[] = $value;
98 - }
99 - }
100 -
101 - $sql = "SELECT COUNT(*) FROM {$this->jobManagerTableName} WHERE " . implode(" AND ", $conditions);
102 -
103 - return (int) $wpdb->get_var($wpdb->prepare($sql, $values));
50 +
51 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom plugin table; identifier from $wpdb->prefix, not user input.
52 + return $wpdb->get_row($wpdb->prepare(
53 + "SELECT * FROM {$this->jobManagerTableName}
54 + WHERE job_type = %s
55 + AND status = 'pending'
56 + AND (retry_after IS NULL OR retry_after <= %d)
57 + ORDER BY id ASC
58 + LIMIT 1",
59 + $jobType,
60 + time()
61 + ));
104 62 }
105 63
106 - public function updateJob($jobData, $where = array())
64 + public function hasAnyProcessingJob(): bool
107 65 {
108 - global $wpdb;
109 -
110 - if (empty($where) || empty($jobData)) return false;
111 -
112 - return $wpdb->update($this->jobManagerTableName, $jobData, $where);
66 + return $this->getCountJobs(['status' => 'processing']) > 0;
113 67 }
114 68
115 - public function deleteJob($where = array())
69 + public function hasPendingOrStaleProcessingJobs(int $staleProcessingTimeoutSeconds = 120): bool
116 70 {
117 71 global $wpdb;
118 72
119 - if (empty($where)) return false;
73 + $now = time();
74 + $staleBefore = $now - $staleProcessingTimeoutSeconds;
120 75
121 - return $wpdb->delete($this->jobManagerTableName, $where);
122 - }
123 -
124 - public function ConvertStaleProcessingJobs($timeoutSeconds = 120)
125 - {
126 - global $wpdb;
127 -
128 - $timeoutTimestamp = time() - $timeoutSeconds;
129 -
130 - return $wpdb->query(
131 - $wpdb->prepare(
132 - "UPDATE {$this->jobManagerTableName}
133 - SET status = 'pending', started_at = NULL
134 - WHERE status = 'processing'
135 - AND job_type = 'sync_basalam_bulk_update_products'
136 - AND started_at IS NOT NULL
137 - AND started_at < %d",
138 - $timeoutTimestamp
139 - )
140 - );
141 -
142 - }
143 -
144 - public function hasProductJobInProgress(int $productId, string $jobType): bool
145 - {
146 - global $wpdb;
147 -
148 - $jobs = $wpdb->get_results($wpdb->prepare(
149 - "SELECT payload FROM {$this->jobManagerTableName}
150 - WHERE job_type = %s
151 - AND (status = %s OR status = %s)",
152 - $jobType,
153 - 'pending',
154 - 'processing'
76 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom plugin table; identifier from $wpdb->prefix, not user input.
77 + $result = $wpdb->get_var($wpdb->prepare(
78 + "SELECT 1 FROM {$this->jobManagerTableName}
79 + WHERE (
80 + status = 'pending'
81 + AND (retry_after IS NULL OR retry_after <= %d)
82 + ) OR (
83 + status = 'processing'
84 + AND started_at IS NOT NULL
85 + AND started_at < %d
86 + )
87 + LIMIT 1",
88 + $now,
89 + $staleBefore
155 90 ));
156 91
157 - if (empty($jobs)) {
158 - return false;
159 - }
160 -
161 - foreach ($jobs as $job) {
162 - $payload = json_decode($job->payload, true);
163 - $jobProductId = $payload['product_id'] ?? $payload;
164 -
165 - if (intval($jobProductId) === intval($productId)) {
166 - return true;
167 - }
168 - }
169 -
170 - return false;
92 + return (string) $result === '1';
171 93 }
172 94
173 - public function retryJob(int $jobId, ?string $errorMessage = null): bool
174 - {
175 - global $wpdb;
176 -
177 - $job = $wpdb->get_row($wpdb->prepare(
178 - "SELECT * FROM {$this->jobManagerTableName} WHERE id = %d",
179 - $jobId
180 - ));
181 -
182 - if (!$job) return false;
183 -
184 - $newAttempts = intval($job->attempts) + 1;
185 -
186 - $errorMessages = [];
187 - if (!empty($job->error_message)) {
188 - $decoded = json_decode($job->error_message, true);
189 - if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) $errorMessages = $decoded;
190 - }
191 -
192 - if ($errorMessage) $errorMessages[$newAttempts] = $errorMessage;
193 -
194 - $encodedErrors = json_encode($errorMessages, JSON_UNESCAPED_UNICODE);
195 -
196 - if ($newAttempts >= intval($job->max_attempts)) {
197 - $this->updateJob(
198 - [
199 - 'status' => 'failed',
200 - 'error_message' => $encodedErrors,
201 - 'failed_at' => time(),
202 - 'started_at' => 0,
203 - 'attempts' => $newAttempts,
204 - ],
205 - ['id' => $jobId]
206 - );
207 - return false;
208 - }
209 -
210 - // Progressive exponential backoff: 30s, 60s, 120s, 240s, ...
211 - $delaySeconds = 30 * (int) pow(2, $newAttempts - 1);
212 - $retryAfter = time() + $delaySeconds;
213 -
214 - // Atomic DELETE + INSERT inside a transaction so a crash can't lose the job.
215 - $wpdb->query('START TRANSACTION');
216 - try {
217 - $wpdb->delete($this->jobManagerTableName, ['id' => $jobId]);
218 -
219 - $wpdb->insert(
220 - $this->jobManagerTableName,
221 - [
222 - 'job_type' => $job->job_type,
223 - 'status' => 'pending',
224 - 'payload' => $job->payload,
225 - 'attempts' => $newAttempts,
226 - 'max_attempts' => $job->max_attempts,
227 - 'error_message' => $encodedErrors,
228 - 'created_at' => $job->created_at,
229 - 'retry_after' => $retryAfter,
230 - 'started_at' => 0,
231 - ]
232 - );
233 -
234 - $wpdb->query('COMMIT');
235 - } catch (\Exception $e) {
236 - $wpdb->query('ROLLBACK');
237 - throw $e;
238 - }
239 -
240 - return true;
241 - }
242 -
243 - public function failJob(int $jobId, ?string $errorMessage = null): bool
244 - {
245 - return $this->updateJob(
246 - [
247 - 'status' => 'failed',
248 - 'error_message' => $errorMessage,
249 - 'failed_at' => time(),
250 - 'started_at' => 0,
251 - ],
252 - ['id' => $jobId]
253 - );
254 - }
255 -
256 -}
95 + public function getJob($where = array())
96 + {
97 + global $wpdb;
98 +
99 + if (empty($where)) return null;
100 +
101 + $conditions = [];
102 + $values = [];
103 +
104 + foreach ($where as $column => $value) {
105 + if (!in_array($column, self::ALLOWED_COLUMNS, true)) {
106 + throw new \InvalidArgumentException(esc_html("Invalid column: {$column}"));
107 + }
108 + $conditions[] = "{$column} = %s";
109 + $values[] = $value;
110 + }
111 +
112 + $sql = "SELECT * FROM {$this->jobManagerTableName} WHERE " . implode(" AND ", $conditions) . " LIMIT 1";
113 +
114 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom plugin table; identifiers from $wpdb->prefix and whitelisted column list, not user input; values are prepared.
115 + return $wpdb->get_row($wpdb->prepare($sql, $values));
116 + }
117 +
118 + public function getCountJobs($where = array())
119 + {
120 + global $wpdb;
121 +
122 + if (empty($where)) return 0;
123 +
124 + $conditions = [];
125 + $values = [];
126 +
127 + foreach ($where as $column => $value) {
128 + if (!in_array($column, self::ALLOWED_COLUMNS, true)) {
129 + throw new \InvalidArgumentException(esc_html("Invalid column: {$column}"));
130 + }
131 + if (is_array($value)) {
132 + $placeholders = array_fill(0, count($value), '%s');
133 + $conditions[] = "{$column} IN (" . implode(',', $placeholders) . ")";
134 + $values = array_merge($values, $value);
135 + } else {
136 + $conditions[] = "{$column} = %s";
137 + $values[] = $value;
138 + }
139 + }
140 +
141 + $sql = "SELECT COUNT(*) FROM {$this->jobManagerTableName} WHERE " . implode(" AND ", $conditions);
142 +
143 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom plugin table; identifiers from $wpdb->prefix and whitelisted column list, not user input; values are prepared.
144 + return (int) $wpdb->get_var($wpdb->prepare($sql, $values));
145 + }
146 +
147 + public function updateJob($jobData, $where = array())
148 + {
149 + global $wpdb;
150 +
151 + if (empty($where) || empty($jobData)) return false;
152 +
153 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table; no object cache for these operational queries.
154 + return $wpdb->update($this->jobManagerTableName, $jobData, $where);
155 + }
156 +
157 + public function deleteJob($where = array())
158 + {
159 + global $wpdb;
160 +
161 + if (empty($where)) return false;
162 +
163 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table; no object cache for these operational queries.
164 + return $wpdb->delete($this->jobManagerTableName, $where);
165 + }
166 +
167 + public function ConvertStaleProcessingJobs($timeoutSeconds = 120)
168 + {
169 + global $wpdb;
170 +
171 + $timeoutTimestamp = time() - $timeoutSeconds;
172 +
173 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom plugin table; identifier from $wpdb->prefix, not user input.
174 + $wpdb->query(
175 + $wpdb->prepare(
176 + "UPDATE {$this->jobManagerTableName}
177 + SET status = CASE
178 + WHEN attempts + 1 >= max_attempts THEN 'failed'
179 + ELSE 'pending'
180 + END,
181 + attempts = attempts + 1,
182 + started_at = NULL,
183 + failed_at = CASE
184 + WHEN attempts + 1 >= max_attempts THEN %d
185 + ELSE failed_at
186 + END
187 + WHERE status = 'processing'
188 + AND started_at IS NOT NULL
189 + AND started_at < %d",
190 + time(),
191 + $timeoutTimestamp
192 + )
193 + );
194 + }
195 +
196 + public function hasProductJobInProgress(int $productId, string $jobType): bool
197 + {
198 + global $wpdb;
199 +
200 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom plugin table; identifier from $wpdb->prefix, not user input.
201 + $jobs = $wpdb->get_results($wpdb->prepare(
202 + "SELECT payload FROM {$this->jobManagerTableName}
203 + WHERE job_type = %s
204 + AND (status = %s OR status = %s)",
205 + $jobType,
206 + 'pending',
207 + 'processing'
208 + ));
209 +
210 + if (empty($jobs)) {
211 + return false;
212 + }
213 +
214 + foreach ($jobs as $job) {
215 + $payload = json_decode($job->payload, true);
216 + $jobProductId = $payload['product_id'] ?? $payload;
217 +
218 + if (intval($jobProductId) === intval($productId)) {
219 + return true;
220 + }
221 + }
222 +
223 + return false;
224 + }
225 +
226 + public function retryJob(int $jobId, ?string $errorMessage = null): bool
227 + {
228 + global $wpdb;
229 +
230 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom plugin table; identifier from $wpdb->prefix, not user input.
231 + $job = $wpdb->get_row($wpdb->prepare(
232 + "SELECT * FROM {$this->jobManagerTableName} WHERE id = %d",
233 + $jobId
234 + ));
235 +
236 + if (!$job) return false;
237 +
238 + $newAttempts = intval($job->attempts) + 1;
239 +
240 + $errorMessages = [];
241 + if (!empty($job->error_message)) {
242 + $decoded = json_decode($job->error_message, true);
243 + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) $errorMessages = $decoded;
244 + }
245 +
246 + if ($errorMessage) $errorMessages[$newAttempts] = $errorMessage;
247 +
248 + $encodedErrors = json_encode($errorMessages, JSON_UNESCAPED_UNICODE);
249 +
250 + if ($newAttempts >= intval($job->max_attempts)) {
251 + $this->updateJob(
252 + [
253 + 'status' => 'failed',
254 + 'error_message' => $encodedErrors,
255 + 'failed_at' => time(),
256 + 'started_at' => 0,
257 + 'attempts' => $newAttempts,
258 + ],
259 + ['id' => $jobId]
260 + );
261 + return false;
262 + }
263 +
264 + // Progressive exponential backoff: 30s, 60s, 120s, 240s, ...
265 + $delaySeconds = 30 * (int) pow(2, $newAttempts - 1);
266 + $retryAfter = time() + $delaySeconds;
267 +
268 + // Atomic DELETE + INSERT inside a transaction so a crash can't lose the job.
269 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic job requeue; no object cache applicable.
270 + $wpdb->query('START TRANSACTION');
271 + try {
272 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table; no object cache for these operational queries.
273 + $wpdb->delete($this->jobManagerTableName, ['id' => $jobId]);
274 +
275 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom plugin table; no object cache for these operational queries.
276 + $wpdb->insert(
277 + $this->jobManagerTableName,
278 + [
279 + 'job_type' => $job->job_type,
280 + 'status' => 'pending',
281 + 'payload' => $job->payload,
282 + 'attempts' => $newAttempts,
283 + 'max_attempts' => $job->max_attempts,
284 + 'error_message' => $encodedErrors,
285 + 'created_at' => $job->created_at,
286 + 'retry_after' => $retryAfter,
287 + 'started_at' => 0,
288 + ]
289 + );
290 +
291 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic job requeue; no object cache applicable.
292 + $wpdb->query('COMMIT');
293 + } catch (\Exception $e) {
294 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Transaction control for atomic job requeue; no object cache applicable.
295 + $wpdb->query('ROLLBACK');
296 + throw $e;
297 + }
298 +
299 + return true;
300 + }
301 +
302 + public function failJob(int $jobId, ?string $errorMessage = null): bool
303 + {
304 + return $this->updateJob(
305 + [
306 + 'status' => 'failed',
307 + 'error_message' => $errorMessage,
308 + 'failed_at' => time(),
309 + 'started_at' => 0,
310 + ],
311 + ['id' => $jobId]
312 + );
313 + }
314 +
315 +}