shopwhizzy / module-stock-cron
Scheduled stock (qty / is_in_stock) import from a nightly CSV drop, with duplicate-file protection, targeted reindex and cache invalidation.
Package info
github.com/shopwhizzy/module-stock-cron
Type:magento2-module
pkg:composer/shopwhizzy/module-stock-cron
Requires
- php: ~8.1.0||~8.2.0||~8.3.0||~8.4.0
- magento/framework: *
- magento/module-backend: *
- magento/module-catalog: *
- magento/module-catalog-inventory: *
- magento/module-cron: *
- magento/module-indexer: *
README
Scheduled stock import for Magento 2. Reads a nightly CSV drop from an external client,
updates qty and is_in_stock, then reindexes and clears cache — without ever importing
the same file twice.
Built for and verified against this installation: Magento 2.4.7-p8, PHP 8.3, production
mode, Redis cache, Varnish full-page cache, MSI disabled (so the legacy
cataloginventory_stock_item table is the source of truth).
The file
var/stock/magento_stock_300726_201126.csv
^^^^^^ ^^^^^^
DDMMYY HHMMSS -> 30 Jul 2026, 20:11:26
sku,qty,is_in_stock 010008 orange,1,1 010008-Black,1,1 010008-Bluette,0,0
The timestamp in the name is the client's export time and is what the module orders and de-duplicates by. Both the mask and the timestamp format are admin-configurable, so if the client changes their naming you do not need a code change.
The reader is deliberately forgiving: BOM, , ; tab and | delimiters, header aliases
(quantity, stock status, in_stock, product_sku, …), 12,5 style decimals, and
yes/no/true/Y/N stock flags are all handled. Rows with an empty SKU or a non-numeric qty
are counted as invalid and logged with their line number rather than aborting the run.
Never importing the same file twice
Four independent layers, in the order they apply:
- Named lock (
shopwhizzy_stockcron_import) — cron and a manual CLI run can never overlap. A second runner backs off immediately rather than queuing. UNIQUE(filename)claim — a file is claimed with a singleINSERTintoshopwhizzy_stockcron_import_logbefore a byte is read. Two concurrent processes cannot both win; the loser gets a duplicate-key error and skips.- SHA-256 of the contents — the same export re-delivered under a new name is recognised and recorded as skipped.
- Client timestamp ordering — a file whose name timestamp predates the last successful import is refused, so a re-delivered old export cannot roll stock backwards.
A run killed mid-flight (OOM, kill -9) leaves a running row; those are automatically
released after 6 hours so the file becomes eligible again. Genuinely failed files are
retried up to 3 times, then need --force.
What a run does
- Pick the newest settled, unimported file. (A file must be untouched for
Settle Timeseconds — that is what stops a half-finished SFTP upload being read. Otherwise it is simply retried on the next cron tick.) - Stream it and diff every row against the catalog. Nothing is written yet.
- Optionally abort if the change volume exceeds the safety threshold — because the diff is complete before the first write, a corrupt export is rejected wholesale rather than half-applied.
- Write only the rows that actually differ, via
INSERT … ON DUPLICATE KEY UPDATEonUNIQUE(product_id, stock_id). - Expand the changed set with configurable/grouped/bundle parents and reindex.
- Invalidate cache (Varnish included).
- Archive the file and record the outcome.
Step 2 matters more than it looks. On this catalog only ~3,000 of 10,832 rows differ on a
typical night, so 72% of writes are skipped. cataloginventory_stock_item carries the mview
triggers, so writing all 10,832 rows would push 10,832 no-op entries into the indexer
changelog every night.
Reindex
Indexers are called via IndexerRegistry::reindexList() rather than the CatalogInventory
processor, because the processor silently short-circuits when an indexer is set to
"Update by Schedule" — which is how this install is configured.
Cache
This store fronts Magento with Varnish, so emptying the Redis full_page bucket alone
would leave stale pages served. Both paths go through the events Magento_CacheInvalidate
listens on, so Varnish is actually purged:
| Mode | Magento cache | Varnish |
|---|---|---|
| Targeted | cat_p_<id> tags for changed products |
per-product bans |
| Types (default) | cleans the selected types | full ban when full_page is selected |
| All | flushes all storage | full ban |
Configuration
Stores → Configuration → ShopWhizzy → Stock Import (Cron)
The section sits under its own ShopWhizzy tab so later modules from the same vendor have a
home alongside it, rather than being scattered through Magento's own tabs.
A Download Example CSV button in the General group serves the bundled
sample/stock-import-example.csv — hand it to whoever builds the export. It is deliberately
not named magento_stock_*.csv, so downloading it and dropping it into the inbox by accident
cannot trigger an import.
Upload A Stock File takes a CSV straight from the browser for a one-off correction or to try a new export. The columns are validated against the real import reader before the file is accepted, so a wrong format is rejected while you are still on the screen rather than at 08:00.
The upload is saved into the source directory under a normal feed name built from the mask and
the current time (magento_stock_050826_114500.csv) and then left alone — it goes through the
same locator, dedupe, diff and reindex path as a client delivery, so there is only ever one
import code path. It is stamped past the settle window, because unlike an FTP transfer an HTTP
upload is complete the moment it lands. Nothing is stored in the config value itself; the field
is an action, not a setting.
Installing or updating in production mode
This install runs in production mode, where new PHP classes need their interceptors generated and the admin action list rebuilt. After adding the module — or after any update that adds a controller, block or model — run:
bin/magento setup:upgrade bin/magento setup:di:compile bin/magento cache:flush
Skipping setup:di:compile is what makes a newly added admin route return 404: the class
exists on disk but has no generated interceptor and is absent from the cached action list.
Clearing caches alone does not fix it.
The schedule is a Start Time + Frequency (or a raw cron expression). Saving rewrites
crontab/default/jobs/shopwhizzy_stockcron_import/schedule/cron_expr, so it takes effect
without a deployment. Magento's own cron must be installed and running.
Defaults as shipped: disabled, var/stock, magento_stock_*.csv, daily 08:00, newest file
only, missing SKUs left untouched, changed-products-only reindex of
cataloginventory_stock + catalogsearch_fulltext, cleans full_page + block_html,
archive to var/stock/archive with 60-day retention.
Products not listed in the file defaults to leave untouched. The authoritative setting (force everything absent out of stock) is available, but a truncated export would then wipe stock store-wide — only use it if the client always sends every SKU.
CLI
# What would happen? Writes nothing, reindexes nothing, flushes nothing. bin/magento shopwhizzy:stock:import --dry-run # Run it now (works even while the module is disabled) bin/magento shopwhizzy:stock:import --force # One specific file; --force also clears that file's history so it can be re-imported bin/magento shopwhizzy:stock:import --file=magento_stock_300726_201126.csv --force # Every pending file oldest-first, instead of just the newest bin/magento shopwhizzy:stock:import --all # Per-SKU CSV report of exactly what was (or would be) classified how bin/magento shopwhizzy:stock:import --dry-run --report # Config, files waiting, recent history bin/magento shopwhizzy:stock:status
Exit code is non-zero if any file failed, so it drops straight into external monitoring.
--report
Writes one CSV row per feed row, so you can answer "which SKUs did this actually touch, and which did it leave alone?" after the fact — the import log itself only keeps counts.
Reports land in the configured Report Directory, var/stock/report by default (created
automatically, pruned on the same retention schedule as the archive):
| Invocation | Writes to |
|---|---|
--report |
var/stock/report/stock-report-<Ymd-His>.csv |
--report=nightly.csv |
var/stock/report/nightly.csv |
--report=/tmp/out.csv |
/tmp/out.csv (anything containing / is taken as given) |
Reports sit inside var/stock but are never mistaken for feeds — the locator's glob does not
recurse into subdirectories, and the names do not match the feed mask either.
sku,status,product_id,qty_before,qty_after,is_in_stock_before,is_in_stock_after,note 010008-Bluette,changed,27580,0,99,0,1, 012001-Blu,unchanged,29217,0,0,0,0, THIS-SKU-DOES-NOT-EXIST-999,not_found,,,5,,1, ,invalid,,,,,,"line 10834: empty SKU"
Statuses: changed, unchanged, not_found, invalid, forced_out_of_stock.
Rows are streamed as each batch is diffed, so the report costs no more memory than the batch.
The path is opened before any stock is written, so an unwritable location fails the run early
rather than halfway through. Under --dry-run it classifies every SKU while touching nothing.
Visibility
- System → Data Transfer → Stock Import History — every file ever seen, with row counts, duration, and why anything was skipped.
var/log/shopwhizzy_stockcron.log— its own channel, not system.log.- Admin notification (and optional email) on failure.
- Stale-feed watchdog: if no file arrives for N days you get told. A feed that quietly stops looks identical to a healthy one until the catalog is badly wrong.
Notes
- The full diff is held in memory before writing (~a few MB at 10k rows; scales linearly). This is the trade for all-or-nothing safety. At 500k+ rows, raise PHP's memory limit for cron or lower the safety threshold to 0 and split the feed.
- Archiving needs write permission on the source directory. The files may be owned by someone else — only the directory needs to be writable to move them.
Directories
The source, archive and report directories are all created on demand at 0770, so a fresh
install needs no manual mkdir. Where the parent is setgid (as var/stock is here) the group
is inherited, which is what keeps the delivering account and the cron user sharing access.
Creation happens as whoever runs the import first. If that is ever root — one sudo bin/magento is enough — the directory is left owned by root and every later cron run fails.
The module reports that case precisely rather than with a bare "could not be opened", naming
the mode, the owner, the account doing the work and the chown that fixes it.
Permissions
The delivering account and the cron user do not need to be the same user, but the cron user
must be able to read the drop. On this install that works because stockftp is a member of
the u1b63e30fce588 group, uploads land 0660, and var/stock is setgid 2770 so the group
is inherited by the archive directory too.
That makes the whole thing dependent on the delivering account's umask. If it ever tightens to
0077, files arrive 0600 and the cron user cannot read them. The module detects this before
claiming the file and says exactly what is wrong:
Cannot read magento_stock_050826_090000.csv (mode 0600, owned by stockftp:u1b63e30fce588,
being read as u1b63e30fce588). Left in place; it will import automatically once the
permissions are fixed. Check the umask of the account delivering the file.
It is deliberately not claimed, so the file stays eligible and imports itself the moment the
mode is corrected — rather than consuming the retry budget and needing --force afterwards.
shopwhizzy:stock:status shows such files as blocked with a Readable: NO column.
- Turning archiving off is safe: history lives in the database, not on disk.