In short
Siganos SA sells through a WooCommerce shop in Greek and English, and its catalogue lives in Entersoft. Every product has to exist twice in WordPress, once per language, with the right prices, stock, attributes and images. The shop runs on shared hosting, where a PHP request that works for too long simply gets killed. This post is about the plumbing that makes that sync boring: a cron endpoint that hangs up before it starts working, a cursor that survives crashes, a lock that copes with two processes waking up at once, and the checks that stop a SKU turning into two products. The business side of the project is in the Siganos case study.
The constraint nobody puts in the spec
On a dedicated server you would write the import as one long job and go to bed. On shared hosting that job dies half way through, usually on a product that was only half saved. WooCommerce product saves are not cheap either: every save fires hooks, recounts terms and clears transients, and here every SKU means two products that Polylang has to link together.
The Entersoft side is heavy too. One saved response from the items query held 149 rows and weighed about 700 KB, because each row carries Greek and English names, descriptions and attributes, B2C and B2B prices, stock and a large catalogue field. You do not want to fetch that more often than you have to.
So the design goal was simple to state: no single request should ever do much, and any request that dies should cost almost nothing.
Answer the cron first, then do the work
A server cron calls our endpoint every five minutes. If that endpoint does the sync inside the request, the caller waits, the web server's timeout ticks and eventually something gets killed. So the endpoint tells the caller it's done before it has started:
header('Connection: close');
header('Content-Length: 2');
echo 'OK';
flush();
if (function_exists('fastcgi_finish_request')) fastcgi_finish_request();
if (function_exists('litespeed_finish_request')) litespeed_finish_request();
ignore_user_abort(true);
set_time_limit(0);
// only now load WordPress and process the next batchThe caller gets its two bytes and disconnects. The PHP worker carries on with the batch. The admin screen's Process Now button uses the same idea: it fires a non-blocking loopback request with a 0.01 second timeout, so clicking it never leaves a browser tab spinning.
Fetch once, then walk a cursor
The full Entersoft response is stored once, as JSON, in a row of our own sync table, together with a cursor and the total count Entersoft reported. Every run after that takes the next slice and moves the cursor before it processes anything:
$batch = array_slice($data, $job->lastInseredID, 15);
$wpdb->update($table, ['lastInseredID' => $job->lastInseredID + count($batch)], ['id' => $job->id]);
foreach ($batch as $row) { /* create or update the EL and EN products */ }Moving the cursor first is a deliberate trade. If the worker dies mid-batch, those 15 products wait for the next full sync instead of being retried forever and blocking everything behind them. A crash costs at most one batch, and continuing a batch never touches the Entersoft API.
The batch size started at 50. With two languages, attributes and images per SKU, 50 products regularly ran past what the host allowed, so we brought it down to 15. Smaller batches mean more cron ticks for a full catalogue, and that's fine: the job runs in the background and nobody is watching it.
The same table holds rows for categories, catalogues, stock balances and images, each with its own module name, so every kind of sync resumes the same way.
A lock that copes with races
There are several ways into the sync: the five-minute cron, a thirty-minute safety cron, the daily full refresh and the admin buttons. Two of them will eventually wake up at the same moment. A plain "is there a lock? no? take it" check has a gap between the read and the write, and both processes walk through it.
The lock is a WordPress transient that stores the owner's process id (pid plus microtime). After writing it, the process sleeps for 50 to 100 milliseconds, reads the lock back and only carries on if it still owns it. Otherwise it logs SKIP:race and exits. It's not a database mutex, but on this hosting it closed the gap completely.
Locks also go stale when a worker is killed. The transient expires after an hour, and every cron treats a lock older than 30 minutes as abandoned and clears it. There's a Clear Sync Lock button in the admin for the rare case someone needs it sooner.
One SKU, two languages, never two products
The expensive bug in a bilingual WooCommerce import is the duplicate. The same SKU legitimately exists twice, once as the Greek product and once as the English one, so a plain SKU lookup finds the wrong one or none at all, and the import creates a third.
Every lookup here joins the SKU with the product's language meta and falls back to the Polylang language taxonomy if that meta is missing. Each batch preloads a SKU cache per language, so we're not querying the database once per row. Just before an insert there's a second, direct database check, and when it finds something it logs SAFETY: Found existing product and updates instead. A small SKU-to-ID map table with a unique (id, sku, lang) index and INSERT IGNORE makes the mapping itself impossible to duplicate.
Belt and braces, yes. A duplicate product costs a morning of manual clean-up and confuses customers who find two versions of the same thing. The extra query per insert costs nothing anyone can measure.
Respect the API quota
Entersoft access is metered, and a sync that re-fetches on every retry burns through a quota fast. Since February 2026 the rule is strict: the five and thirty minute crons may only continue a batch that's already stored. Only the daily refresh and a manual queue request are allowed to call Entersoft. One secondary check that also cost API calls is switched off until the quota is sorted out.
One failure taught us to validate before storing. An Entersoft error response once got saved as if it were a batch of products, and the cron dutifully tried to walk it. Every entry point now checks that the first element is a real product row and marks the job inactive with SKIP:invalid_data if it isn't.
Making WooCommerce saves cheaper
During a run we defer term and comment counting and unhook WooCommerce's product transient clean-up from the save hooks, then clear transients once at the end. On a batch of 15 products in two languages that removes a lot of repeated work.
Images come from per-SKU folders on the server rather than from the API. After each batch's products are saved, the list of (product, SKU, language) that need images is queued as its own job. The image step compares file modification times, so it only re-attaches pictures that actually changed, and it removes attachments that no longer belong to anything.
What we would tell anyone doing this
- Design for the request being killed, not for it finishing. Then the host's limits stop being your problem.
- Store the ERP payload once and resume from it. Re-fetching on every retry is how quotas and nights disappear.
- Treat language as part of the product's identity in every lookup, not as a label you add afterwards.
- Give the client's team the recovery buttons (clear queue, clear lock, process now) and log every skip with a reason. Most support questions answer themselves from the log.
If you run WooCommerce against Entersoft or any other ERP and your sync is fragile, we can help with WooCommerce development and ERP integration. Get in touch.