toga-ai 1.0.454 → 1.0.455
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/_underscore/features/model-magic-field-access.md +16 -2
- package/knowledge/2.0/apps/_underscore/features/per-client-database-connections.md +38 -8
- package/knowledge/2.0/apps/api2/INDEX.md +1 -1
- package/knowledge/2.0/apps/api2/features/cross-client-data-retrieval.md +157 -67
- package/knowledge/2.0/apps/api2/features/encrypted-user-uuid-auth-handoff.md +18 -1
- package/knowledge/2.0/apps/worker2/INDEX.md +2 -1
- package/knowledge/2.0/apps/worker2/features/platform-cache-cleanup.md +69 -0
- package/knowledge/2.0/apps/worker2/features/talos-transcript-ingestion.md +110 -1
- package/knowledge/INDEX.md +1 -1
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-07-28
|
|
10
10
|
owners: ["jcardinal"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model/Core/Model.php
|
|
@@ -43,7 +43,15 @@ gate on `isset($model->field)` or `$model->field ?? $default` — both silently
|
|
|
43
43
|
|
|
44
44
|
## Gotchas / known issues
|
|
45
45
|
|
|
46
|
-
- `isset(
|
|
46
|
+
- **Treat `empty()` / `isset()` / `??` on a magic field as simply UNRELIABLE — the exact behavior
|
|
47
|
+
is not uniform.** Fields live in `$_model_fields[...]['value']` behind `__get()`. In the 2026-06-25
|
|
48
|
+
case `??` short-circuited to null; in the 2026-07-28 cross-client case the opposite mix appeared —
|
|
49
|
+
`empty($model->id)` reported a **populated** field (value `49`) as absent, while
|
|
50
|
+
`$model->id ?? $default` read it fine. Do not reason about which way it will fail. **Read the
|
|
51
|
+
value into a plain local variable first and test that local.** This cost three debugging rounds
|
|
52
|
+
because it failed in the "looks unauthenticated" direction — a populated identity read as empty.
|
|
53
|
+
A codebase-wide sweep for `empty(`/`isset(` on model fields is warranted.
|
|
54
|
+
- `isset($model->magicField)` / `$model->magicField ?? null` cannot be trusted for DB-backed
|
|
47
55
|
magic fields. Use `getFieldConfig()` + `array_key_exists` to test presence.
|
|
48
56
|
- A bare `__get` on an unconfigured field **throws** — never read a maybe-absent field without the
|
|
49
57
|
`getFieldConfig()` guard.
|
|
@@ -52,6 +60,12 @@ gate on `isset($model->field)` or `$model->field ?? $default` — both silently
|
|
|
52
60
|
|
|
53
61
|
## Change history
|
|
54
62
|
|
|
63
|
+
- 2026-07-28 — Broadened the rule after the cross-client work: `empty()` can report a **populated**
|
|
64
|
+
field as absent while `??` reads it correctly — the inverse of the 2026-06-25 symptom. The safe
|
|
65
|
+
rule is now "copy to a plain local, then test the local," and a codebase-wide sweep is warranted
|
|
66
|
+
because it fails in the "looks unauthenticated" direction. Also noted the sibling trap: `_Model`
|
|
67
|
+
writes every **declared** field explicitly, so a DB column not declared on the model both makes
|
|
68
|
+
`__get` throw and means the column's SQL default never applies on insert. (jcardinal)
|
|
55
69
|
- 2026-06-25 — Documented the `__get`-without-`__isset` gap and the `getFieldConfig()` +
|
|
56
70
|
`array_key_exists` safe-read pattern, discovered while debugging a missing `uuid` in the API2
|
|
57
71
|
translation fallback warning. (jcardinal)
|
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
9
|
+
updated: 2026-07-28
|
|
10
10
|
owners: ["dfranks", "jcardinal", "mhammontree", "apeterson", "kyalamarthi"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Database.php
|
|
@@ -50,8 +50,11 @@ The three per-client aliases and their concrete local schemas:
|
|
|
50
50
|
| `ClientLogs` (`DB_CLIENT_LOGS`) | `Logs_<Id>` | **API transaction logging** |
|
|
51
51
|
| `Archive` | `Archive_<Id>` | historical snapshots |
|
|
52
52
|
|
|
53
|
-
`<Id>` is the client's `clientIdentifier` from `Core.Clients` (e.g. `Growrk` →
|
|
54
|
-
`Logs_Growrk`, `Archive_Growrk`)
|
|
53
|
+
`<Id>` is *usually* the client's `clientIdentifier` from `Core.Clients` (e.g. `Growrk` →
|
|
54
|
+
`Client_Growrk`, `Logs_Growrk`, `Archive_Growrk`) — but that is a naming convention, **not** a rule
|
|
55
|
+
you may rely on in code. **Always resolve the real schema name through
|
|
56
|
+
`Core.Clients.clientDatabaseId` → `Core.Databases.name`** (see the gotcha below). A `Logs_<Id>`
|
|
57
|
+
schema has 5 tables:
|
|
55
58
|
`Api, CustomRecordField, Error, Record, RecordField`.
|
|
56
59
|
|
|
57
60
|
## Data model
|
|
@@ -63,18 +66,28 @@ Engine InnoDB, `utf8mb4_unicode_ci`.
|
|
|
63
66
|
|
|
64
67
|
## Client variations
|
|
65
68
|
|
|
66
|
-
|
|
69
|
+
Structurally uniform, but two per-client values genuinely differ and must never be assumed:
|
|
70
|
+
|
|
71
|
+
- **The schema NAME** — resolve `Core.Clients.clientDatabaseId` → `Core.Databases.name`. Observed
|
|
72
|
+
divergence: `clientIdentifier` = `Compass_Usa` but the database is `Client_Compass`.
|
|
73
|
+
- **The CHARSET/COLLATION** — client databases do **not** share one. `utf8mb4_unicode_ci`,
|
|
74
|
+
`utf8mb4_0900_ai_ci` and `latin1_swedish_ci` were all observed on the same logical column across
|
|
75
|
+
tenants.
|
|
67
76
|
|
|
68
77
|
## Related shared clusters (not per-client)
|
|
69
78
|
|
|
70
79
|
Alongside the three per-client aliases and the shared **Core** cluster, a dedicated shared
|
|
71
|
-
**Cache** cluster was added (2026-07-07)
|
|
72
|
-
`
|
|
80
|
+
**Cache** cluster was added (2026-07-07), alias `DB_CACHE`. It is resolved **by name**
|
|
81
|
+
(`Databases.name = 'Cache'`) in both api2 and worker2 — there is deliberately **no
|
|
82
|
+
`CACHE_DATABASE_ID` constant**, because `Databases.id` is auto-assigned per Core instance and
|
|
83
|
+
differs per environment, so a hardcoded id works only where it happens to match and fails silently
|
|
84
|
+
elsewhere. It is modeled like Core (a single shared cluster, **not**
|
|
73
85
|
per-client), region-aware (1=us-east-1, 2=us-west-2, 3=eu-west-1) with per-region `DatabaseHosts`
|
|
74
86
|
(`hostCluster` DevOps-TBD). It exists to keep cache churn off Core and backs the api2
|
|
75
87
|
[multi-client data retrieval](../../api2/features/cross-client-data-retrieval.md) engine. Boot
|
|
76
|
-
registration is
|
|
77
|
-
`Records.ttlCache` (TINYINT UNSIGNED, default 15) governs cache TTL
|
|
88
|
+
registration is name-based in `api2/Controller/Index.php` and `worker2/Controller/Index.php`.
|
|
89
|
+
`Records.ttlCache` (TINYINT UNSIGNED, default 15) governs cache TTL; `0` means "effectively
|
|
90
|
+
uncached" but is **floored at 1 minute** so an entry always outlives the request that built it.
|
|
78
91
|
|
|
79
92
|
### The `Team` schema rides the Core cluster (`DB_TEAM`)
|
|
80
93
|
|
|
@@ -94,6 +107,15 @@ here — they live in `Config/*.ini`.)
|
|
|
94
107
|
|
|
95
108
|
## Gotchas / known issues
|
|
96
109
|
|
|
110
|
+
- **NEVER build a client's database name as `'Client_' . $clientIdentifier`.** They diverge in
|
|
111
|
+
production (`Compass_Usa` → `Client_Compass`). Resolve
|
|
112
|
+
`Core.Clients.clientDatabaseId` → `Core.Databases.name`. This mirrors the log-database resolution
|
|
113
|
+
already done in `_underscore`'s `Email.php`. A string-built name fails only for the clients whose
|
|
114
|
+
names happen to diverge, so it passes local testing and breaks in production.
|
|
115
|
+
- **Client DBs do not share a charset, so any hardcoded `COLLATE` is invalid somewhere.** Emitting
|
|
116
|
+
e.g. `COLLATE utf8mb4_unicode_ci` against a `latin1` column errors with `COLLATION ... is not
|
|
117
|
+
valid for CHARACTER SET 'latin1'`. For cross-tenant string comparison use `CAST(expr AS BINARY)`
|
|
118
|
+
(byte ordering) rather than naming a collation.
|
|
97
119
|
- **The "logs DB write trap" (local dev).** A laptop usually imports only `Client_<Id>`, not
|
|
98
120
|
`Logs_<Id>`/`Archive_<Id>`. Because logging is **on by default**, the first request 500s on
|
|
99
121
|
`Unknown database 'logs_<id>'` from `ApiRequest.php`'s logging write — *after* the real work
|
|
@@ -130,6 +152,14 @@ here — they live in `Config/*.ini`.)
|
|
|
130
152
|
|
|
131
153
|
## Change history
|
|
132
154
|
|
|
155
|
+
- 2026-07-28 — **Corrected a wrong rule:** a client's schema name is *not* reliably
|
|
156
|
+
`'Client_' . clientIdentifier` (`Compass_Usa` → `Client_Compass`); resolve
|
|
157
|
+
`Clients.clientDatabaseId` → `Databases.name`. Recorded that client DBs do **not** share a
|
|
158
|
+
charset (utf8mb4_unicode_ci / utf8mb4_0900_ai_ci / latin1_swedish_ci observed on one column), so
|
|
159
|
+
hardcoded `COLLATE` is invalid somewhere — use `CAST(... AS BINARY)`. Replaced the Cache
|
|
160
|
+
cluster's hardcoded id 145 / `CACHE_DATABASE_ID` with **name-based resolution**
|
|
161
|
+
(`Databases.name = 'Cache'`), since ids are per-Core-instance and a hardcoded id fails silently
|
|
162
|
+
per environment. (jcardinal)
|
|
133
163
|
- 2026-07-27 — Recorded that the three per-client aliases key **all** live connection state, so
|
|
134
164
|
re-pointing an alias mid-request does not reconnect on its own; split the detail into
|
|
135
165
|
[Re-pointing a DB alias mid-request](./database-alias-repointing.md). (jcardinal)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
| Doc | Summary | Files |
|
|
4
4
|
|-----|---------|-------|
|
|
5
5
|
| [API (api2 / TOGa API v2) Architecture](architecture.md) | `api2` is the backend powering the public **TOGa 2.0 API**. | api2/Controller/Index.php, api2/Component/Api/V2/V2.php, api2/Component/Api/Cxml/Cxml.php, api2/Component/Api/V2/Response/Response.php, api2/Config/ |
|
|
6
|
-
| [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,
|
|
6
|
+
| [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 |
|
|
7
7
|
| [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 |
|
|
8
8
|
| [Health-check endpoint (/health liveness short-circuit)](features/health-check-endpoint.md) | `_Controller_Index::api()` short-circuits **liveness/health-probe** requests to an HTTP 200 **before** any routing, DB bootstrap, or V2 engine work runs. | api2/Controller/Index.php |
|
|
9
9
|
| [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 |
|
|
@@ -5,23 +5,29 @@ repo: api2
|
|
|
5
5
|
project: API
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
|
-
status:
|
|
9
|
-
updated: 2026-07-
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-07-28
|
|
10
10
|
owners: [jcardinal]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/CrossClient/CrossClient.php
|
|
13
13
|
- api2/Component/Api/V2/V2.php
|
|
14
14
|
- api2/Controller/Index.php
|
|
15
|
-
- api2/_.php
|
|
16
15
|
- _underscore/Model/Cache/Table.php
|
|
17
16
|
- _underscore/Model/Cache/Tables/Client.php
|
|
18
|
-
-
|
|
17
|
+
- _underscore/Model/Core/Record.php
|
|
18
|
+
- worker2/Worker/Platform/Cache.php
|
|
19
|
+
- worker2/Controller/Index.php
|
|
20
|
+
- worker2/_.php
|
|
19
21
|
- dbchanges2/Cache/2026-06-30a - MultiClientCacheTables.sql
|
|
22
|
+
- dbchanges2/Core/2026-06-30b - CacheClusterRegistrationAndRecordTtl.sql
|
|
23
|
+
- dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql
|
|
20
24
|
related:
|
|
21
25
|
- ./encrypted-user-uuid-auth-handoff.md
|
|
26
|
+
- ../../worker2/features/platform-cache-cleanup.md
|
|
22
27
|
- ../../_underscore/features/per-client-database-connections.md
|
|
23
28
|
- ../../_underscore/features/acl-permission-chain.md
|
|
24
29
|
- ../../_underscore/features/database-alias-repointing.md
|
|
30
|
+
- ../../_underscore/features/model-magic-field-access.md
|
|
25
31
|
---
|
|
26
32
|
|
|
27
33
|
## What it is
|
|
@@ -33,13 +39,13 @@ single-client path is byte-for-byte unchanged.
|
|
|
33
39
|
|
|
34
40
|
Because client DBs live on **different clusters**, a cross-DB JOIN/UNION is impossible. The feature
|
|
35
41
|
is a **scatter-gather** engine: it fans out to each entitled client's own V2 API, merges the
|
|
36
|
-
streams, and caches the result per-user on a dedicated Cache
|
|
42
|
+
streams with a watermark, and caches the merged result per-user/per-query on a dedicated Cache
|
|
43
|
+
cluster.
|
|
37
44
|
|
|
38
|
-
> **Status:
|
|
39
|
-
>
|
|
40
|
-
>
|
|
41
|
-
>
|
|
42
|
-
> owner-accepted deferred risk.
|
|
45
|
+
> **Status: working end to end on local dev** (multi-client fan-out, ACL-filtered `client` object,
|
|
46
|
+
> typed multi-column sort, keyset paging, narrowed `fields`, per-query cache isolation).
|
|
47
|
+
> **Not yet deployed** — next stop is external testing. See
|
|
48
|
+
> [api2 architecture](../architecture.md) for the accepted risks carried into that test.
|
|
43
49
|
|
|
44
50
|
## How it works
|
|
45
51
|
|
|
@@ -47,84 +53,168 @@ Entry point is `_Component_Api_CrossClient` (`api2/Component/Api/CrossClient/Cro
|
|
|
47
53
|
delegated to from a guard at the **top** of the `ACTION__LIST` case in
|
|
48
54
|
`_Component_Api_V2::processRoutePairs()`: when `isCrossClientRequest($httpOptions)` is true (a
|
|
49
55
|
non-empty `client` option), it calls `handleListing()`, merges result rows into `$outData` and meta
|
|
50
|
-
into `$this->response->meta`,
|
|
51
|
-
single-client GETs.
|
|
56
|
+
into `$this->response->meta`, and breaks. The guard is **inert** on ordinary single-client GETs.
|
|
52
57
|
|
|
53
58
|
1. **resolveScope** — the caller's entitled clients resolve via the `Users_Clients` bridge
|
|
54
59
|
(`Users.homeClientUserId` maps one home user to per-client user records across tenant DBs); each
|
|
55
|
-
target yields a minted per-client identity.
|
|
60
|
+
target yields a minted per-client identity. This is **serial** — one DB context switch + query
|
|
61
|
+
per permitted client, on every request including cache hits.
|
|
56
62
|
2. **queryHash** — SHA-256 of the normalized query; the cache key.
|
|
57
|
-
3. **Build lock** — a named `GET_LOCK`
|
|
58
|
-
concurrent builds.
|
|
63
|
+
3. **Build lock** — a named `GET_LOCK` over a **fixed-width digest** of client/user/hash.
|
|
59
64
|
4. **Fan out** — a two-phase concurrent HTTP handshake per client (see
|
|
60
65
|
[encrypted-user-uuid-auth-handoff](./encrypted-user-uuid-auth-handoff.md)): Phase A mints an
|
|
61
66
|
access token, Phase B issues the authenticated GET.
|
|
62
|
-
5. **Watermark k-way merge** — client streams
|
|
63
|
-
watermark
|
|
64
|
-
|
|
65
|
-
6. **Cache build/serve** — rows are
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
###
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
67
|
+
5. **Watermark k-way merge** — client streams merge into a globally-correct sort order behind a
|
|
68
|
+
watermark; a `deepen` loop pulls more from the lagging stream, capped by a 50-iteration backstop
|
|
69
|
+
plus a **stall guard** (a pass that returns no new rows with nobody exhausted logs and breaks).
|
|
70
|
+
6. **Cache build/serve** — merged rows are written to a per-query result table on the Cache
|
|
71
|
+
cluster; `servePage` emits a keyset page.
|
|
72
|
+
|
|
73
|
+
### Row identity is the record `uuid` — never the numeric id
|
|
74
|
+
|
|
75
|
+
A V2 LIST response **never exposes `id`**. The original design keyed cached rows on
|
|
76
|
+
`primaryKeyId` + `seekId`, which could not work: every row arrived with `id = 0`, collided on the
|
|
77
|
+
unique key, and the cache retained exactly one row per page. Result rows are now keyed on
|
|
78
|
+
**`rowUuid CHAR(36)`** and the keyset cursor tiebreaker is **`seekUuid`**. The default sort is
|
|
79
|
+
`uuid` (it used to be `id`, a field the API never returns → null sort values and a cursor that
|
|
80
|
+
could not advance).
|
|
81
|
+
|
|
82
|
+
### Per-query result tables with TYPED sort columns
|
|
83
|
+
|
|
84
|
+
Each cached query gets its own `TableResults_<Tables.id>` table carrying **one typed column per
|
|
85
|
+
requested sort field** — type taken from the model field, or `FIELDOPT_SQL_TYPE` for calculated
|
|
86
|
+
`FIELD_SQL` fields, defaulting to `CHAR`. Tables are per-**query**, not per record type, precisely
|
|
87
|
+
so the sort columns can be typed for that query. The index declares per-column **ASC/DESC
|
|
88
|
+
directions matching the ORDER BY**; without that, any DESC sort filesorts the entire table.
|
|
89
|
+
|
|
90
|
+
### Multi-column keyset seek (V2)
|
|
91
|
+
|
|
92
|
+
The seek generalised from single-column `seekValue` + `seekId` to **`seekValues`** (a JSON array)
|
|
93
|
+
+ **`seekUuid`**: a lexicographic predicate with NULL-safe `<=>` links and typed literals, tie-broken
|
|
94
|
+
on `CAST(uuid AS BINARY)`. The fan-out must send `sort` **explicitly** (rebuilt from the resolved
|
|
95
|
+
sort specs) — V2 only engages its keyset when the sub-request itself carries sort terms, and the
|
|
96
|
+
term count must match the `seekValues` tuple.
|
|
97
|
+
|
|
98
|
+
### Deepen-on-demand paging + watermark-only deepening
|
|
99
|
+
|
|
100
|
+
`Tables.safeRecordCount` records how deep the cache is **provably complete**. The raw stored row
|
|
101
|
+
count must **never** be used for that decision: a fast client can leave rows cached that a lagging
|
|
102
|
+
client would displace. Duplicate rows are de-duplicated by `clientId|rowUuid` before counting —
|
|
103
|
+
double-counting inflated `safeRecordCount` (25 real rows reported as 50), so later pages skipped
|
|
104
|
+
deepening and returned empty pages.
|
|
105
|
+
|
|
106
|
+
**Only the watermark-holding client is deepened per pass.** A client whose cursor is already past
|
|
107
|
+
the watermark cannot make any row safe, so querying it is a wasted round trip. At 1000 clients that
|
|
108
|
+
is ~1 sub-request per pass instead of 1000 — the single biggest efficiency property of the design.
|
|
109
|
+
|
|
110
|
+
### Per-row `client` object (ACL-filtered)
|
|
111
|
+
|
|
112
|
+
Every returned row carries a `client` object filtered by the caller's **CORE** roles
|
|
113
|
+
(`id.core.roles`) against the Core `Clients` record. `getAclFieldPermissions()` interprets role ids
|
|
114
|
+
"in the perspective of `$record->aclDatabase`", so a CORE record requires **core** role ids — client
|
|
115
|
+
role ids silently yield nothing.
|
|
116
|
+
|
|
117
|
+
### Narrowed `fields`
|
|
118
|
+
|
|
119
|
+
The fan-out injects `uuid` + the sort fields into each sub-request (the merge cannot identify or
|
|
120
|
+
order rows without them) and strips them back out of the returned rows. This resolves **per
|
|
121
|
+
request**, not per fan-out, so cache hits strip too.
|
|
122
|
+
|
|
123
|
+
### Failure surfacing
|
|
124
|
+
|
|
125
|
+
Sub-request failures reach the caller: `meta.crossClient.failures[]` plus an ERROR per failed client
|
|
126
|
+
in `messages[]`, and the response no longer reports 200/success when **every** in-scope client
|
|
127
|
+
failed. Previously a 500 inside a sub-request returned 200 with an empty page.
|
|
128
|
+
|
|
129
|
+
Cross-client `meta` now mirrors the single-client LIST meta exactly — `depth`, `calcDepth`, `page`,
|
|
130
|
+
`prevPage`, `nextPage`, `totalPageCount`, `recordsPerPage`, `pageRecordCount`, `totalRecordCount`,
|
|
131
|
+
`prohibitedRecordCount` — plus the `crossClient` block.
|
|
132
|
+
|
|
133
|
+
### Cache cluster & cleanup
|
|
134
|
+
|
|
135
|
+
The dedicated **Cache** cluster is registered for **all** environments, and both api2 and worker2
|
|
136
|
+
resolve it **by name** (`Databases.name = 'Cache'`). There is deliberately **no
|
|
137
|
+
`CACHE_DATABASE_ID` constant** — `Databases.id` is auto-assigned per Core instance and differs per
|
|
138
|
+
environment, so a hardcoded id works only where it happens to match and fails silently everywhere
|
|
139
|
+
else. This was caught as a deploy blocker.
|
|
140
|
+
|
|
141
|
+
Expired `Tables` rows and orphaned `TableResults_*` tables are reaped by
|
|
142
|
+
[`_Worker_Platform_Cache::Clean()`](../../worker2/features/platform-cache-cleanup.md) on a `*/5`
|
|
143
|
+
cron.
|
|
95
144
|
|
|
96
145
|
## Key rules
|
|
97
146
|
|
|
98
147
|
- **Triggered only by a non-empty `client` option** — single-client GETs are unaffected.
|
|
99
|
-
- **
|
|
100
|
-
|
|
101
|
-
- **
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
148
|
+
- **Row identity is the `uuid`**, never the numeric primary key. Chosen over exposing internal ids
|
|
149
|
+
over HTTP, consistent with the platform never emitting `id`.
|
|
150
|
+
- **Cross-client is USER-LEVEL ONLY.** API credentials (`Client.Apis`) have no cross-client
|
|
151
|
+
entitlement bridge — `Users_Clients` is user-keyed and there is no `Apis_Clients` equivalent.
|
|
152
|
+
`homeClientApiId` was dropped from the schema entirely.
|
|
153
|
+
- **`Records.ttlCache = 0` means "effectively uncached" but is FLOORED at 1 minute**, so a cache
|
|
154
|
+
entry always outlives the request that built it and the cleanup worker cannot delete rows
|
|
155
|
+
mid-read.
|
|
156
|
+
- **String/uuid comparison uses BYTE ordering** — `utf8mb4_bin` in the cache, `CAST(... AS BINARY)`
|
|
157
|
+
in client queries — because the PHP-side watermark merge compares bytes. Any linguistic collation
|
|
158
|
+
would order differently and the merge could certify rows complete that MySQL then re-orders.
|
|
159
|
+
- **Never use the raw stored row count** to decide paging depth; only `safeRecordCount`.
|
|
160
|
+
- **Every outbound sub-request needs its own globally-unique `transactionId`.**
|
|
161
|
+
- **Forward the caller's original query string verbatim** — parsed `$httpOptions` do not round-trip
|
|
162
|
+
(see the url-decode gotcha below). `MAX_CONCURRENT_CLIENT_FETCHES = 25` bounds fan-out.
|
|
107
163
|
|
|
108
164
|
## Gotchas
|
|
109
165
|
|
|
110
|
-
- **
|
|
111
|
-
`
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
never
|
|
115
|
-
- **
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
`
|
|
166
|
+
- **V2 NEVER url-decodes its query string.** It splits `QUERY_STRING` on `&` and `=` and consumes
|
|
167
|
+
the raw values. Anything containing `,`, `[`, `"`, `%`, `+` or a space arrives mangled — this bit
|
|
168
|
+
the feature twice (the JSON `seekValues` array, and a comma-separated `fields` list corrupted into
|
|
169
|
+
a single field literally named `number%2Cuuid`). Corollary: a fan-out must forward options
|
|
170
|
+
**verbatim**, never `parse_str` → `http_build_query`.
|
|
171
|
+
- **V2 builds `$httpOptions` from an explicit allowlist.** `client`, `seekValues` and `seekUuid`
|
|
172
|
+
were never parsed into it, so the cross-client branch was unreachable and the keyset could never
|
|
173
|
+
engage. Adding an option means adding it to that allowlist.
|
|
174
|
+
- **Client databases do NOT share a charset** — `utf8mb4_unicode_ci`, `utf8mb4_0900_ai_ci` and
|
|
175
|
+
`latin1_swedish_ci` were all observed on the same column across tenants. Any hardcoded `COLLATE`
|
|
176
|
+
is invalid somewhere (`COLLATION ... is not valid for CHARACTER SET 'latin1'`). Use
|
|
177
|
+
`CAST(... AS BINARY)`.
|
|
178
|
+
- **A client's database NAME must come from `Core.Clients.clientDatabaseId` → `Core.Databases.name`**
|
|
179
|
+
— never built as `'Client_' . clientIdentifier`. They diverge in practice (identifier
|
|
180
|
+
`Compass_Usa`, database `Client_Compass`).
|
|
181
|
+
- **MySQL `GET_LOCK` names cap at 64 chars and ERROR rather than truncate.** The original lock name
|
|
182
|
+
was 80 chars.
|
|
183
|
+
- **`_Query::fetchRows()` returns OBJECTS**, not arrays. CrossClient read them with array syntax
|
|
184
|
+
throughout.
|
|
185
|
+
- **A V2 LIST nests records under the route key** (`data: {"salesOrders": [...]}`);
|
|
186
|
+
`parseClientResponse` required `data` to be a flat array and discarded every successful
|
|
187
|
+
sub-response as unusable.
|
|
188
|
+
- **`_Model` writes every declared field explicitly, so column defaults never apply.**
|
|
189
|
+
`Tables.safeRecordCount` is NOT NULL and `buildCache` never assigned it. Likewise a Core column
|
|
190
|
+
(`Records.ttlCache`) that exists in SQL but is not declared on the model makes `__get` throw.
|
|
191
|
+
- **Dangling foreign keys used to fail an entire listing.** `getFullModelData()` threw out of
|
|
192
|
+
`_Model::initialize()` when a referenced row did not exist; one orphaned pointer killed the whole
|
|
193
|
+
page. It now logs and expands the relationship to `null`. **This affects every REST listing/GET in
|
|
194
|
+
api2, not just cross-client.**
|
|
195
|
+
- **`empty()`/`isset()` on a `_Model` field is unreliable** and fails in the "looks unauthenticated"
|
|
196
|
+
direction — see [_Model magic-field access](../../_underscore/features/model-magic-field-access.md).
|
|
197
|
+
- **Switching the `Client` DB alias mid-request** needs the park/restore path — `_Database` keys
|
|
198
|
+
live connection state by **alias**, not physical schema. See
|
|
120
199
|
[Re-pointing a DB alias mid-request](../../_underscore/features/database-alias-repointing.md).
|
|
121
200
|
- **`curl_multi` busy-spin guard** — both multi loops `usleep(100)` when
|
|
122
201
|
`curl_multi_select() === -1`.
|
|
123
202
|
|
|
124
203
|
## Change history
|
|
204
|
+
- 2026-07-28 — Took the feature from never-executed to working end to end. Row identity moved from
|
|
205
|
+
the numeric id to `rowUuid`/`seekUuid` (a V2 LIST never returns `id`); per-query
|
|
206
|
+
`TableResults_<id>` tables with typed, direction-indexed sort columns; multi-column `seekValues`
|
|
207
|
+
keyset; `safeRecordCount` watermark with de-dup by `clientId|rowUuid` and watermark-only
|
|
208
|
+
deepening; ACL-filtered per-row `client` object (CORE roles); narrowed `fields`; sub-request
|
|
209
|
+
failures surfaced in `meta.crossClient.failures[]` + `messages[]`; Cache cluster registered for
|
|
210
|
+
all environments and resolved **by name** (no `CACHE_DATABASE_ID`); worker2 cache reaper on a
|
|
211
|
+
`*/5` cron. Cross-client declared **user-level only** (`homeClientApiId` dropped). Fixed a dozen
|
|
212
|
+
latent runtime bugs (namespace, option allowlist, 80-char `GET_LOCK`, undeclared `ttlCache` field,
|
|
213
|
+
unassigned `safeRecordCount`, route-key-nested `data`, `id` default sort, missing explicit `sort`,
|
|
214
|
+
duplicate counting, object-vs-array `fetchRows`, dangling-FK listing failure). (jcardinal)
|
|
125
215
|
- 2026-07-27 — Root-caused cross-client reads hitting the home client's DB: `_Database` keys live
|
|
126
216
|
connection state by alias, so re-registering `DB_CLIENT` never swapped the open link. Fixed in
|
|
127
217
|
`_underscore/Database.php`; CrossClient needed no edit. (jcardinal)
|
|
128
218
|
- 2026-07-07 — Initial capture: scatter-gather cross-client retrieval engine (orchestrator, watermark
|
|
129
|
-
k-way merge, keyset pagination
|
|
219
|
+
k-way merge, keyset pagination, `client`-option delegation, Cache cluster).
|
|
130
220
|
Code-complete + reviewer-hardened, not yet runtime-tested. (jcardinal)
|
|
@@ -6,7 +6,7 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
9
|
+
updated: 2026-07-28
|
|
10
10
|
owners: [jcardinal]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/CrossClient/CrossClient.php
|
|
@@ -22,6 +22,12 @@ without the caller holding the target client's credentials. It is what the scatt
|
|
|
22
22
|
[cross-client data retrieval](./cross-client-data-retrieval.md) engine uses to authenticate each
|
|
23
23
|
per-client sub-request.
|
|
24
24
|
|
|
25
|
+
> **Correction (2026-07-28): the route is ORIGIN-GATED and browser-shaped.** It resolves the
|
|
26
|
+
> `Origin` header against `Core.Domains` to establish `appId`/`clientId` *before* minting, and
|
|
27
|
+
> rejects an unknown origin with **EN-6**. It therefore **cannot** be used server-to-server without
|
|
28
|
+
> presenting a domain registered for the target client. Earlier revisions of this doc described it
|
|
29
|
+
> as the cross-client engine's auth mechanism without noting this constraint — plan for it.
|
|
30
|
+
|
|
25
31
|
## How it works
|
|
26
32
|
|
|
27
33
|
- The encrypted `{client, user}` pairs are produced during **normal** auth: `/auth/api` and
|
|
@@ -41,6 +47,12 @@ per-client sub-request.
|
|
|
41
47
|
|
|
42
48
|
## Key rules
|
|
43
49
|
|
|
50
|
+
- **Origin gating is mandatory.** The `Origin` header must resolve to a `Core.Domains` row for the
|
|
51
|
+
target client; that lookup — not the caller's own session — establishes the `appId`/`clientId` the
|
|
52
|
+
token is minted under. Unknown origin → **EN-6**.
|
|
53
|
+
- **The minted token's `appId` is the resolved DOMAIN's app, not the caller's app.** Observed in
|
|
54
|
+
cross-client fan-out: sub-request tokens carried `appId 1` (TOGa Supply) while the caller was
|
|
55
|
+
Talos. Harmless for reads today; it must be revisited before anything ACLs on `appId`.
|
|
44
56
|
- **Identity source of truth is the `Users_Clients` bridge** — `Users.homeClientUserId` maps one
|
|
45
57
|
home user to its per-client user records across tenant DBs; that is where the per-client UUIDs
|
|
46
58
|
come from.
|
|
@@ -50,5 +62,10 @@ per-client sub-request.
|
|
|
50
62
|
tolerates the rotation set.
|
|
51
63
|
|
|
52
64
|
## Change history
|
|
65
|
+
- 2026-07-28 — Corrected: the route is **origin-gated** (resolves `Origin` against `Core.Domains`
|
|
66
|
+
to establish `appId`/`clientId`, EN-6 on unknown origin), so it is browser-shaped and cannot be
|
|
67
|
+
used server-to-server without a registered domain for the target client. Also recorded that the
|
|
68
|
+
minted token's `appId` follows the resolved domain's app, not the caller's. Found while getting
|
|
69
|
+
the cross-client fan-out running end to end. (jcardinal)
|
|
53
70
|
- 2026-07-07 — Documented `/auth/encrypted-user-uuid` as the cross-client/SSO identity-handoff route
|
|
54
71
|
and its two-phase use by the cross-client fan-out engine. (jcardinal)
|
|
@@ -24,10 +24,11 @@
|
|
|
24
24
|
| [Background Email-Template Worker (_Worker_Notification_EmailTemplate)](features/notification-email-template.md) | `_Worker_Notification_EmailTemplate::Send(...)` dispatches a **stored, client-defined `EmailTemplates` row off-thread** as a background WorkerJob. | worker2/Worker/Notification/EmailTemplate.php, worker2/Worker/Client/True.php, _underscore/Model/Client/EmailTemplate.php |
|
|
25
25
|
| [DB-Driven Notification (Internal) Email](features/notification-email.md) | Internal/notification emails (merge-conflict alerts, ops notices — anything system-generated, not client-facing transactional mail) are sent through one worker | worker2/Worker/Notification/Email.php, _underscore/Model/Client/EmailTemplate.php, dbchanges2/Client/2026-06-23a - EmailTemplateWrapper.sql, dbchanges2/Client_True/2026-06-23a - EmailTemplateWrapper.sql |
|
|
26
26
|
| [OneUptime push-metric monitors for 2.0 workers](features/oneuptime-worker2-monitoring.md) | A second, **OneUptime-reporting** monitoring pattern for the 2.0 worker2 tier, ported from the 1.0 `App_SystemMonitor_Compass` monitors. | worker2/Worker/Monitor/Compass.php, worker2/Worker/Client/Compass.php, worker2/composer.json, _underscore/Cloud.php |
|
|
27
|
+
| [Platform Cache Cleanup (_Worker_Platform_Cache::Clean)](features/platform-cache-cleanup.md) | `_Worker_Platform_Cache::Clean()` is the reaper for the shared **Cache** cluster that backs api2's [multi-client data retrieval](../../api2/features/cross-clien | worker2/Worker/Platform/Cache.php, worker2/Controller/Index.php, worker2/_.php, dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql |
|
|
27
28
|
| [Startech Webhook Handler (worker2)](features/startech-webhook-handler.md) | Receives inbound webhook events from Startech (Easeedesk) and creates or updates the corresponding ticket in TOGA 2.0. | worker2/Worker/Startech.php |
|
|
28
29
|
| [Talos (TOGa IQ) Meeting-Notes Integration & Token Auto-Refresh (consumer)](features/talos-meeting-notes-integration.md) | How a **dev tool / agent consumes Talos (TOGa IQ)** to query the team meeting-notes corpus programmatically. | .claude/skills/plan-ticket/scripts/talos.js |
|
|
29
30
|
| [Talos Pricing Automation (worker2 Cron — AWS Actuals, Calibration, Monthly Report)](features/talos-pricing-automation.md) | The worker2 half of the **Talos Pricing Platform** (see the talos `pricing-cogs-model` and tools `talos-pricing-ui` docs for the other halves). | worker2/Worker/Talos/Pricing.php, worker2/Database/TalosPricingCrons.sql |
|
|
30
|
-
| [Talos Transcript Ingestion Pipeline (worker2 → AWS Bedrock KBs)](features/talos-transcript-ingestion.md) | `_Worker_Team_Transcripts` runs a fully automated, cron-driven pipeline that ingests raw Teams transcripts into the **Talos / TOGa IQ** AWS Bedrock knowledge ba | worker2/Worker/Team/Transcripts.php, worker2/bin/sync-knowledge-bases.php, worker2/Config/production.ini, worker2/Database/TeamsTranscriptExports.sql, dbchanges2/Team/2026-06-30a, dbchanges2/Team/2026-06-30b, dbchanges2/Team/2026-06-30c, dbchanges2/Team/2026-06-30d, dbchanges2/Team/2026-06-30e, dbchanges2/Core/2026-06-30a, dbchanges2/Core/2026-07-02a, dbchanges2/Team/2026-07-02a, dbchanges2/Team/2026-07-08a, dbchanges2/Team/2026-07-09a, dbchanges2/Team/2026-07-10a |
|
|
31
|
+
| [Talos Transcript Ingestion Pipeline (worker2 → AWS Bedrock KBs)](features/talos-transcript-ingestion.md) | `_Worker_Team_Transcripts` runs a fully automated, cron-driven pipeline that ingests raw Teams transcripts into the **Talos / TOGa IQ** AWS Bedrock knowledge ba | worker2/Worker/Team/Transcripts.php, worker2/bin/sync-knowledge-bases.php, worker2/Config/production.ini, worker2/Database/TeamsTranscriptExports.sql, dbchanges2/Team/2026-06-30a, dbchanges2/Team/2026-06-30b, dbchanges2/Team/2026-06-30c, dbchanges2/Team/2026-06-30d, dbchanges2/Team/2026-06-30e, dbchanges2/Core/2026-06-30a, dbchanges2/Core/2026-07-02a, dbchanges2/Team/2026-07-02a, dbchanges2/Team/2026-07-08a, dbchanges2/Team/2026-07-09a, dbchanges2/Team/2026-07-10a, dbchanges2/Team/2026-07-28a - TranscriptProcessingRetryAttempts.sql, dbchanges2/Team/2026-07-28b - TranscriptPromptTemplateConverseModel.sql, dbchanges2/Core/2026-07-28a - TeamsTranscriptRetryCron.sql |
|
|
31
32
|
| [Team Sprint Management & Reporting](features/team-sprint-management.md) | `_Worker_Team_Sprint` (file `Worker/Team/Sprint.php`) is the engine behind TOGA's internal **development-sprint process and reporting**. | worker2/Worker/Team/Sprint.php, _underscore/Model/Team/Sprint.php, dbchanges2/Core/CronJobs (SprintLockScheduled seed) |
|
|
32
33
|
| [Teams Meeting Transcript Export](features/teams-transcript-export.md) | > **SUPERSEDED (2026-07-09) — the S3-staging model below is history.** `Export` is now a thin > **GRAPH-DIRECT** cron poller: it no longer archives raw VTT to ` | worker2/Worker/Team/Transcripts.php, worker2/Config/production.ini, worker2/Database/TeamsTranscriptExports.sql, dbchanges2/Core/2026-06-18a - Teams Transcript Export schedule.sql |
|
|
33
34
|
| [VAPI Webhook Handler (worker2 — AI-BDR end-of-call processing)](features/vapi-webhook-handler.md) | `_Worker_Vapi` ([worker2/Worker/Vapi.php](worker2/Worker/Vapi.php)) is the **PHP side of the AI-BDR call loop** — the webhook that receives VAPI's end-of-call r | worker2/Worker/Vapi.php, worker2/Worker/Ai/Bdr/Vapi.php |
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Platform Cache Cleanup (_Worker_Platform_Cache::Clean)
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: worker2
|
|
5
|
+
project: Worker
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-07-28
|
|
10
|
+
owners: [jcardinal]
|
|
11
|
+
files:
|
|
12
|
+
- worker2/Worker/Platform/Cache.php
|
|
13
|
+
- worker2/Controller/Index.php
|
|
14
|
+
- worker2/_.php
|
|
15
|
+
- dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql
|
|
16
|
+
related:
|
|
17
|
+
- ../../api2/features/cross-client-data-retrieval.md
|
|
18
|
+
- ./creating-worker-actions.md
|
|
19
|
+
- ../../_underscore/features/per-client-database-connections.md
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Summary
|
|
23
|
+
|
|
24
|
+
`_Worker_Platform_Cache::Clean()` is the reaper for the shared **Cache** cluster that backs api2's
|
|
25
|
+
[multi-client data retrieval](../../api2/features/cross-client-data-retrieval.md). It deletes
|
|
26
|
+
expired `Cache.Tables` rows and drops the per-query `TableResults_<Tables.id>` tables that no
|
|
27
|
+
longer have a parent. Without it the Cache schema grows one table per distinct cross-client query,
|
|
28
|
+
forever.
|
|
29
|
+
|
|
30
|
+
Action path `Platform/Cache/Clean`, scheduled on a **`*/5`** cron registered in
|
|
31
|
+
`dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql`.
|
|
32
|
+
|
|
33
|
+
## How it works
|
|
34
|
+
|
|
35
|
+
1. **Reap expired parents.** Delete `Cache.Tables` rows whose TTL has elapsed, with a **2-minute
|
|
36
|
+
grace period** so a request that is mid-read cannot have its rows deleted out from under it.
|
|
37
|
+
This pairs with the api2 side flooring `Records.ttlCache = 0` at 1 minute — together they
|
|
38
|
+
guarantee a cache entry outlives the request that built it.
|
|
39
|
+
2. **Drop orphans.** Enumerate `TableResults_*` tables and drop the ones with no surviving parent
|
|
40
|
+
row. Ordering is load-bearing: the parent row is **deleted and committed first**, and orphan
|
|
41
|
+
status is **re-verified on the write host immediately before each `DROP`** — a `DROP TABLE` is
|
|
42
|
+
irreversible and non-transactional, so a stale read replica must never be the basis for one.
|
|
43
|
+
|
|
44
|
+
## Key rules
|
|
45
|
+
|
|
46
|
+
- **Delete-parent-then-drop-child, and re-check on the writer.** Never drop a result table based on
|
|
47
|
+
a list read earlier in the run or from a reader host.
|
|
48
|
+
- **Never shorten the grace period below the longest plausible in-flight request.** The whole
|
|
49
|
+
correctness argument for reaping while requests are live rests on it.
|
|
50
|
+
- The Cache DB is registered as `DB_CACHE` in `worker2/_.php` and resolved **by name**
|
|
51
|
+
(`Databases.name = 'Cache'`) in `worker2/Controller/Index.php` — the same rule as api2. There is
|
|
52
|
+
deliberately no `CACHE_DATABASE_ID`: `Databases.id` is auto-assigned per Core instance and a
|
|
53
|
+
hardcoded id fails silently in every environment where it does not happen to match.
|
|
54
|
+
|
|
55
|
+
## Gotchas
|
|
56
|
+
|
|
57
|
+
- **Worker classes are `abstract class _Worker_<Path>` with `public static` entry methods**,
|
|
58
|
+
dispatched as `$className::$functionName()`. They do **not** `extends _Worker` — that is the
|
|
59
|
+
dispatcher. See [Creating Worker Actions](./creating-worker-actions.md). (The 2.0
|
|
60
|
+
`standards/framework-rules.md` worker contract still describes the older `extends _Worker` +
|
|
61
|
+
`run()` shape and needs reconciling.)
|
|
62
|
+
- **Adding the cron is a dbchanges2 change, not a code change** — the schedule lives in Core, so a
|
|
63
|
+
new worker action is not actually scheduled until that SQL is applied per environment.
|
|
64
|
+
|
|
65
|
+
## Change history
|
|
66
|
+
- 2026-07-28 — Initial capture: `_Worker_Platform_Cache::Clean()` reaps expired `Cache.Tables` rows
|
|
67
|
+
(2-minute grace) and drops orphaned `TableResults_*` tables (parent deleted+committed first,
|
|
68
|
+
orphan status re-verified on the write host immediately before each DROP); `DB_CACHE` registered
|
|
69
|
+
by name in worker2 boot; `*/5` cron added in dbchanges2 `Core/2026-07-27a`. (jcardinal)
|
|
@@ -24,6 +24,9 @@ files:
|
|
|
24
24
|
- dbchanges2/Team/2026-07-08a
|
|
25
25
|
- dbchanges2/Team/2026-07-09a
|
|
26
26
|
- dbchanges2/Team/2026-07-10a
|
|
27
|
+
- dbchanges2/Team/2026-07-28a - TranscriptProcessingRetryAttempts.sql
|
|
28
|
+
- dbchanges2/Team/2026-07-28b - TranscriptPromptTemplateConverseModel.sql
|
|
29
|
+
- dbchanges2/Core/2026-07-28a - TeamsTranscriptRetryCron.sql
|
|
27
30
|
related:
|
|
28
31
|
- ./teams-transcript-export.md
|
|
29
32
|
- ./notification-email-template.md
|
|
@@ -56,7 +59,7 @@ JSON that PHP renders to an inline-styled HTML recap email, enqueued to the orga
|
|
|
56
59
|
|
|
57
60
|
## Key files / entry points
|
|
58
61
|
|
|
59
|
-
- `Worker/Team/Transcripts.php` — actions `Export`, `Process`, `SyncKb`,
|
|
62
|
+
- `Worker/Team/Transcripts.php` — actions `Export`, `Process`, `Retry`, `SyncKb`,
|
|
60
63
|
`SyncKnowledgeBases`. **`Scan` was removed.**
|
|
61
64
|
- `bin/sync-knowledge-bases.php` — CLI entry for the Bedrock→`Team.KnowledgeBases` registry
|
|
62
65
|
sync (`--dry-run` supported).
|
|
@@ -111,6 +114,53 @@ the Graph date filter and gets stuck "observing" forever.
|
|
|
111
114
|
`alreadyExported()` and `recordExport()` were **removed** (superseded by the observation
|
|
112
115
|
helpers).
|
|
113
116
|
|
|
117
|
+
### `Retry(?int $limit = null, array $cc = [], array $bcc = [])` (cron) — bounded re-drive of failures
|
|
118
|
+
Added 2026-07-28. Before it existed, a failed `Process()` was **permanently terminal** (see
|
|
119
|
+
Gotchas). `Retry` sweeps `Team.TranscriptProcessing` for `status='failed'` and re-enqueues
|
|
120
|
+
`Team/Transcripts/Process` **directly from the `Team.TranscriptExports` ledger row**, which
|
|
121
|
+
already holds every Graph id `Process` needs.
|
|
122
|
+
|
|
123
|
+
Two deliberate non-behaviors:
|
|
124
|
+
- It does **not** clear `dtEnqueued`, and it does **not** route back through `Export`. Going
|
|
125
|
+
through `Export` would re-download every VTT purely to re-measure its size and would perturb
|
|
126
|
+
the observe-until-stable state; the ledger is sufficient.
|
|
127
|
+
- Nothing "resets" a failure — the row stays `failed` until a `Process` run succeeds.
|
|
128
|
+
|
|
129
|
+
**Bounded on three axes** so a permanently unprocessable transcript *parks itself* instead of
|
|
130
|
+
retrying forever (budget sized for a multi-day AI-endpoint outage, not a blip):
|
|
131
|
+
- `RETRY_MAX_ATTEMPTS = 20`
|
|
132
|
+
- doubling backoff: `RETRY_BACKOFF_MINUTES = 15`, doubling up to `RETRY_BACKOFF_CAP_MINUTES = 360`
|
|
133
|
+
- `RETRY_MAX_AGE_DAYS = 30`
|
|
134
|
+
|
|
135
|
+
**Atomic claim (do not "simplify" this).** `claimRetryAttempt()` increments the attempt counter
|
|
136
|
+
with a status-guarded UPDATE — `... WHERE transcriptIdentifier = ? AND status = 'failed'` — reads
|
|
137
|
+
`_Query::getAffectedRows()` **before** the commit (affected-rows reflects the last statement on
|
|
138
|
+
the connection), and **skips the enqueue when zero rows were claimed**. `loadRetryCandidates()`
|
|
139
|
+
takes no locks and `Process()` only short-circuits when status is already `'synced'`, so an
|
|
140
|
+
unconditional increment let two overlapping sweeps both run the full pipeline for one meeting:
|
|
141
|
+
two AI passes, two Bedrock syncs, and **two recap emails to the organizer**. Claim-then-enqueue is
|
|
142
|
+
the pattern for making any sweep-and-requeue worker safe without locking.
|
|
143
|
+
|
|
144
|
+
**Unprocessable rows are excluded in the candidate SQL, not skipped in the loop.** A ledger row
|
|
145
|
+
with no `meetingIdentifier` can never succeed (`Process()` throws without one); leaving such rows
|
|
146
|
+
selectable let a handful fill the `LIMIT` on every sweep and starve rows that could succeed. They
|
|
147
|
+
are surfaced separately by `countUnprocessableFailures()` in the sweep summary.
|
|
148
|
+
|
|
149
|
+
Private helpers: `loadRetryCandidates()`, `claimRetryAttempt()`, `countUnprocessableFailures()`,
|
|
150
|
+
`toIsoUtc()`.
|
|
151
|
+
|
|
152
|
+
**Schema (`Team/2026-07-28a`):** `Team.TranscriptProcessing.retryAttempts`
|
|
153
|
+
(`TINYINT UNSIGNED NOT NULL DEFAULT 0`), `dtLastRetried` (`DATETIME NULL`), plus a
|
|
154
|
+
`(status, retryAttempts)` index. The defaults make every already-failed row eligible on the first
|
|
155
|
+
sweep, so **no backfill or one-off UPDATE is needed** (an explicit requirement). The retry join
|
|
156
|
+
needs no new index — `TranscriptExports.transcriptIdentifier` already has the `uq_transcript`
|
|
157
|
+
UNIQUE index.
|
|
158
|
+
|
|
159
|
+
**Cron (`Core/2026-07-28a`): `10,40 9-18 * * 1-5`** — deliberately offset from the `Export` cron
|
|
160
|
+
(`15,45 9-18 * * 1-5`, `Core.CronJobs` id 18) so the two never contend for the Graph token or the
|
|
161
|
+
AI endpoint in the same minute, and kept inside Export's business-hours window because a re-drive
|
|
162
|
+
emails a recap to the meeting organizer.
|
|
163
|
+
|
|
114
164
|
### `Process(int $transcriptId, string $graphUserId, string $meetingId, string $organizerEmail, string $subject, string $meetingStartIso, array $cc = [], array $bcc = [])`
|
|
115
165
|
1. Download the VTT **straight from Graph** (no S3 round trip).
|
|
116
166
|
2. `stripAndGroupVtt()` — strips WebVTT timestamps and **merges consecutive same-speaker cues
|
|
@@ -225,6 +275,48 @@ part of the ingestion loop** (raw reads removed). Credential values live only in
|
|
|
225
275
|
|
|
226
276
|
## Gotchas / known issues
|
|
227
277
|
|
|
278
|
+
- **THE AI MODEL FOR THIS PIPELINE COMES FROM THE DATABASE, NOT `production.ini`.** `Process()`
|
|
279
|
+
resolves it as `($template->model ?? _Config::talos('model'))` against the **active
|
|
280
|
+
`Team.TranscriptPromptTemplate` row**, and because that table's `model` column is
|
|
281
|
+
`VARCHAR(255) NOT NULL`, the ini value is **unreachable — dead fallback on this path**. Editing
|
|
282
|
+
`worker2/Config/production.ini` does nothing here. Both AI passes (`callAICleaningAPI`
|
|
283
|
+
clean/classify and `buildRecapEmailBody` recap) read the **same row**, so one row update fixes
|
|
284
|
+
both. There is **no admin screen** for the prompt template in `tools/mvc/talos/`, so a model
|
|
285
|
+
change belongs in a dbchanges2 migration like its original seed (`Team/2026-06-30c`). Write such
|
|
286
|
+
a migration to match on the **OLD model value** (not id/uuid) so it no-ops in an environment
|
|
287
|
+
already hand-corrected and cannot clobber one deliberately pinned to a different model.
|
|
288
|
+
This was the root cause of a five-day outage: Talos `/api/ai/generate` returned
|
|
289
|
+
`500 {"error":"internal_error"}` for `bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0`
|
|
290
|
+
because the endpoint now requires the **`bedrock_converse/` prefix** (verified against
|
|
291
|
+
`Logs_True.Api` id 5581286, `dtStamp 2026-07-28 09:45:54`, route `/api/ai/generate`).
|
|
292
|
+
Fixed by `Team/2026-07-28b`.
|
|
293
|
+
- **A failed `Process()` used to be PERMANENTLY TERMINAL — "just re-run it" never recovered it.**
|
|
294
|
+
Three mechanisms combined: (a) workers return **HTTP 200 even on failure** by framework
|
|
295
|
+
contract, so SQS never redelivers; (b) `Export()` stamps `TranscriptExports.dtEnqueued`
|
|
296
|
+
**before** the SQS handoff and **nothing ever clears it**, while Export's first check skips any
|
|
297
|
+
transcript whose `dtEnqueued` is set — so `dtEnqueued` is a **one-way latch**; (c) nothing else
|
|
298
|
+
re-read `status='failed'` rows. Observed impact: **25 meetings stranded 2026-07-22 → 2026-07-27**
|
|
299
|
+
(last successful sync 2026-07-22 16:15) while `Export` reported
|
|
300
|
+
`found: 106, enqueued: 0, skipped: 106` — i.e. fixing the underlying error alone would **not**
|
|
301
|
+
have recovered them. **Diagnostic tell:** Export/Process pairs *do* appear in
|
|
302
|
+
`Core.WorkerJobs`; an `Export` with `enqueued: 0` simply has no `Process` after it, which is
|
|
303
|
+
easy to misread as a missing dispatch. The `Retry` action is the fix.
|
|
304
|
+
- **Losing the `'synced'` status write is uniquely dangerous now that `Retry` exists.**
|
|
305
|
+
`setStatus()` catches `Throwable` and only `error_log`s. If the failure hits the **`'synced'`**
|
|
306
|
+
write specifically, the pipeline **completed** and the recap was **already sent**, but the row
|
|
307
|
+
still reads `failed` — so the `Retry` sweep re-runs everything and re-sends the recap,
|
|
308
|
+
repeatedly. It now logs a distinct **CRITICAL** line naming the uuid and telling the operator to
|
|
309
|
+
set the row to `synced` manually. Residual risk **accepted** rather than adding a
|
|
310
|
+
`dtRecapSent` column.
|
|
311
|
+
- **DEPLOY ORDER (2026-07-28 change): Team migration → worker2 code → Core cron row.** Any other
|
|
312
|
+
order is safe but inert. Also note: draining the 25-meeting backlog **sends 25 recap emails** to
|
|
313
|
+
the original organizers for meetings up to six days old.
|
|
314
|
+
- **Two of the 25 stranded meetings are not the outage and will not self-heal** — they will
|
|
315
|
+
exhaust the 20-attempt budget and park, which is the intended behavior of the bound.
|
|
316
|
+
"Agilant & The Tow Foundation Sync" (2026-07-22, 179,779 bytes, the largest in the set) failed
|
|
317
|
+
**HTTP 422**, not 500, against an active-template `maxTokens` of 40000 — it likely exceeds the
|
|
318
|
+
model's input budget and needs **chunking**. "Tow Receipt Project Update" is **86 bytes**, an
|
|
319
|
+
effectively empty transcript.
|
|
228
320
|
- **DEPLOY ORDER: apply migration `2026-07-10a` BEFORE deploying the new worker2 code.** The
|
|
229
321
|
code reads/writes `dtEnqueued`/`dtLastChanged`; deploying code first errors on every poll.
|
|
230
322
|
The migration also backfills every existing row as handed-off (`dtEnqueued=dtExported`) so
|
|
@@ -294,6 +386,23 @@ part of the ingestion loop** (raw reads removed). Credential values live only in
|
|
|
294
386
|
|
|
295
387
|
## Change history
|
|
296
388
|
|
|
389
|
+
- 2026-07-28 — **Added `Team/Transcripts/Retry`, a bounded automatic re-drive of failed
|
|
390
|
+
transcripts, and fixed the five-day 500 outage.** Discovered a failed `Process()` was
|
|
391
|
+
*permanently* terminal (HTTP-200 workers + the `dtEnqueued` one-way latch + nothing re-reading
|
|
392
|
+
`status='failed'`), stranding 25 meetings from 2026-07-22. `Retry` re-enqueues `Process` straight
|
|
393
|
+
from the `TranscriptExports` ledger, bounded by `RETRY_MAX_ATTEMPTS=20`, a 15→360-minute doubling
|
|
394
|
+
backoff, and `RETRY_MAX_AGE_DAYS=30`. Review findings addressed: made the attempt increment an
|
|
395
|
+
**atomic status-guarded claim** (read `getAffectedRows()` pre-commit, skip enqueue on 0 rows) —
|
|
396
|
+
the unconditional version let concurrent sweeps double-run the pipeline and send **two recap
|
|
397
|
+
emails**; excluded `meetingIdentifier`-less unprocessable rows in the candidate SQL so they can't
|
|
398
|
+
starve the `LIMIT`; added a CRITICAL log when the `'synced'` status write is lost. Outage root
|
|
399
|
+
cause was a stale model id **in `Team.TranscriptPromptTemplate`, not `production.ini`** (the ini
|
|
400
|
+
is dead fallback) — Talos now requires the `bedrock_converse/` prefix. dbchanges2:
|
|
401
|
+
`Team/2026-07-28a` (retryAttempts/dtLastRetried, defaults make failures eligible with **no
|
|
402
|
+
backfill**), `Team/2026-07-28b` (model, matched on the old value so it no-ops if hand-fixed),
|
|
403
|
+
`Core/2026-07-28a` (cron `10,40 9-18 * * 1-5`, offset from Export's `15,45`). `php -l` clean;
|
|
404
|
+
php-reviewer 1 critical + 1 high + 1 medium all addressed; sql-reviewer SAFE TO MERGE.
|
|
405
|
+
**Nothing deployed or applied yet.** (jcardinal)
|
|
297
406
|
- 2026-07-28 — **`callTalosEndpoint()` migrated from raw curl to `_ApiRequest`** so both AI
|
|
298
407
|
passes are recorded in the api log; `initialize()` now also registers `Logs_True` under
|
|
299
408
|
`_underscore::DB_CLIENT_LOGS` (without it the logging is a silent no-op). Deliberately kept
|
package/knowledge/INDEX.md
CHANGED
|
@@ -18,7 +18,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
18
18
|
## 2.0 framework
|
|
19
19
|
|
|
20
20
|
- **_underscore** (_Underscore) _(framework core)_ — 39 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
|
|
21
|
-
- **worker2** (Worker) —
|
|
21
|
+
- **worker2** (Worker) — 32 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
|
|
22
22
|
- **api2** (API) — 18 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
|
|
23
23
|
- **dbchanges2** (Database Changes) _(framework core)_ — 3 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
|
|
24
24
|
- **toga2-supply** (TOGa Supply) — 3 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
|
package/package.json
CHANGED