Skip to content

fix: address the review findings before the first tag - #2

Merged
ddtcorex merged 11 commits into
masterfrom
fix/review-findings
Sep 30, 2026
Merged

ddtcorex merged 11 commits into
masterfrom
fix/review-findings

Conversation

@ddtcorex

@ddtcorex ddtcorex commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

Summary

Fixes the ten confirmed review findings on the bridge before the first tag. One commit per finding, each with a failing test first where the behaviour is unit-testable, and each verified against a live Magento 2.4.9 Open Source store (developer mode, admin token, /rest/all/ unless stated) running this branch from app/code. CHANGELOG.md keeps [1.0.0] - unreleased and gains a Fixed section. Nothing is tagged or released.

One finding did not reproduce as stated (1). The raw adapter transaction did not lose the after-commit callbacks: core registers Magento\Framework\Model\ExecuteCommitCallbacks as a plugin on AdapterInterface::commit/rollBack (in app/etc/di.xml of 2.4.9, and of 2.4.6 upstream), so catalog_category_save_commit_after fired at transaction level 0 and category flat rows were written both before and after the change. The commit keeps the decided switch to the category resource model's transaction API, and the part that was really broken, rolling back on \Throwable and not only \Exception.

Commits

# Commit Test (red first)
1 fix(categories): roll back on any Throwable via the category resource testAnErrorThatIsNotAnExceptionStillRollsBack, testSuccessCommitsThroughTheCategoryResourceModel
2 fix(categories): always use the native processor and validate the root testASlashInsideANameIsEscapedForTheNativeProcessor, testMissingRootIsRejectedBeforeAnythingIsCreated, testRootMatchesCaseInsensitivelyAsTheNativeProcessorDoes, testTheRootIsReadAtTheAdminStore, testANameEndingWithABackslashIsRejected, parser testParseRecognisesTheRootCaseInsensitively...
3 fix(categories): serialise concurrent upserts with a named lock testTheProcessorIsBuiltUnderTheLockAndTheLockIsReleased, testTheLockIsReleasedWhenTheUpsertFails, testTheLockIsNamedAndBounded, testALockTimeoutAnswersServiceUnavailable
4 fix(attribute-values): refuse attributes outside the standard tables testStaticAttributeThatIsNotAProductColumnIsUnsupported, testAttributeWhoseBackendIsNotScalarIsUnsupported, testAttributeWithACustomBackendTableIsUnsupported, testUnsupportedAttributeCodesAreRejectedAndListed
5 fix(index): declare Commerce content staging unsupported testStatusJoinFollowsTheLinkFieldOnCommerceStaging replaced by testStatusJoinTakesTheLinkFieldFromTheMetadataPool (no behaviour change)
6 fix(attribute-values): match SKUs case-insensitively like the database testASkuSpelledInAnotherCaseAnswersUnderTheRequestedSpelling, testAStaticValueIsFoundForASkuSpelledInAnotherCase
7 fix(validation): reject empty lists, empty separators and unknown stores testEmptySkusAreRejected, testEmptyAttributeCodesAreRejected, testAStoreThatDoesNotExistIsRejected, testAnEmptySeparatorIsRejected
8 fix(composer): drop the version field and pin real Magento ranges testVersionIsTheTopReleaseHeadingOfTheChangelog, testComposerJsonCarriesNoVersionField
9 fix(docs): make the install and development notes true docs only
10 fix(test): pin value binding through an escaping quote double testCallerValuesAreBoundThroughTheAdapterQuoting (a coverage test: passes on the existing code, and fails when the double is mutated to paste raw values)

