toga-ai 1.0.694 → 1.0.696
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/knowledge/2.0/apps/api2/INDEX.md +4 -4
- package/knowledge/2.0/apps/api2/architecture.md +64 -2
- package/knowledge/2.0/apps/api2/features/environment-variable-drives-underscore-branch.md +26 -2
- package/knowledge/2.0/apps/api2/features/health-check-endpoint.md +43 -8
- package/knowledge/2.0/apps/api2/features/v2-deadlock-retry.md +93 -13
- package/knowledge/2.0/apps/api2/workflows/codepipeline-codeconnections-deploy.md +79 -3
- package/knowledge/clients/compass-usa/features/asn-to-item-fulfillment.md +22 -1
- package/package.json +1 -1
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
| [Multi-Client (Cross-Client) Data Retrieval](features/cross-client-data-retrieval.md) | A single authenticated V2 GET listing can return records across **many** clients (designed for 1000+) that the caller is entitled to, honoring **each target cli | api2/Component/Api/CrossClient/CrossClient.php, api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Cache/Table.php, _underscore/Model/Cache/Tables/Client.php, _underscore/Model/Core/Record.php, worker2/Worker/Platform/Cache.php, worker2/Controller/Index.php, worker2/_.php, dbchanges2/Cache/2026-06-30a - MultiClientCacheTables.sql, dbchanges2/Core/2026-06-30b - CacheClusterRegistrationAndRecordTtl.sql, dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql |
|
|
8
8
|
| [cXML ShipNotice Gateway (ASN ingestion, carrier resolution, per-client provisioning)](features/cxml-shipnotice-gateway.md) | `_Component_Api_Cxml` accepts a supplier `ShipNoticeRequest` and translates it into a `POST /v2/advance-shipping-notices` on the V2 JSON engine. | api2/Component/Api/Cxml/Cxml.php, api2/Component/Api/V2/V2.php, api2/Controller/Index.php |
|
|
9
9
|
| [Encrypted-User-UUID Auth Handoff (/auth/encrypted-user-uuid)](features/encrypted-user-uuid-auth-handoff.md) | `POST /auth/encrypted-user-uuid` is the intended **cross-client / SSO-handoff identity mechanism**: given an encrypted `{client, user}` UUID pair, it mints a fr | api2/Component/Api/CrossClient/CrossClient.php |
|
|
10
|
-
| [ENVIRONMENT (not the EB environment name) decides the _underscore branch and Config file](features/environment-variable-drives-underscore-branch.md) | An api2 Elastic Beanstalk instance decides **which `_underscore` branch it clones** and **which `Config/<env>.ini` it loads** from the EB environment property * | api2/.ebextensions/git.php, api2/.ebextensions/php_include_underscore.config, api2/.ebextensions/git.sandbox-dev.json, api2/.ebextensions/git.sandbox-client.json, api2/Config/beta.ini, api2/Config/sandbox-dev.ini, api2/Component/Api/V2/V2.php |
|
|
11
|
-
| [Health-check endpoint (/health liveness short-circuit)](features/health-check-endpoint.md) | `
|
|
10
|
+
| [ENVIRONMENT (not the EB environment name) decides the _underscore branch and Config file](features/environment-variable-drives-underscore-branch.md) | An api2 Elastic Beanstalk instance decides **which `_underscore` branch it clones** and **which `Config/<env>.ini` it loads** from the EB environment property * | api2/.ebextensions/git.php, api2/.platform/hooks/prebuild/git.sh, api2/.ebextensions/php_include_underscore.config, api2/.ebextensions/git.sandbox-dev.json, api2/.ebextensions/git.sandbox-client.json, api2/Config/beta.ini, api2/Config/sandbox-dev.ini, api2/Component/Api/V2/V2.php |
|
|
11
|
+
| [Health-check endpoint (/health liveness — static file + PHP short-circuit)](features/health-check-endpoint.md) | The EB/ALB liveness probe (`/health`) is served two ways, and the **load-bearing** one is now a **static Apache-served file**, not PHP: - **`/health` → a static | api2/health, api2/.platform/httpd/conf.d/health_probe.conf, api2/Controller/Index.php |
|
|
12
12
|
| [Language Translation Layer (audience.language + sidecar tables)](features/language-translation-layer.md) | Serves the same TOGa data (Item title/description/longDescription, plus item **feature** text — `Features.name`, `ItemCategoryFeatureGroups.name`, `ItemFeatures | api2/Component/Api/V2/V2.php, api2/Component/Api/V2/Response/Response.php, _underscore/Model/Core/Setting.php, _underscore/Model/Core/RecordField.php, _underscore/Model/Core/DefaultGlobalSetting.php, _underscore/Model/Client/ItemTranslation.php, _underscore/Model/Client/FeatureTranslation.php, _underscore/Model/Client/ItemCategoryFeatureGroupTranslation.php, _underscore/Model/Client/ItemFeatureTranslation.php, dbchanges2/Client/2026-06-23a - ItemTranslations.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-13a - FeatureTranslations.sql, dbchanges2/Client/2026-07-13b - FeatureTranslationsAcl.sql, dbchanges2/Core/2026-06-23a - RecordFieldsTranslationColumn.sql, dbchanges2/Core/2026-06-23b - ItemTranslationsRecord.sql, dbchanges2/Core/2026-07-13 - FeatureTranslationsRecord.sql |
|
|
13
13
|
| [/auth/login resolves the client from the email domain, not the Bearer token (cross-client user path)](features/login-cross-client-user-resolution.md) | `POST /v2/auth/login` (email/password user login) can silently swap the target client mid-request. | api2/Component/Api/V2/V2.php, _underscore/String.php |
|
|
14
14
|
| [Nested FK object embedding is gated by the CHILD record's own ACL](features/nested-fk-acl-embedding.md) | When the V2 JSON engine serializes a foreign-key field into a **nested object** (in `getFullModelData()`, ~V2.php L6016-6060), it re-checks the **child** record | api2/Component/Api/V2/V2.php, dbchanges2/Client_Compass/2026-07-23b - PurchaseOrdersRecordReadAcl.sql |
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
| [TableView field/column metadata (TableViewFields, hidden projected columns)](features/tableview-field-metadata.md) | The columns of a 2.0 table view are defined by DB metadata, not code. | _underscore/Model/Client/TableView.php, api2/Component/Api/V2/V2.php, dbchanges2/Client/2026-07-20 - ItemsUuidForPurchaseOrderItemsTableView.sql, dbchanges2/Core/2026-08-17a - ItemFulfillmentQuantityFieldTypeNumber.sql, dbchanges2/Client/2026-08-17a - ItemFulfillmentColumnsCopyable.sql, dbchanges2/Client_Quad/2026-08-18c - SalesOrderListingSortByDateOrderDesc.sql, dbchanges2/Client_Quad/2026-08-24 - Quad Multi Currency Item Pricing.sql, dbchanges2/Client_Elite/2026-08-18 - InventoryUnitsItemColumns.sql, dbchanges2/Client_Nychh/2026-08-18 - InventoryUnitsItemColumns.sql, dbchanges2/Client_CompassCanada/2026-08-27a - ItemFulfillmentTableViewsRebuild.sql |
|
|
23
23
|
| [Tickets API (/v2/tickets)](features/tickets-api.md) | The generic ticket endpoint of the 2.0 REST API. | Component/Api/V2/V2.php |
|
|
24
24
|
| [V2 API error/message codes (EV/EZ troubleshooting map)](features/v2-api-error-codes.md) | The V2 JSON engine (`Component/Api/V2/V2.php`) returns short **message codes** in the response `error` field, grouped by family: `EN-*` authentication, `EZ-*` a | api2/Component/Api/V2/V2.php, api2/Component/Api/V2/Response/Response.php, api2/Component/Api/V2/Response/Oauth/Oauth.php, api2/Controller/Index.php, toga2-supply/src/globalTypes.ts, toga2-supply/src/pages/Orders/api/OrdersApi.ts, toga2-supply/src/api/toga.ts, _underscore/Model/Client/TrackingNumber.php |
|
|
25
|
-
| [V2 request
|
|
25
|
+
| [V2 request retry (Prong A) — deadlock AND retriable unique-key race, route-scoped in-process replay](features/v2-deadlock-retry.md) | api2's front controller ("**Prong A**") can **detect a retriable concurrency failure and replay the whole request in-process**, so a transient collision self-re | api2/Controller/Index.php, api2/Component/Api/V2/V2.php, _underscore/Database.php, _underscore/Query.php, _underscore/Model/Compass/AdvanceShippingNotice.php |
|
|
26
26
|
| [V2 REST query contract (params, where grammar, encoding, ACL behavior)](features/v2-rest-query-contract.md) | What an **HTTP client** has to get right to query the Toga v2 REST API: which query params are recognized, the exact `where` grammar, how the query string is (n | api2/Component/Api/V2/V2.php |
|
|
27
27
|
| [V2 reverse hasMany collections must be named in the fetch fields whitelist](features/v2-reverse-hasmany-fields-whitelist.md) | In the V2 JSON engine, a **reverse hasMany** relationship — the collection of child records that foreign-key back to a parent (e.g. | api2/Component/Api/V2/V2.php |
|
|
28
|
-
| [AWS CodePipeline Deployment via CodeConnections (GitHub → Elastic Beanstalk)](workflows/codepipeline-codeconnections-deploy.md) | 2.0 apps (`api2`, `_underscore`) are deployed through **AWS CodePipeline**. | api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, api2/ebs/register_instance_to_shared_application_load_balancer.php, api2/.platform/hooks/prebuild/git.sh, api2/.ebextensions/git.php, api2/.ebextensions/git.sandbox-dev.json |
|
|
28
|
+
| [AWS CodePipeline Deployment via CodeConnections (GitHub → Elastic Beanstalk)](workflows/codepipeline-codeconnections-deploy.md) | 2.0 apps (`api2`, `_underscore`) are deployed through **AWS CodePipeline**. | api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, api2/ebs/register_instance_to_shared_application_load_balancer.php, api2/.platform/hooks/prebuild/git.sh, api2/.platform/hooks/postdeploy/015_install_composer.sh, api2/.ebextensions/git.php, api2/.ebextensions/git.sandbox-dev.json |
|
|
29
29
|
| [New Environment Configuration & Provisioning (api2)](workflows/environment-configuration-and-provisioning.md) | What it takes for a 2.0 API environment (e.g. | api2/Config/<environment>.ini, api2/composer.json, api2/composer.lock, api2/Controller/Index.php, dbchanges2/Core/2026-06-16a - DatabaseHosts for new QA QC stage demo environments.sql, dbchanges2/Logs/, _underscore/Route.php |
|
|
@@ -6,7 +6,7 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: architecture
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-28
|
|
10
10
|
owners: [jcardinal, bala, mhammontree, dfranks]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Controller/Index.php
|
|
@@ -197,6 +197,12 @@ always exits 0). See
|
|
|
197
197
|
[the worker2 reference implementation](../worker2/features/alb-target-group-auto-registration.md) —
|
|
198
198
|
api2's copy is the **unhardened original** and must be brought up to it.
|
|
199
199
|
|
|
200
|
+
**Deploy policy (2026-08-28):** the three production envs (`api-production-1/2/3`, 2 instances each)
|
|
201
|
+
run **Rolling / `BatchSize` 1** (was `AllAtOnce`/100%), target-group `HealthyThresholdCount` 2 — so
|
|
202
|
+
a deploy keeps ≥1 healthy backend and no longer emits the ELB-502 blip. Each CodePipeline
|
|
203
|
+
auto-deploys on a push to `_production`. See
|
|
204
|
+
[the CodePipeline deploy workflow](workflows/codepipeline-codeconnections-deploy.md).
|
|
205
|
+
|
|
200
206
|
## CI — commit message policy
|
|
201
207
|
|
|
202
208
|
`.github/workflows/true-devteam-requirements.yml` enforces **`TRUE-{ticket}: {Subject}`** —
|
|
@@ -254,6 +260,48 @@ interceptor's plain-`Exception` rejections — all now returning 4xx/fixed. Dist
|
|
|
254
260
|
reading the ALB 5xx metric and `Logs.Issue`: zero 5xx + a traffic spike = transient EB snapshot;
|
|
255
261
|
a sustained 5xx rate = a code bug to fix.
|
|
256
262
|
|
|
263
|
+
**A third cause — a `Degraded`/`Severe` flip with NO stated cause is the PHP-served `/health`
|
|
264
|
+
probe being STARVED, not client traffic failing.** Confirmed on `api-production-1` (acct
|
|
265
|
+
`654654170868`): off-peak **bursts of slow, failing `POST /v2/advance-shipping-notices`**
|
|
266
|
+
(Compass ASN; 17–63 s each, ~100% HTTP 500) saturate the **PHP-FPM worker pool** on an
|
|
267
|
+
instance. The ALB `/health` check — historically **PHP-served, 5 s timeout** — then can't get a
|
|
268
|
+
worker and fails a few checks, so EB flags the target group "reduced health" **with no cause
|
|
269
|
+
cited**. Two reasons this looks invisible: (1) **health-check traffic does NOT appear in ALB
|
|
270
|
+
`RequestCount`/`5xx` metrics**, so the ALB view reads clean; (2) **`HealthyHostCount` never
|
|
271
|
+
drops**, because the probe failure is shorter than the **5-check / 75 s deregistration
|
|
272
|
+
threshold** — it flaps EB enhanced-health without ever deregistering a target. A separate, benign
|
|
273
|
+
contributor is genuine **sub-minute `/health` transients** on a single instance. Correlation
|
|
274
|
+
proof: a **06:00–06:08 CST ASN burst == the 11:05 UTC EB `Severe` flip**. Diagnosed via AWS CLI
|
|
275
|
+
(`describe-events`, `describe-configuration-settings`, CloudWatch `get-metric-data`) + prod
|
|
276
|
+
`Logs.Api`/`Logs.Issue`.
|
|
277
|
+
|
|
278
|
+
**Rule:** a "reduced health / no cause" flip on this 2-instance PHP env means the ALB `/health`
|
|
279
|
+
probe was starved of a PHP-FPM worker — do **not** chase client-traffic 5xx. **Fix shipped
|
|
280
|
+
(working tree):** `/health` is now served as a **static Apache file** (`api2/health`), decoupling
|
|
281
|
+
liveness from PHP-FPM worker availability so a slow-request burst can no longer flap it — see
|
|
282
|
+
[health-check endpoint](features/health-check-endpoint.md).
|
|
283
|
+
|
|
284
|
+
**A fourth cause (deploy-triggered) — a burst of ELB `502`s when both instances restart at once.**
|
|
285
|
+
Confirmed on `api-production-1` (2026-08-28 23:51 UTC deploy: **43 × 502 in one minute**, the only
|
|
286
|
+
ELB 5xx in a 7-hour window). Under the old `AllAtOnce`/100% deploy policy, every deploy to a
|
|
287
|
+
2-instance env restarts Apache/PHP-FPM on **both** instances simultaneously, so in-flight requests
|
|
288
|
+
have their connections dropped mid-response → a burst of **ELB-generated HTTP 502**. EB flags "X%
|
|
289
|
+
of requests to the ELB are failing with HTTP 5xx" `Degraded` ~3–4 min later and recovers in ~5 min.
|
|
290
|
+
Distinguishing fingerprint vs. a real app failure: it is **502** (a dropped backend connection),
|
|
291
|
+
**not 503** (no healthy backend) or **504** (backend timeout); **`HTTPCode_Target_5XX_Count` (app
|
|
292
|
+
5xx) = 0**; **`HealthyHostCount` never drops below 2**; and there is **NOTHING in `Logs.Api`** —
|
|
293
|
+
api2 logs a request only *after* the response, and a 502 never completes one, so the request leaves
|
|
294
|
+
no log row. Diagnosis path: CloudWatch `HTTPCode_ELB_502/503/504_Count` vs
|
|
295
|
+
`HTTPCode_Target_5XX_Count`, plus EB `describe-events` (a version change) and CodePipeline
|
|
296
|
+
`list-pipeline-executions` — because a push to `_production` auto-deploys all three prod tiers, "we
|
|
297
|
+
didn't deploy" can be false (see
|
|
298
|
+
[the CodePipeline deploy workflow](workflows/codepipeline-codeconnections-deploy.md)).
|
|
299
|
+
**Rule:** an EB "% 5xx" `Degraded` with an **empty `Logs.Api` and 0 target-5xx** right after a
|
|
300
|
+
deploy is a deploy-restart 502 artifact, not an application bug — check the ELB-5xx breakdown and
|
|
301
|
+
the deploy/version timeline before hunting app bugs. **Fixed 2026-08-28:** all three prod envs moved
|
|
302
|
+
to **Rolling / batch-size 1** (≥1 healthy backend throughout a deploy), which eliminates the blip —
|
|
303
|
+
config + tradeoffs in the [deploy workflow](workflows/codepipeline-codeconnections-deploy.md).
|
|
304
|
+
|
|
257
305
|
## Known issues / accepted risks
|
|
258
306
|
|
|
259
307
|
Open items a maintainer should know before changing this tier. None are "bugs to fix right now" —
|
|
@@ -314,7 +362,11 @@ they are the known sharp edges. Do not re-discover these from scratch.
|
|
|
314
362
|
class lives in `_underscore` (`Model/Client/...`).
|
|
315
363
|
- **Client-specific behavior** belongs in `_Model_<ClientSlug>_X` overrides and
|
|
316
364
|
`Client_ApiPayloadInterceptor` pre/post hooks — not in `V2.php`.
|
|
317
|
-
- **Don't break `/health
|
|
365
|
+
- **Don't break `/health`.** It is now a **static Apache-served file** (`api2/health`, body
|
|
366
|
+
`OK`) — **not** a PHP short-circuit — so the EB/ALB liveness probe is decoupled from PHP-FPM
|
|
367
|
+
worker availability. Do **not** route `/health` back through PHP or remove the file, and don't
|
|
368
|
+
weaken `enforce_https.conf`. (`/v2/health` remains PHP-handled.) See
|
|
369
|
+
[health-check endpoint](features/health-check-endpoint.md).
|
|
318
370
|
- **504s:** verify `long_gateway_timeout.conf` on both this tier and the proxy
|
|
319
371
|
(LB 3600s → proxy Apache 1800s → proxy cURL 900s → api Apache 1800s).
|
|
320
372
|
- **`transactionId` uniqueness is load-bearing** — the idempotency/audit key; don't bypass
|
|
@@ -327,6 +379,16 @@ they are the known sharp edges. Do not re-discover these from scratch.
|
|
|
327
379
|
single-caller branches in `V2.php` as unverified until exercised directly.
|
|
328
380
|
|
|
329
381
|
## Change history
|
|
382
|
+
- 2026-08-28 — Recorded a **fourth EB-`Degraded` cause — the deploy-restart ELB 502**: under the old
|
|
383
|
+
`AllAtOnce`/100% policy a deploy restarts both instances of a 2-instance env at once, dropping
|
|
384
|
+
in-flight connections → an ELB-502 burst (43 in one minute on `api-production-1`, 2026-08-28) that
|
|
385
|
+
EB flags as "X% 5xx" Degraded ~3–4 min later. Fingerprint vs. an app bug: **502** (not 503/504),
|
|
386
|
+
**`HTTPCode_Target_5XX_Count`=0**, `HealthyHostCount` never < 2, and **nothing in `Logs.Api`** (a
|
|
387
|
+
502 never completes a PHP response, which api2 logs only after). Diagnose via the ELB-5xx breakdown
|
|
388
|
+
+ `describe-events`/`list-pipeline-executions` — a push to `_production` auto-deploys all three prod
|
|
389
|
+
tiers, so "we didn't deploy" can be false. Fixed by moving to Rolling/batch-1 (deploy config +
|
|
390
|
+
policy tradeoffs captured in the CodePipeline deploy workflow). (jcardinal)
|
|
391
|
+
- 2026-08-28 — Recorded the third EB-`Degraded` cause: a no-stated-cause "reduced health" flip on `api-production-1` is the **PHP-served `/health` probe starved of a PHP-FPM worker** by off-peak bursts of slow failing Compass ASN POSTs (17–63 s, ~100% 500) — invisible in ALB `RequestCount`/`5xx` and never dropping `HealthyHostCount` (flap < 75 s deregistration). Correlated a 06:00–06:08 CST ASN burst to the 11:05 UTC Severe flip. Noted the fix (static-file `/health` decouples liveness from PHP-FPM) and updated the "Don't break `/health`" rule to the static-file form. (jcardinal)
|
|
330
392
|
- 2026-08-26 — Documented and fixed a `/v2/auth/public` OOM: a present-but-null client `transactionId` (`{"transactionId": null}`) flowed into the request-logger's `_Model::load()` dup-check; because `Logs.Api.transactionId` is `FIELD_CHAR`, a null value collapses the WHERE and full-table-scans prod `Logs.Api` → memory exhaustion (the `_underscore` collapsed-WHERE mechanism). V2 only auto-uuids an *absent* transactionId (~L543), so a present null fell through (~L553 read → null). Fix (commit `36ef31f`): coerce null/empty transactionId → uuid in the auth block, and skip `load()` in the final logger dup-check (~L2277) when null/empty. Added the "don't blame the latest deploy — check first-occurrence" triage note. (jcardinal)
|
|
331
393
|
- 2026-08-25 — **Resolved the auto-generated `Api.transactionId` 1062 collision** (was the 2026-07-28
|
|
332
394
|
gotcha): the two DB-insert log sites now uuid the log-row `transactionId` (`_String::generateUuid()`)
|
|
@@ -6,10 +6,11 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
10
|
-
owners: ["bala", "mhammontree", "apeterson"]
|
|
9
|
+
updated: 2026-08-28
|
|
10
|
+
owners: ["bala", "mhammontree", "apeterson", "jcardinal"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/.ebextensions/git.php
|
|
13
|
+
- api2/.platform/hooks/prebuild/git.sh
|
|
13
14
|
- api2/.ebextensions/php_include_underscore.config
|
|
14
15
|
- api2/.ebextensions/git.sandbox-dev.json
|
|
15
16
|
- api2/.ebextensions/git.sandbox-client.json
|
|
@@ -177,6 +178,21 @@ the tier trades a stale-code bug for a total outage. Full procedure:
|
|
|
177
178
|
|
|
178
179
|
## Gotchas / known issues
|
|
179
180
|
|
|
181
|
+
- **⚠ Cross-repo change ordering: land `_underscore` BEFORE deploying api2.** Because api2's EB
|
|
182
|
+
deploy **clones `_underscore` (branch `_<ENVIRONMENT>`, e.g. `_production`) at BUILD time**
|
|
183
|
+
(`.platform/hooks/prebuild/git.sh`), a change that spans both repos must have its `_underscore`
|
|
184
|
+
side merged to `_<ENVIRONMENT>` **first** — otherwise the freshly-built api2 will call a framework
|
|
185
|
+
method/class that isn't on the box and fatal. Landing `_underscore` first is **safe on its own**:
|
|
186
|
+
the old api2 controller simply doesn't use the new framework code yet. Order: merge `_underscore`
|
|
187
|
+
→ (verify it's on the branch) → deploy api2.
|
|
188
|
+
- **⚠ A "Could not find required file for `_Model_...`" fatal on prod can be a STALE-BUILD
|
|
189
|
+
artifact, not a missing file.** `Logs.Issue #476`
|
|
190
|
+
("Could not find required file for '_Model_Client_ItemFulfillments_InventoryAdjustment'") fired
|
|
191
|
+
because that model file **was** committed to `_production` but `api-production-1` was still running
|
|
192
|
+
an **older build**; a **redeploy** (which re-pulls `_production`) resolved it with no recurrence.
|
|
193
|
+
Before assuming a model file is absent, check the **running build's `_underscore` against
|
|
194
|
+
`_production`** (file presence on the box; `git merge-base --is-ancestor`) — the build-time clone
|
|
195
|
+
means a merged file may simply not be on that instance yet.
|
|
180
196
|
- **⚠ Merging to the branch named after the EB environment can be a no-op.** Merge to
|
|
181
197
|
`_` + `ENVIRONMENT`. If your change "deployed successfully" but the behavior is absent and no
|
|
182
198
|
error appears, check this before debugging the code. A missing class also throws `Error`
|
|
@@ -241,6 +257,14 @@ the tier trades a stale-code bug for a total outage. Full procedure:
|
|
|
241
257
|
|
|
242
258
|
## Change history
|
|
243
259
|
|
|
260
|
+
- 2026-08-28 — Recorded two build-time-clone consequences: (1) **cross-repo deploy ordering** — api2
|
|
261
|
+
clones `_underscore` (`_<ENVIRONMENT>`) at build via `.platform/hooks/prebuild/git.sh`, so for a
|
|
262
|
+
change spanning both repos, `_underscore` must land on the deploy branch **before** api2 is
|
|
263
|
+
deployed (landing `_underscore` first is safe alone); and (2) a **"Could not find required file
|
|
264
|
+
for `_Model_...`" fatal can be a stale build**, not a missing file — `Logs.Issue #476` fired for
|
|
265
|
+
a model committed to `_production` while `api-production-1` ran an older build, and a redeploy
|
|
266
|
+
resolved it. Check the running build's `_underscore` vs `_production` before assuming the file is
|
|
267
|
+
absent. (jcardinal)
|
|
244
268
|
- 2026-08-27 — Recorded the **inverse hazard**: moving a tier **forward** onto its correct
|
|
245
269
|
`_underscore` branch is a **schema event**. The 2026-08-26 deploy moved `sandbox-client` off the
|
|
246
270
|
`_production` framework (which the 2026-08-25 entry below explains it had been running for weeks)
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
---
|
|
2
|
-
title: "Health-check endpoint (/health liveness short-circuit)"
|
|
2
|
+
title: "Health-check endpoint (/health liveness — static file + PHP short-circuit)"
|
|
3
3
|
framework: "2.0"
|
|
4
4
|
repo: api2
|
|
5
5
|
project: API
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-08-28
|
|
10
10
|
owners: [jcardinal]
|
|
11
11
|
files:
|
|
12
|
+
- api2/health
|
|
13
|
+
- api2/.platform/httpd/conf.d/health_probe.conf
|
|
12
14
|
- api2/Controller/Index.php
|
|
13
15
|
related:
|
|
14
16
|
- ../architecture.md
|
|
@@ -17,13 +19,37 @@ related:
|
|
|
17
19
|
|
|
18
20
|
## Summary
|
|
19
21
|
|
|
20
|
-
`
|
|
21
|
-
**
|
|
22
|
-
load balancer) probes depend on this always returning 200 cheaply — the architecture doc's
|
|
23
|
-
critical rule is literally "Don't break `/health`". This doc records the **matching contract**
|
|
24
|
-
so a teammate doesn't reintroduce a brittle exact-string check.
|
|
22
|
+
The EB/ALB liveness probe (`/health`) is served two ways, and the **load-bearing** one is now
|
|
23
|
+
a **static Apache-served file**, not PHP:
|
|
25
24
|
|
|
26
|
-
|
|
25
|
+
- **`/health` → a static file `api2/health`** (literal body `OK`), served by Apache **without
|
|
26
|
+
ever reaching PHP-FPM**. This is what the EB target group health-check hits. Decoupling the
|
|
27
|
+
probe from PHP means a PHP-FPM worker-pool exhaustion (e.g. a burst of slow ASN requests
|
|
28
|
+
saturating all workers) or a PHP transient can **no longer flap EB health** — the probe
|
|
29
|
+
answers from Apache regardless of app load.
|
|
30
|
+
- **`/v2/health` (and `/health` as a fallback) → PHP short-circuit** in
|
|
31
|
+
`_Controller_Index::api()`, an HTTP 200 returned **before** any routing, DB bootstrap, or V2
|
|
32
|
+
engine work. This still exists for callers that hit the PHP entrypoint directly.
|
|
33
|
+
|
|
34
|
+
This doc records the **matching contract** for both so a teammate doesn't reintroduce a brittle
|
|
35
|
+
exact-string check or accidentally route the liveness probe back through PHP.
|
|
36
|
+
|
|
37
|
+
## The static probe (the one EB actually uses) — 2026-08-28
|
|
38
|
+
|
|
39
|
+
- **`api2/health`** — a one-line static file (`OK`). Apache serves it directly.
|
|
40
|
+
- **`api2/.platform/httpd/conf.d/health_probe.conf`** — comment-only documentation of why the
|
|
41
|
+
probe is static (no directives; it exists to explain the decision at the Apache layer).
|
|
42
|
+
- **Why it works without a rewrite fight:** `api2/.htaccess` only rewrites to `index.php` when
|
|
43
|
+
the request target is **not a real file** (`RewriteCond %{REQUEST_FILENAME} !-f`). Because
|
|
44
|
+
`health` is a real file on disk, the rewrite is skipped and Apache serves it straight.
|
|
45
|
+
- **Why `enforce_https` doesn't redirect it:** EB health checks arrive on **HTTP:80 with no
|
|
46
|
+
`X-Forwarded-Proto`**, and `enforce_https.conf` only redirects requests that carry the
|
|
47
|
+
forwarded-proto marker — so the probe is not bounced to HTTPS.
|
|
48
|
+
- **Do not** move `/health` back into a PHP-served route or rename/remove `api2/health` — that
|
|
49
|
+
re-couples EB liveness to PHP-FPM worker availability, which is the exact failure this fixed
|
|
50
|
+
(see [api2 architecture — EB "Degraded" health](../architecture.md)).
|
|
51
|
+
|
|
52
|
+
## PHP short-circuit (for `/v2/health` and direct hits)
|
|
27
53
|
|
|
28
54
|
The short-circuit lives at the top of `Index.php::api()`, before host dispatch and before the
|
|
29
55
|
Core/Logs DB bootstrap. It matches against a class constant:
|
|
@@ -55,6 +81,15 @@ normalized form: lowercase, no trailing slash, no query) rather than re-adding a
|
|
|
55
81
|
branch.
|
|
56
82
|
|
|
57
83
|
## Change history
|
|
84
|
+
- 2026-08-28 — Moved the **EB/ALB liveness probe off PHP**: `/health` is now a **static file**
|
|
85
|
+
(`api2/health`, body `OK`) served by Apache — it never reaches PHP-FPM, so a slow-request burst
|
|
86
|
+
or a PHP transient can no longer starve the probe and flap EB health. Works because
|
|
87
|
+
`.htaccess` only rewrites to `index.php` for non-files (`!-f`), and `enforce_https` skips the
|
|
88
|
+
probe (HTTP:80, no `X-Forwarded-Proto`). Added a comment-only
|
|
89
|
+
`.platform/httpd/conf.d/health_probe.conf` documenting the decision. `/v2/health` stays
|
|
90
|
+
PHP-handled and the `HEALTH_CHECK_ROUTES` PHP short-circuit remains as a fallback. Working tree
|
|
91
|
+
only (uncommitted). See the [EB "Degraded" health](../architecture.md) diagnosis this fixes.
|
|
92
|
+
(jcardinal)
|
|
58
93
|
- 2026-07-24 — Replaced the brittle exact-string `REQUEST_URI == '/health'` check with a
|
|
59
94
|
`HEALTH_CHECK_ROUTES` constant + normalized (lowercase / strip query / strip trailing slash)
|
|
60
95
|
strict `in_array` match, so `/v2/health`, `/health/`, and `/health?x` all short-circuit to 200
|
|
@@ -1,38 +1,101 @@
|
|
|
1
1
|
---
|
|
2
|
-
title: V2 request
|
|
2
|
+
title: V2 request retry (Prong A) — deadlock AND retriable unique-key race, route-scoped in-process replay
|
|
3
3
|
framework: "2.0"
|
|
4
4
|
repo: api2
|
|
5
5
|
project: API
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: draft
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-28
|
|
10
10
|
owners: [jcardinal]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Controller/Index.php
|
|
13
|
+
- api2/Component/Api/V2/V2.php
|
|
13
14
|
- _underscore/Database.php
|
|
14
15
|
- _underscore/Query.php
|
|
16
|
+
- _underscore/Model/Compass/AdvanceShippingNotice.php
|
|
15
17
|
related:
|
|
16
18
|
- ../../_underscore/features/model-save-parent-cascade-stored-field-deadlock.md
|
|
19
|
+
- ../../../../clients/compass-usa/features/asn-to-item-fulfillment.md
|
|
17
20
|
- ../architecture.md
|
|
18
21
|
- ./v2-api-error-codes.md
|
|
19
22
|
---
|
|
20
23
|
|
|
21
24
|
## Summary
|
|
22
25
|
|
|
23
|
-
api2's front controller can **detect a
|
|
24
|
-
the whole request in-process**, so a transient
|
|
25
|
-
|
|
26
|
-
(`DEADLOCK_RETRIABLE_ROUTES`) — currently only `/v2/advance-shipping-notices` — because whole-
|
|
27
|
-
request in-process replay is **only safe for routes with no non-rollbackable external side effect**.
|
|
28
|
-
This is the safety-net half of the ASN deadlock fix; the source-level fix (stop the collateral
|
|
29
|
-
parent re-save) is in
|
|
30
|
-
[_underscore's save-cascade doc](../../_underscore/features/model-save-parent-cascade-stored-field-deadlock.md).
|
|
26
|
+
api2's front controller ("**Prong A**") can **detect a retriable concurrency failure and replay
|
|
27
|
+
the whole request in-process**, so a transient collision self-recovers instead of returning an
|
|
28
|
+
error to the caller. Two failure classes now qualify:
|
|
31
29
|
|
|
32
|
-
|
|
30
|
+
1. **MySQL deadlock (1213) / lock-wait timeout (1205)** — the original case (a same-PO ASN write
|
|
31
|
+
deadlock; see the
|
|
32
|
+
[save-cascade doc](../../_underscore/features/model-save-parent-cascade-stored-field-deadlock.md)).
|
|
33
|
+
2. **Retriable unique-key get-or-create race (1062)** — a concurrent same-key INSERT lost the
|
|
34
|
+
race; a whole-request replay on a fresh snapshot lets the retry's GET reuse the winner's
|
|
35
|
+
committed row (added 2026-08-28 for the Compass ASN data-loss race).
|
|
36
|
+
|
|
37
|
+
The replay is **route-scoped by an allowlist** (`DEADLOCK_RETRIABLE_ROUTES`) — currently only
|
|
38
|
+
`/v2/advance-shipping-notices` — because whole-request in-process replay is **only safe for routes
|
|
39
|
+
with no non-rollbackable external side effect**. The retry gate is now
|
|
40
|
+
**`wasDeadlock() || wasRetriableUniqueRace()`**.
|
|
41
|
+
|
|
42
|
+
> Status (2026-08-28): **in the working tree only** — not committed or deployed. Pending sandbox
|
|
33
43
|
> validation + regression tests.
|
|
34
44
|
|
|
35
|
-
##
|
|
45
|
+
## The retriable unique-key race (1062) — Compass ASN data-loss (2026-08-28)
|
|
46
|
+
|
|
47
|
+
`_Model_Compass_AdvanceShippingNotice::postPost` **get-or-creates** an SO-numbered Item
|
|
48
|
+
Fulfillment (GET by number, else POST). `ItemFulfillments.number` is UNIQUE and vendors
|
|
49
|
+
re-transmit the same shipment file, so two concurrent same-number ASNs race: the loser's INSERT
|
|
50
|
+
trips the unique key. Pre-fix, `internalApiRequest` threw (default `throwExceptionsOnError=true`),
|
|
51
|
+
the 500 propagated out of `postPost`, and **the api2 controller rolled back the whole request —
|
|
52
|
+
losing the ASN row and its tracking numbers** (`Logs.Issue #239`, EV-10). This is a bug 1062
|
|
53
|
+
must NOT be treated like a normal error, because a replay recovers it cleanly.
|
|
54
|
+
|
|
55
|
+
**Fix (Approach B — a CTO review rejected a simpler in-place `FOR UPDATE` recovery, because an
|
|
56
|
+
interceptor cannot recover a poisoned response in place; see *V2 concurrency mechanics* below).**
|
|
57
|
+
A generalized retriable-unique-race path:
|
|
58
|
+
|
|
59
|
+
- **`_Database`** gains `const ERRNO_DUPLICATE_ENTRY = 1062`, a sticky
|
|
60
|
+
`public static bool $retriableUniqueRace` (reset per attempt in `clearLastError()`), and
|
|
61
|
+
`wasRetriableUniqueRace()` — true **only** when the caller-set flag is set **AND**
|
|
62
|
+
`lastErrorNumber === 1062`. The flag is caller-opt-in on purpose: a bare 1062 is a real
|
|
63
|
+
constraint violation and must **not** silently retry; only a caller that knows the 1062 is a
|
|
64
|
+
benign get-or-create race sets it.
|
|
65
|
+
- **The ASN model** calls the IF-create `internalApiRequest` with **`throwExceptionsOnError:false`**;
|
|
66
|
+
on an empty response with `lastErrorNumber === 1062` it sets `$retriableUniqueRace = true` and
|
|
67
|
+
returns null. `postPost` then **aborts immediately** (issues no further query).
|
|
68
|
+
- **The controller's Prong A gate** becomes `wasDeadlock() || wasRetriableUniqueRace()`. Replaying
|
|
69
|
+
the whole request on a fresh REPEATABLE-READ snapshot lets the retry's GET see the winner's
|
|
70
|
+
committed IF and reuse it.
|
|
71
|
+
|
|
72
|
+
Bounded by `MAX_DEADLOCK_ATTEMPTS = 3`, gated to `DEADLOCK_RETRIABLE_ROUTES =
|
|
73
|
+
['/v2/advance-shipping-notices']`.
|
|
74
|
+
|
|
75
|
+
## V2 concurrency mechanics (durable framework knowledge)
|
|
76
|
+
|
|
77
|
+
These are the V2-engine facts that decide **how any interceptor-level write race must be handled**
|
|
78
|
+
— they are why the fix is a whole-request replay and not an in-place recovery:
|
|
79
|
+
|
|
80
|
+
- **Response poisoning (load-bearing).** A failed nested save via `internalApiRequest` calls
|
|
81
|
+
`addDefinedMessage(EV-10)` on the **shared** `$api->response`, setting a non-2xx status **before**
|
|
82
|
+
control returns. So even if an interceptor catches the throw and "recovers," `execute()`
|
|
83
|
+
recomputes `isSuccess = false` (`V2.php` ~2154) and **self-rolls-back DB_CORE + DB_CLIENT**
|
|
84
|
+
(~2157-2159). **An interceptor therefore CANNOT recover a failed internal save in place** — the
|
|
85
|
+
only correct recovery is a whole-request replay. This same poisoning is what drives the Prong A
|
|
86
|
+
retry gate and, on exhaustion, produces the coherent EV-10 response.
|
|
87
|
+
- **`internalApiRequest($method, $route, $payload, $options, $throwExceptionsOnError = true)`** — a
|
|
88
|
+
**5th argument** controls throw-vs-return-on-error. Pass `false` to inspect `lastErrorNumber`
|
|
89
|
+
instead of unwinding.
|
|
90
|
+
- **`_Database::$lastErrorNumber` is sticky.** Set only on query failure, never cleared by a later
|
|
91
|
+
success; reset per attempt via `clearLastError()`. A recorded **deadlock (1213/1205) is protected
|
|
92
|
+
from overwrite** by a later error; a **1062 is NOT** protected — so read/act on a 1062 promptly.
|
|
93
|
+
- **The prod client cluster runs REPEATABLE-READ.** A same-transaction re-SELECT after a losing
|
|
94
|
+
concurrent INSERT is **blind to the winner's commit** — only a locking read or a **fresh-snapshot
|
|
95
|
+
whole-request replay** sees it. This is why in-place retry inside the transaction cannot work and
|
|
96
|
+
the replay must start a new transaction.
|
|
97
|
+
|
|
98
|
+
## How it works — deadlock path
|
|
36
99
|
|
|
37
100
|
**Detection primitive (`_underscore`, reusable).** `_Database` gains
|
|
38
101
|
`public static ?int $lastErrorNumber`, the consts `ERRNO_DEADLOCK` (1213) and
|
|
@@ -42,7 +105,8 @@ driver errno **at its throw site**, and the recording is **sticky**: a later unr
|
|
|
42
105
|
inside a model-save `try/catch` still be visible to the outer controller and trigger a retry.
|
|
43
106
|
|
|
44
107
|
**Retry loop (`api2/Controller/Index.php`).** The v2 dispatch is wrapped in a bounded loop
|
|
45
|
-
(`MAX_DEADLOCK_ATTEMPTS = 3`). On an unsuccessful response where
|
|
108
|
+
(`MAX_DEADLOCK_ATTEMPTS = 3`). On an unsuccessful response where
|
|
109
|
+
`_Database::wasDeadlock() || _Database::wasRetriableUniqueRace()` is true
|
|
46
110
|
and the route is in `DEADLOCK_RETRIABLE_ROUTES`, the controller:
|
|
47
111
|
|
|
48
112
|
1. rolls back all open transactions;
|
|
@@ -82,6 +146,22 @@ superglobal. Default is to leave a route off the list.
|
|
|
82
146
|
not a substitute for removing the deadlock at its source.
|
|
83
147
|
|
|
84
148
|
## Change history
|
|
149
|
+
- 2026-08-28 — **Generalized Prong A to a second retriable class: the unique-key (1062)
|
|
150
|
+
get-or-create race**, fixing the Compass ASN data-loss bug (`Logs.Issue #239`, EV-10) where a
|
|
151
|
+
concurrent same-`ItemFulfillments.number` INSERT lost the race, `internalApiRequest` threw, and
|
|
152
|
+
the whole request rolled back — losing the ASN row and its tracking. Added
|
|
153
|
+
`_Database::ERRNO_DUPLICATE_ENTRY = 1062`, a sticky caller-opt-in `$retriableUniqueRace`
|
|
154
|
+
(reset in `clearLastError()`), and `wasRetriableUniqueRace()` (flag AND `lastErrorNumber===1062`);
|
|
155
|
+
the ASN model calls the IF-create with `throwExceptionsOnError:false`, sets the flag on an empty
|
|
156
|
+
1062 response, and aborts; the controller gate became
|
|
157
|
+
`wasDeadlock() || wasRetriableUniqueRace()`. Chose the whole-request replay (Approach B) over an
|
|
158
|
+
in-place `FOR UPDATE` recovery after a CTO review, because V2 **response poisoning** (a failed
|
|
159
|
+
nested save flips the shared `$api->response` to non-2xx and `execute()` self-rolls-back
|
|
160
|
+
DB_CORE+DB_CLIENT, `V2.php` ~2154-2159) makes in-place interceptor recovery impossible, and the
|
|
161
|
+
prod cluster's REPEATABLE-READ isolation makes a same-transaction re-SELECT blind to the winner's
|
|
162
|
+
commit. Recorded the durable V2 concurrency mechanics (response poisoning, `internalApiRequest`
|
|
163
|
+
5th arg, sticky-`lastErrorNumber` with 1213 protected / 1062 not, REPEATABLE-READ). Working tree
|
|
164
|
+
only, pending sandbox validation. (jcardinal)
|
|
85
165
|
- 2026-08-13 — Added deadlock/lock-wait detection to `_Database`/`_Query` (`$lastErrorNumber`,
|
|
86
166
|
`wasDeadlock()`, sticky errno recording) and a bounded, route-scoped in-process replay loop in
|
|
87
167
|
`Controller/Index.php`, gated to `/v2/advance-shipping-notices` only. Documented why whole-request
|
|
@@ -6,12 +6,13 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: workflow
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
9
|
+
updated: 2026-08-28
|
|
10
10
|
owners: ["jcardinal", "mhammontree", "bala", "apeterson"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh
|
|
13
13
|
- api2/ebs/register_instance_to_shared_application_load_balancer.php
|
|
14
14
|
- api2/.platform/hooks/prebuild/git.sh
|
|
15
|
+
- api2/.platform/hooks/postdeploy/015_install_composer.sh
|
|
15
16
|
- api2/.ebextensions/git.php
|
|
16
17
|
- api2/.ebextensions/git.sandbox-dev.json
|
|
17
18
|
related:
|
|
@@ -62,8 +63,11 @@ are actually changing before you merge anything:
|
|
|
62
63
|
`_<environment>`). Consequence: **a framework-only change requires REDEPLOYING THE CONSUMING
|
|
63
64
|
TIER even when `api2` itself has zero changes** — an instance **restart will not pick it up**,
|
|
64
65
|
because the clone happens in prebuild.
|
|
65
|
-
- **`api2` has no deploy workflow
|
|
66
|
-
linter
|
|
66
|
+
- **`api2` has no GitHub *Actions* deploy workflow** — its only GitHub Action is the
|
|
67
|
+
`TRUE-#####:` commit-message linter. **The production tiers, however, auto-deploy via AWS
|
|
68
|
+
CodePipeline on every push to `_production`** (a different system from GitHub Actions — see
|
|
69
|
+
*Production deploy: auto-trigger + rolling policy* below). Non-pipeline-triggered tiers are
|
|
70
|
+
still deployed by hand; don't wait for a GitHub Action to ship a framework change.
|
|
67
71
|
|
|
68
72
|
### ⚠ The `_underscore` SOURCE ACTION has its own branch trigger — and a green pipeline hides a wrong one
|
|
69
73
|
|
|
@@ -105,6 +109,64 @@ beta env actually received and returned when Sentry isn't wired up. (The Rate AI
|
|
|
105
109
|
scans this same `Logs_Rate.Api` for failed outbound calls — see
|
|
106
110
|
`clients/rate/features/aig-contract-creation.md`.)
|
|
107
111
|
|
|
112
|
+
## Production deploy: auto-trigger + rolling policy (2026-08-28)
|
|
113
|
+
|
|
114
|
+
### Production auto-deploys on every push to `_production` — a merge IS a deployment
|
|
115
|
+
The `api-production-1` (us-east-1), `api-production-2` (us-west-2), and `api-production-3`
|
|
116
|
+
(eu-west-1) CodePipelines each **auto-deploy on every push to branch `_production`** — a
|
|
117
|
+
**WebhookV2** trigger on the CodeConnections GitHub connection, watching source repos
|
|
118
|
+
`agilantsolutions/_underscore` and `agilantsolutions/api2`. There is **no separate manual deploy
|
|
119
|
+
step for production**: pushing or merging to `_production` **is** a production deployment to all
|
|
120
|
+
three regions. (This is why deploying a fix can itself page a moment later — see the
|
|
121
|
+
deploy-restart 502 below.)
|
|
122
|
+
|
|
123
|
+
**Triage consequence:** "we didn't deploy" can be false — any push to `_production` is a deploy.
|
|
124
|
+
Correlate an incident against the actual pipeline runs, not just remembered manual actions:
|
|
125
|
+
```bash
|
|
126
|
+
aws codepipeline list-pipeline-executions --pipeline-name api-production-1 --region us-east-1
|
|
127
|
+
aws elasticbeanstalk describe-events --environment-name api-production-1 --region us-east-1 # version-change events
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### ⚠ An EB `Degraded` right after a deploy = the deploy-restart 502
|
|
131
|
+
Under the old `AllAtOnce`/100% policy every deploy restarted Apache/PHP-FPM on **both**
|
|
132
|
+
instances of a 2-instance env simultaneously, dropping in-flight connections → a burst of
|
|
133
|
+
ELB-generated **HTTP 502** that EB flags as an "X% 5xx" `Degraded` ~3–4 min later (recovers in
|
|
134
|
+
~5 min). This is a **deploy artifact, not an application failure** — the full diagnostic
|
|
135
|
+
fingerprint (502 not 503/504, app `HTTPCode_Target_5XX_Count`=0, `HealthyHostCount` never < 2,
|
|
136
|
+
**nothing in `Logs.Api`** because a 502 never completes a PHP response) lives in
|
|
137
|
+
[api2 architecture — EB "Degraded" health](../architecture.md). The rolling policy below
|
|
138
|
+
eliminates it.
|
|
139
|
+
|
|
140
|
+
### Deploy policy: Rolling, batch size 1 (changed 2026-08-28)
|
|
141
|
+
All three production envs (app **"API"**, 2 instances each) were switched from
|
|
142
|
+
`AllAtOnce`/`Percentage`/`100` to **`DeploymentPolicy=Rolling`, `BatchSizeType=Fixed`,
|
|
143
|
+
`BatchSize=1`**, and the target group **`HealthyThresholdCount` dropped 3 → 2** (health path
|
|
144
|
+
`/health`, `HealthCheckInterval` 15 s, `UnhealthyThresholdCount` 5 unchanged). Rolling batch-1 on
|
|
145
|
+
a 2-instance env deploys in **2 batches with ≥1 healthy backend throughout**, so a deploy no
|
|
146
|
+
longer drops in-flight requests → **no more deploy-restart 502 blip**; the 3→2 threshold shaves
|
|
147
|
+
one 15 s interval (~45 s → 30 s) of per-batch health settle.
|
|
148
|
+
|
|
149
|
+
- Applied via a **config-only `update-environment`** (AWS CLI, per env) — no instance
|
|
150
|
+
replacement, no downtime; **takes effect on the NEXT deploy**.
|
|
151
|
+
- Namespaces: `aws:elasticbeanstalk:command` (`DeploymentPolicy` / `BatchSizeType` /
|
|
152
|
+
`BatchSize`); `aws:elasticbeanstalk:environment:process:default` (`HealthyThresholdCount`).
|
|
153
|
+
- Trade-off: Rolling is slower end-to-end than the old ~31 s `AllAtOnce`, and capacity briefly
|
|
154
|
+
drops to 1/2 instances per batch (fine at current traffic).
|
|
155
|
+
|
|
156
|
+
### Deploy-policy guidance (don't reach for immutable expecting speed)
|
|
157
|
+
- **Immutable does NOT reduce deploy time.** It is the **slowest** policy — it boots a fresh
|
|
158
|
+
instance batch, health-checks it, then swaps, and temporarily **doubles** the instance count.
|
|
159
|
+
It does fully eliminate deploy 5xx and gives instant rollback, but it is not "faster."
|
|
160
|
+
- **`AllAtOnce` is fast (~31 s) precisely BECAUSE both instances restart at once** — which is
|
|
161
|
+
what causes the 502s. You cannot have "as fast as AllAtOnce" **and** "zero 502s."
|
|
162
|
+
- **Rolling batch-1 is the sweet spot** for a 2-instance env.
|
|
163
|
+
- **`RollingWithAdditionalBatch`** is the option only if zero capacity loss during a deploy is
|
|
164
|
+
ever required (it adds a temporary instance; slower and costlier).
|
|
165
|
+
- The real deploy-**time** levers are independent of the policy: the health-check settle
|
|
166
|
+
(`HealthCheckInterval` × `HealthyThresholdCount`) and per-instance postdeploy work — api2 runs
|
|
167
|
+
`composer install` on **every** deploy via
|
|
168
|
+
`.platform/hooks/postdeploy/015_install_composer.sh` (a caching / pre-bake candidate).
|
|
169
|
+
|
|
108
170
|
## Steps
|
|
109
171
|
1. A push to the watched branch (e.g. `_stage`) triggers the pipeline.
|
|
110
172
|
2. The Source stage uses `CodeStarSourceConnection` actions, each referencing:
|
|
@@ -233,6 +295,20 @@ aws codeconnections get-connection --connection-arn "<CONN_ARN>" --region "$REGI
|
|
|
233
295
|
```
|
|
234
296
|
|
|
235
297
|
## Change history
|
|
298
|
+
- 2026-08-28 — Recorded the **production deploy trigger + policy change**: the `api-production-1/2/3`
|
|
299
|
+
CodePipelines **auto-deploy on every push to `_production`** (WebhookV2 on the CodeConnections
|
|
300
|
+
connection, repos `_underscore` + `api2`), so a merge to `_production` **is** a three-region prod
|
|
301
|
+
deploy — correlate incidents against `list-pipeline-executions`, not remembered manual actions.
|
|
302
|
+
Switched all three 2-instance envs from `AllAtOnce`/100% to **Rolling / Fixed / BatchSize 1** and
|
|
303
|
+
dropped the target-group **`HealthyThresholdCount` 3 → 2** (config-only `update-environment`,
|
|
304
|
+
effective next deploy) to eliminate the **deploy-restart 502 blip** (both instances restarting at
|
|
305
|
+
once dropped in-flight requests → ELB 502 → EB "X% 5xx" Degraded). Added deploy-policy guidance:
|
|
306
|
+
immutable is the **slowest** policy (not faster) and doubles instances; `AllAtOnce` is fast only
|
|
307
|
+
because it restarts both at once (the 502 cause); Rolling batch-1 is the sweet spot;
|
|
308
|
+
`RollingWithAdditionalBatch` only if zero capacity loss is needed; the real deploy-time levers are
|
|
309
|
+
the health-check settle and the per-deploy `015_install_composer.sh` `composer install`. Corrected
|
|
310
|
+
the "EB deploys are manual" note — that is only true of the absent GitHub *Actions* workflow; prod
|
|
311
|
+
deploys via CodePipeline are automatic. (jcardinal)
|
|
236
312
|
- 2026-08-25 — Documented that the Source stage's **`_underscore` action has its own AWS-side branch
|
|
237
313
|
trigger**, independent of `git.<env>.json` and invisible in the repo — a second way to silently run
|
|
238
314
|
the wrong framework branch. `ApiSandboxClient` ran `_production` from a mis-set source action while
|
|
@@ -5,7 +5,7 @@ project: _Underscore
|
|
|
5
5
|
client: compass-usa
|
|
6
6
|
type: client-feature
|
|
7
7
|
status: active
|
|
8
|
-
updated: 2026-08-
|
|
8
|
+
updated: 2026-08-28
|
|
9
9
|
owners: [jcardinal, bala]
|
|
10
10
|
files:
|
|
11
11
|
- _underscore/Model/Compass/AdvanceShippingNotice.php
|
|
@@ -200,6 +200,20 @@ Compass Canada (`Model/Compass/Canada/`) is a separate sub-client. The Compass c
|
|
|
200
200
|
(`$_model_skipParentCascadeOnSave`), and api2 retries the ASN route on a detected deadlock. Full
|
|
201
201
|
mechanic:
|
|
202
202
|
[save-cascade stored-field deadlock](../../../2.0/apps/_underscore/features/model-save-parent-cascade-stored-field-deadlock.md).
|
|
203
|
+
- **Concurrent same-SO-number ASNs lost the ASN + its tracking to a unique-key race (fixed
|
|
204
|
+
2026-08-28, working tree).** `postPost` get-or-creates the SO-numbered IF (GET by number, else
|
|
205
|
+
POST). `ItemFulfillments.number` is UNIQUE and vendors re-transmit the same shipment file, so two
|
|
206
|
+
concurrent same-number ASNs race: the loser's IF INSERT hits the unique key (MySQL **1062**),
|
|
207
|
+
`internalApiRequest` threw (default `throwExceptionsOnError=true`), the 500 propagated out of
|
|
208
|
+
`postPost`, and the api2 controller **rolled back the whole request — losing the ASN row AND its
|
|
209
|
+
tracking numbers** (`Logs.Issue #239`, EV-10). Fix: the IF-create call now passes
|
|
210
|
+
`throwExceptionsOnError:false`; on an empty response with `lastErrorNumber===1062` the model sets
|
|
211
|
+
`_Database::$retriableUniqueRace = true` and returns null, `postPost` aborts, and api2's Prong A
|
|
212
|
+
replays the whole request on a fresh REPEATABLE-READ snapshot so the retry's GET reuses the
|
|
213
|
+
winner's committed IF. Distinct from the 1213 deadlock above (a different concurrency failure on
|
|
214
|
+
the same re-transmission traffic). Full mechanic + why in-place recovery is impossible (V2
|
|
215
|
+
response poisoning):
|
|
216
|
+
[V2 request retry — Prong A](../../../2.0/apps/api2/features/v2-deadlock-retry.md).
|
|
203
217
|
- **Cross-line tracking contamination from bad SOI↔POI bridge rows (root cause, fixed
|
|
204
218
|
forward 2026-06-11):** `postPost` resolves which IFI(s) a tracking number attaches to by
|
|
205
219
|
joining `AdvanceShippingNoticeItems → SalesOrderItems_PurchaseOrderItems → SalesOrderItems`.
|
|
@@ -313,6 +327,13 @@ feed cannot currently support a line-level completeness test.
|
|
|
313
327
|
|
|
314
328
|
## Change history
|
|
315
329
|
Dated one-liners, newest first.
|
|
330
|
+
- 2026-08-28 — Fixed a **data-loss unique-key race** on concurrent same-SO-number ASNs: the loser's
|
|
331
|
+
SO-numbered IF INSERT trips `ItemFulfillments.number` UNIQUE (1062), `internalApiRequest` threw,
|
|
332
|
+
and api2 rolled back the whole request, losing the ASN row + tracking (`Logs.Issue #239`, EV-10).
|
|
333
|
+
The model now creates the IF with `throwExceptionsOnError:false`, flags a retriable 1062
|
|
334
|
+
(`_Database::$retriableUniqueRace`) and aborts, and api2's Prong A replays the request on a fresh
|
|
335
|
+
snapshot so the retry reuses the winner's IF. Distinct from the 2026-08-13 1213 deadlock. Working
|
|
336
|
+
tree only; also swept 6 banned `throw new _Exception(...)` → `\Exception` in the file. (jcardinal)
|
|
316
337
|
- 2026-08-27 — Quantified the **Office Depot duplicate-ASN** problem on prod and settled what it is:
|
|
317
338
|
of ODP PO lines with more than one shipping notice, **10,012 exceed the PO line quantity and only 8
|
|
318
339
|
are genuine split shipments**, so a second notice on an ODP line is a **duplicate**, not a partial.
|
package/package.json
CHANGED