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.

Maintainers

Package info

github.com/shopwhizzy/module-stock-cron

Type:magento2-module

pkg:composer/shopwhizzy/module-stock-cron

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-08-05 11:11 UTC

This package is auto-updated.

Last update: 2026-08-05 11:15:15 UTC


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:

  1. Named lock (shopwhizzy_stockcron_import) — cron and a manual CLI run can never overlap. A second runner backs off immediately rather than queuing.
  2. UNIQUE(filename) claim — a file is claimed with a single INSERT into shopwhizzy_stockcron_import_log before a byte is read. Two concurrent processes cannot both win; the loser gets a duplicate-key error and skips.
  3. SHA-256 of the contents — the same export re-delivered under a new name is recognised and recorded as skipped.
  4. 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

  1. Pick the newest settled, unimported file. (A file must be untouched for Settle Time seconds — that is what stops a half-finished SFTP upload being read. Otherwise it is simply retried on the next cron tick.)
  2. Stream it and diff every row against the catalog. Nothing is written yet.
  3. 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.
  4. Write only the rows that actually differ, via INSERT … ON DUPLICATE KEY UPDATE on UNIQUE(product_id, stock_id).
  5. Expand the changed set with configurable/grouped/bundle parents and reindex.
  6. Invalidate cache (Varnish included).
  7. 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.