Design notes

  • 2: the repository branch and Model/CategoryFactory.php are gone. Every path goes through the native CategoryProcessor, a / inside a name is escaped as the processor's own \/, and a name ending with \ is rejected (the processor cannot express it). The root is checked at store 0, case-insensitively as the processor compares names. The native semantics are documented in README ("Category upsert semantics").
  • 3: the lock alone is not enough. The processor loads the whole tree in its constructor, so an injected shared instance answers from the tree as it was when the request started. It is now built per call, after the lock is held, through a module-owned CategoryProcessorFactory. Lock timeout 15 s, then a Webapi\Exception with HTTP 503.
  • 4: tier_price declares no backend_table, so getBackendTable() resolves to catalog_product_entity_decimal and a table check alone does not catch it. Its backend reports isScalar() === false, the signal Magento's own EAV save uses, so the check is: static codes must be product table columns (describeTable), other codes must resolve through getBackendTable() to one of the five standard tables and have a scalar backend.
  • 8: ranges come from the components the Mage-OS mirror lists for magento/product-community-edition 2.4.6 and 2.4.9 (framework 103.0.6 / 103.0.9, catalog 104.0.6 / .9, catalog-import-export 101.1.6 / .9, store 101.1.6 / .9, backend 102.0.6 / .9, eav 102.1.6 / .9). magento/module-eav was used but never required; it is now required and sequenced. The tilde ranges (~103.0.6, ...) also admit later 2.4 patch lines; say so if you prefer a hard cap at 2.4.9. The Mage-OS repositories block stays because Packagist hosts none of the magento/* packages (checked: 404 for all six) and Composer reads repositories from the root package only; README Development explains it.

Live evidence

Defect Request Before (origin/master) After (this branch) DB / indexer effect
1 POST categories/upsert {"paths":["FlatBefore/Child"]} then FlatAfter/Child, category flat enabled, flat indexer Update on Save, a temporary observer logging catalog_category_save_after / _save_commit_after with the transaction level 200; save_after at txlevel 3, save_commit_after at txlevel 0 for both ids identical: 200, save_commit_after at txlevel 0 new ids present in catalog_category_flat_store_1 in both runs: the callbacks were not lost before the change
1 a failing upsert (url key collision) n/a 400, probe log empty rollback left no callback behind
2 {"paths":["X"],"root":"No Such Root"} 200, id 19 400 The root category "..." does not exist. before: a new tree root (id 18) was created; after: category count unchanged
2 inactive Men (is_active=0), then {"paths":["Men|T/Shirts"],"separator":"|"} 400, url key already exists 200, id 36, parent 20 (the inactive Men); repeated call answers 36 again one T/Shirts row, stored literally
2 {"paths":["women|A/B"],"separator":"|"} 400, url key already exists 200, lands under Women (id 6) no second women sibling
2 {"paths":["Men/Shirts","Men/Shirts"]} then again n/a same id (21) three times no duplicate siblings (GROUP BY parent, LOWER(name) HAVING count > 1 is empty)
2 POST /rest/default/.../upsert {"paths":["Scope/Check"]} n/a 200 name, url_key and url_path rows only at store_id 0
3 4 (before) and 6 (after) parallel calls for the same new path 2 of 4 answered 400 (url rewrite collision) 6 of 6 answered 200 with identical ids exactly one new set, contiguous ids 40 to 43 (no rolled back inserts)
3 lock held from another DB session (GET_LOCK('<db>|dagster_bridge_category_upsert') plus SLEEP) n/a 503 after 15 s: Another category upsert is still running after 15 seconds; retry the call later. nothing written
4 attribute-values {"attribute_codes":["category_ids"]} 400 carrying the raw SQL error text (Unknown column 'e.category_ids'), developer mode 400 naming category_ids none
4 {"attribute_codes":["tier_price","price"]} 200, tier_price silently empty although the product has tier price rows 400 naming tier_price none
4 category_ids, tier_price, media_gallery, name n/a 400 naming the three none
4 regression sku, created_at, name, price, status, quantity_and_stock_status n/a 200, all values present none
5 products/index?limit=2 n/a one row per product catalog_product_entity has no row_id column (Open Source)
6 {"skus":["tst-conf-simp-s-gray"],...} for a stored TST-Conf-Simp-S-Gray 200, name and sku both empty 200, values present under the requested spelling, sku returns the stored spelling none
7 empty skus 200 [] 400 At least one SKU is required. none
7 empty attribute_codes n/a 400 At least one attribute code is required. none
7 separator: "" 500 with a PHP ValueError stack trace as the body 400 The separator must not be empty. none
7 store_id: 999 200, defaults only 400 Store 999 does not exist.; store 0 and 2 still answer none
8 GET capabilities 1.0.0 1.0.0 from Capabilities::VERSION none
9 dry-run composer require ddtcorex/module-dagster-bridge:dev-master in a copy of a 2.4.9 project with minimum-stability: stable n/a locks dev-master 1e374c6 none
10 {"skus":["x') OR 1=1 -- ","o'brien"],...} n/a 200, one empty item per pair no rows leaked

The temporary observer module, the flat config change and every test category were removed from the store afterwards.

Test plan

  • composer test: 57 tests green (PHPUnit 12.5, PHP 8.5)
  • composer phpcs: clean
  • composer phpstan: level 6, no errors
  • composer validate --strict
  • Lowest bound: composer update --prefer-lowest --prefer-stable under PHP 8.1.34 resolved framework 103.0.6, catalog 104.0.6, eav 102.1.6, PHPUnit 10.5, PHPStan 1.12.0 and passed phpcs, phpstan and all unit tests (run locally in a container; the new CI job lowest does the same)
  • Live REST checks above on 2.4.9
  • CI on this PR: push and pull_request runs green on php 8.1 to 8.5 and on the new php 8.1, lowest dependencies job

Not verified

  • Behaviour on a live 2.4.6 store: only the lowest dependency install and the unit tests ran against 2.4.6 components, plus the upstream app/etc/di.xml check for ExecuteCommitCallbacks.
  • Production mode error masking: all live calls ran in developer mode.
  • A \Throwable that is not an Exception was only exercised in unit tests; no live path produced one.
  • Adobe Commerce with content staging: declared unsupported, not tested.
  • The lock under a non-DB lock provider (file, zookeeper): only the DB provider ran.
  • dev-master resolves today to the pre-fix commit; the fixed code reaches dev-master only after this PR merges.

The upsert caught only \Exception, so an Error (a TypeError raised deep
in a save, for instance) escaped without a rollback and left the
transaction open for the rest of the request.

The transaction now runs through the category resource model's
beginTransaction/commit/rollBack, the API Magento's own category code
uses, instead of the raw adapter. Live check on 2.4.9: the raw adapter
path already ran the after-commit callbacks, because core registers
ExecuteCommitCallbacks as a plugin on AdapterInterface::commit and
rollBack (also present in 2.4.6), so catalog_category_save_commit_after
fired at transaction level 0 and the flat table was updated before and
after this change alike. The switch keeps the module off that global
plugin rather than fixing a lost reindex.

composer test 39/39, phpcs clean, phpstan level 6 clean.
The upsert had two code paths that disagreed. Names containing a slash
went through the category repository, which matched names
case-sensitively, did not see an inactive sibling and so saved a second
"Men" that failed on the url_key collision, and read names in the
request's store. Every other path went through the native processor,
which silently created an unknown root as a brand new tree.

The repository path is gone. Every path now goes through the native
CatalogImportExport processor, with a slash inside a name escaped as
the processor's own "\/" quoting, and a name ending with a backslash
rejected because the processor cannot express it. The root is checked
up front at store 0, case-insensitively as the processor compares
names, and an unknown root answers 400 before anything is written. The
parser recognises the root case-insensitively for the same reason.

Live on 2.4.9: missing root 400 with no row created, an inactive "Men"
reused, "Men/Shirts" twice gives the same id, "T/Shirts" stored
literally, "women|A/B" lands under "Women", a call through
/rest/default writes store 0 only, no duplicate siblings.
Two simultaneous calls creating the same new path raced: live on 2.4.9,
four parallel "Race/Alpha/Beta" calls left two answering 400 on the url
rewrite collision. The native processor also loads the whole tree in
its constructor, so an injected shared instance answered from the tree
as it was when the request started.

The upsert now holds Magento's LockManagerInterface lock
dagster_bridge_category_upsert for the whole read and write, and builds
a fresh processor only once it holds the lock, through a module-owned
factory, so it sees what the previous holder committed. A call that
cannot get the lock in 15 seconds answers 503 with a retry message.

Live: six parallel calls for two new paths all answered 200 with the
same ids and created exactly one set of contiguous ids; with the lock
held from another DB session, the call answered 503 after 15 seconds
and wrote nothing.
Two classes of attribute code produced wrong answers. A static code that
is not a column of catalog_product_entity (category_ids, media_gallery)
built an unknown column: live on 2.4.9 the call failed with the raw SQL
error text in the response. A code whose values live elsewhere
(tier_price) was read from prefix plus backend type and silently
answered null for every product.

The query builder now names the codes it cannot read: a static code
must be a column of the product table (checked with describeTable), and
any other code must resolve, through the attribute's own
getBackendTable(), to one of the five standard value tables and have a
scalar backend. The scalar check is what catches tier_price: it
declares no backend table, so getBackendTable() names the decimal
table, but its backend reports isScalar() false, the signal Magento's
own EAV save uses. The service answers 400 listing every such code,
like the existing unknown-code error, and the value select reads the
table getBackendTable() resolved.

Live: category_ids, tier_price and media_gallery each answer 400 naming
them; sku, created_at, name, price, status and
quantity_and_stock_status still read correctly.
The index and attribute value queries join on the metadata link field,
and a test named ...OnCommerceStaging asserted that the join used
row_id, which read as a claim of staging support. With content staging
a product has one row per staged version, so both queries would answer
one row per version: the test locked in behaviour that is wrong there.

The module targets Open Source only. The test now pins just what is
true, that the join column comes from the metadata pool, the code
comments say staging is not supported and why, and README Requirements
states that the endpoints are Open Source only.

No behaviour change. Live on 2.4.9 Open Source: catalog_product_entity
has no row_id column and products/index answers one row per product.
The SKU filter runs under MySQL's case-insensitive collation, so asking
for "tst-conf-simp-s-gray" matched the row "TST-Conf-Simp-S-Gray". The
rows were then stored under the database spelling while the items were
built from the requested spelling, so the caller got null for both
values: live on 2.4.9, name and sku both came back empty.

Values are now keyed by the lowercased SKU and every item answers under
the spelling the caller sent. Live: a lowercase and an uppercase
spelling of two stored SKUs both answer their name, and the sku code
returns the stored spelling.
Three inputs slipped past validation. An empty skus list with codes
reached the query builder, which renders IN (). An empty category
separator reached explode(), whose ValueError escaped as an uncaught
Error: live on 2.4.9 the call answered HTTP 500 with a PHP stack trace
as the body. A store_id naming no store was accepted and quietly
answered default values only, which looks like "no store override".

The attribute values call now answers 400 for an empty skus or
attribute_codes list and for a store the store repository does not
know; the category upsert answers 400 for an empty separator, before
anything else runs. The library never sends empty lists (it chunks and
skips), so the stricter contract does not affect it.

Live: each of the four inputs answers 400 with its own message; store
0 and store 2 still answer.
composer.json hard-coded "version": "1.0.0". Composer skips a VCS tag
whose version disagrees with that field, so the first tag cut without
bumping it would silently not install. The capabilities endpoint read
its version from that same field, which is why it existed.

The version now lives in Capabilities::VERSION, with a unit test that
fails when it differs from the top release heading of CHANGELOG.md and
a one-line release checklist in the changelog. composer.json carries
no version, and a test pins that.

Every magento/* requirement was "*". They now follow the components
Magento 2.4.6 and 2.4.9 ship, read from the Mage-OS mirror metadata
(framework ~103.0.6, catalog ~104.0.6, catalog-import-export ~101.1.6,
store ~101.1.6, backend ~102.0.6), the PHP range is explicit, and
magento/module-eav is required and sequenced because the code uses its
classes and never declared it. The tilde ranges admit later 2.4 patch
lines, as Magento keeps them compatible.

The Mage-OS repositories block stays: Packagist hosts none of these
packages, and Composer reads repositories from the root package only,
so it serves development and is ignored by stores; README says so. A
new CI job installs with --prefer-lowest on PHP 8.1.

Verified: composer validate --strict; a --prefer-lowest install under
PHP 8.1.34 resolved framework 103.0.6, catalog 104.0.6, eav 102.1.6
and passed phpcs, phpstan and 56 unit tests; live capabilities still
answers 1.0.0.
README claimed PHPStan runs with bitexpert/phpstan-magento, which is
neither required in composer.json nor configured in phpstan.neon.dist;
the claim is removed rather than adding an extension the analysis does
not need. The tagged install used ^1.0, which cannot resolve before a
tag exists, and is now marked as the instruction for once 1.0.0 is
tagged. The branch install pointed at the merged feat/bridge-v1 branch
and told the store to lower minimum-stability; it now requires
dev-master, which a dry-run composer require against the 2.4.9
sandbox resolved (dev-master 1e374c6) with minimum-stability left at
stable. The integration paragraph that repeated Access control is
gone.

CHANGELOG keeps [1.0.0] unreleased and lists every review fix.
The SQL builder tests assert substrings against a quoteInto double that
pastes values in unescaped, so they prove the shape of the query and
nothing about injection safety.

A new test binds a hostile SKU and one with an apostrophe through a
double that escapes like MySQL string literals and asserts both stay
inside their literals; with the double reverted to raw pasting the test
fails, so it does test the binding path. README Development states
what injection safety actually rests on: quoteInto placeholders for
every caller value and module-resolved identifiers only.

Live on 2.4.9, the same two SKUs answer one empty item per pair and
leak no rows.
@ddtcorex
ddtcorex merged commit 4987275 into master Sep 30, 2026
12 checks passed
@ddtcorex
ddtcorex deleted the fix/review-findings branch September 30, 2026 04:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant