|
@@ -0,0 +1,206 @@
|
|
|
|
|
+# Database relations (v2.11.0): what we hand-roll today, and what to declare
|
|
|
|
|
+
|
|
|
|
|
+Written 2026-08-10. Read-only analysis, no code changed. Context: the upstream
|
|
|
|
|
+`smartbotic-database` daemon on zeus is 2.11.0 (relations, unique constraints);
|
|
|
|
|
+our client on mulan is still 2.9.0 with no relation API, so nothing here can be
|
|
|
|
|
+used yet. This is groundwork for when the client upgrade lands.
|
|
|
|
|
+
|
|
|
|
|
+Sources read in full before writing this: `/data/smartbotic-database/proto/database.proto`
|
|
|
|
|
+(from line 919) and `/data/smartbotic-database/client/include/smartbotic/database/client.hpp`
|
|
|
|
|
+(from line 800).
|
|
|
|
|
+
|
|
|
|
|
+## 1. Where we hand-roll referential integrity today
|
|
|
|
|
+
|
|
|
|
|
+| # | Delete site | Cleans up | Mechanism | Complete? |
|
|
|
|
|
+|---|---|---|---|---|
|
|
|
|
|
+| 1 | `workflow_controller.cpp:673-696` `deleteWorkflow` | its own scheduled triggers, then `executions` (workflowId match) and its private `wf_<id>` collection and its files | `updateScheduledTriggers(id,false)`, then `retention_.retire(id)` (`retention_service.cpp:42-50`) queues an async job: restamp matching executions/documents/files to a short TTL, drop the `wf_` collection after | **No.** `retire()` restamps only executions matching `workflowId` at the moment of the call, via an async worker. Anything that failed to restamp (worker not running yet, restart mid-sweep, or - the actual history here - workflows deleted before this retention machinery existed) is never revisited. Confirmed by the hand sweep below. |
|
|
|
|
|
+| 2 | `project_controller.cpp:303-349` `deleteProject` | nothing directly; instead **refuses** the delete while `workflows`, `credentials` or `workflow_groups` still carry that `projectId` (`ownedCollections()`, `project_controller.cpp:20-25`) | manual restrict-style check, `page_size=1000` per collection | Complete for its stated job (never deletes a project holding work), but it is a hand-written `restrict` relation over exactly the three collections in `ownedCollections()` - nothing else with a `projectId` (e.g. a future collection) is covered without remembering to add it here. |
|
|
|
|
|
+| 3 | `user_controller.cpp:183-227` `deleteUser` | sessions (`auth_store.cpp:247-252` -> `invalidateAllSessions`, `auth_store.cpp:427-438`, deletes each `sessions` row for the user); the user's personal project, but **only if** it holds no workflows/credentials/workflow_groups (`user_controller.cpp:203-221`) | explicit per-row `storage_.remove("sessions", ...)`; explicit query + conditional `storage_.remove("projects", ...)` | **Leaves orphans.** Sessions are the only thing cleaned. Not touched: `credentials.sharedWith` arrays containing the deleted user's id (verified no code path strips it - see `credential_controller.cpp:107-148`, only add/remove-by-request exist); `ownerId`/`createdBy` on workflows, credentials, workflow_groups, projects (dangling attribution, arguably intentional - see relation table); `collection_permissions` and `api_keys` are unused in this codebase today (see Part 3), so nothing references a user there yet. |
|
|
|
|
|
+| 4 | `workflow_group_controller.cpp:320-379` `deleteGroup` | with `force=true`: reassigns every child workflow's `groupId` to `""` and every child subgroup's `parentId` to the deleted group's own parent (`workflow_group_controller.cpp:334-368`); without `force`, refuses if `hasChildren(id)` | manual `set_null`-style reassignment | Complete for its own children. This is exactly what a `set_null` relation with `child_field=groupId`/`parentId` would do automatically. |
|
|
|
|
|
+| 5 | `credential_controller.cpp:383-408` `deleteCredential` | **nothing** | none | **Leaves orphans.** No check that a workflow node still references the credential's id (`config.credentialId`, embedded inside `workflow.nodes[]`), and no cleanup after delete. A workflow keeps pointing at a `credentialId` that resolves to nothing; the node then fails at run time with whatever "credential not found" error the node author wrote (if any). |
|
|
|
|
|
+| 6 | `node_controller.cpp:399-416` `deleteNode` | **nothing** | none | **Leaves orphans.** No check that any workflow still has a node of that type. Note: this reference cannot be expressed as a v2.11.0 relation at all - see the "not relation-able" note under Part 2. |
|
|
|
|
|
+
|
|
|
|
|
+**The orphan sweep.** Commit `a0cf96e` (2026-08-09, "feat: clear orphaned data,
|
|
|
|
|
+refuse dropping a collection that holds documents") is the hand sweep referenced
|
|
|
|
|
+in the task. Its own numbers: **8,538 of 10,159** execution records belonged to
|
|
|
|
|
+workflows that no longer existed ("mostly test workflows deleted over the past
|
|
|
|
|
+days"), plus 67 entirely-null rows from writes that never completed;
|
|
|
|
|
+`executions` went from 481 MB to 74 MB. The commit message states the direct
|
|
|
|
|
+cause: retention's cascade only finds executions whose `workflowId` matches a
|
|
|
|
|
+*current* delete call, so a workflow deleted before `retire()` existed (the
|
|
|
|
|
+whole per-workflow retention feature landed 2026-08-08/09, commits `8a52564`
|
|
|
|
|
+through `802d30c`) has no mechanism that will ever revisit it. The same commit
|
|
|
|
|
+also found four `watch_cursors` that looked orphaned by field-matching but
|
|
|
|
|
+were not (`watch_cursors` is keyed by the collection it watches, not by a
|
|
|
|
|
+workflow id) - a caution about guessing a "child field" instead of reading the
|
|
|
|
|
+schema.
|
|
|
|
|
+
|
|
|
|
|
+Two more things worth flagging that are not deletions but sit right next to
|
|
|
|
|
+this problem:
|
|
|
|
|
+
|
|
|
|
|
+- `execution_controller.cpp:113-131` `canSeeExecution` explicitly handles both
|
|
|
|
|
+ a deleted-workflow execution ("The workflow has been deleted. Its history
|
|
|
|
|
+ outlives it...") and an execution with an **empty** `workflowId` ("Some runs
|
|
|
|
|
+ record no workflow id - the webhook path is one"). The empty-`workflowId`
|
|
|
|
|
+ case was an active bug until **2026-08-08** (see Part 2's hazard section) -
|
|
|
|
|
+ the comment is now a defensive leftover for historical rows, not a live
|
|
|
|
|
+ data path, but those historical rows still exist.
|
|
|
|
|
+- `webserver_service.cpp:661-706` `reconcile` (runner restart handling)
|
|
|
|
|
+ proactively closes `executions` rows stuck in `status: running` on a runner
|
|
|
|
|
+ that no longer claims them, logging them as orphans it repairs rather than
|
|
|
|
|
+ ignores. This is the one piece of "orphan" handling in the codebase that is
|
|
|
|
|
+ actually complete for its scope.
|
|
|
|
|
+
|
|
|
|
|
+## 2. Proposed relation table
|
|
|
|
|
+
|
|
|
|
|
+| Child collection | Child field | Parent collection | `on_delete` | `validate_on_write` | Why |
|
|
|
|
|
+|---|---|---|---|---|---|
|
|
|
|
|
+| `executions` | `workflowId` | `workflows` | `no_action` | **off** | This is the one place `cascade`/`set_null` looks tempting and is wrong twice over: (a) it is exactly the relation whose *child* (`executions`) already carries a per-row TTL restamped after the fact by `retention_service.cpp` - see the TTL hazard below, this time from the child side, since a cascade delete has to walk the child's rows while the TTL sweep may be mutating them concurrently; (b) `validate_on_write` would hard-reject the empty-string `workflowId` that historical rows carry and that the webhook path produced until 2026-08-08 (`followups-from-tier-2` memory: "FIXED 2026-08-08... An execution recorded an empty workflowId whenever the run used the published version"). Recommend `no_action` and leave the existing app-level `retire()` sweep as the cleanup path, or move to `restrict`-then-manual-retire if the team wants delete-time visibility. **Do not use `cascade`.** |
|
|
|
|
|
+| `workflows` | `projectId` | `projects` | `restrict` | on | Replaces the hand-rolled check in `project_controller.cpp:320-337`. `validate_on_write` is safe: nothing found that inserts a workflow with an empty or dangling `projectId` (workflow creation always assigns one, `workflow_controller.cpp:357` sets `ownerId`, and `projectId` comes from the same request). |
|
|
|
|
|
+| `workflow_groups` | `projectId` | `projects` | `restrict` | on | Same reasoning as above; currently covered by the same `ownedCollections()` check. |
|
|
|
|
|
+| `credentials` | `projectId` | `projects` | `restrict` | on | Same; currently covered by `ownedCollections()`. |
|
|
|
|
|
+| `workflows` | `groupId` | `workflow_groups` | `set_null` | on | Matches the existing manual behaviour in `deleteGroup` force-path (`workflow_group_controller.cpp:334-345`) exactly - a declared relation would let that hand-written loop be deleted. |
|
|
|
|
|
+| `workflow_groups` | `parentId` | `workflow_groups` (self) | `set_null` | on | Matches `deleteGroup`'s subgroup reparenting (`workflow_group_controller.cpp:347-367`). Self-referential relations are not called out as unsupported in the proto/client docs, but neither are they described as tested - **verify with upstream before relying on this one**, flagged as a soft caution rather than a hard unsafe. |
|
|
|
|
|
+| `credentials` | `sharedWith` | `users` | `cascade` (array field, so this pulls the id and keeps the credential - equivalent to `set_null` here per the proto note on array fields) | on | Closes gap #3/#5 above: today a deleted user is never stripped from `credentials.sharedWith`, leaving a dangling id in the array forever. `validate_on_write` is safe as long as sharing continues to check `auth_store_.getUser` first, which `credential_controller.cpp:100-116` already does. |
|
|
|
|
|
+| `sessions` | `userId` | `users` | `cascade` | on | Would let `AuthStore::deleteUser`'s explicit per-row loop (`auth_store.cpp:247-252`, `427-438`) be deleted entirely - the relation does the same thing atomically with the parent delete. `validate_on_write` is safe: a session is only ever created from an authenticated login against an existing user (`auth_store.cpp:282-320`). |
|
|
|
|
|
+| `workflows` / `credentials` / `workflow_groups` / `projects` / `nodes` | `ownerId` / `createdBy` | `users` | `no_action` | off | These are attribution fields, not ownership in the referential-integrity sense - the app already treats a dangling owner as fine (e.g. `canSeeExecution`'s deleted-workflow branch). Declaring anything stronger than `no_action` would be a behavior change nobody asked for: deleting a user would either destroy their workflows (`cascade`, clearly wrong) or silently blank out who made them (`set_null`, loses history). Leave alone; `no_action` only adds the reverse index for future `describeDelete()` queries, which is a pure upside. |
|
|
|
|
|
+| `api_keys` | *(n/a)* | `users` | - | - | **Not a real candidate today.** `api_keys` is a reserved system-collection name (`database_controller.cpp:300`, `auth_store.hpp`/`system_collections.hpp:43`) with no controller, no writer, and (checked live) zero documents and not even listed among the instance's existing collections. Nothing to declare a relation over until the feature is built. |
|
|
|
|
|
+| `collection_permissions` | *(n/a)* | `users` | - | - | **Not a real candidate.** It is a single settings document keyed `"settings"` (`collection_permissions.cpp:39` `storage_.get("collection_permissions", "settings")`), not a per-user row. No child field to point at a user. |
|
|
|
|
|
+| workflow node `config.credentialId` | - | `credentials` | *(not expressible)* | - | **Cannot be declared as a v2.11.0 relation at all.** `child_field` is a dot-path that resolves to a single id or an array of ids on the *child document itself*. A workflow's credential references live inside `nodes[].config.credentialId`, i.e. one string per element of an array of *objects*, not an array of ids. The relation contract has no path syntax for "the `credentialId` field of every element of this array." This is exactly the gap behind hand-rolled cleanup gap #5 (`deleteCredential` cleans up nothing) - relations do not fix it; only app code walking `workflow.nodes` can. |
|
|
|
|
|
+| workflow node `type` | - | `nodes` | *(not expressible)* | - | Same shape of problem as above, same conclusion: not relation-able, still needs app code if `deleteNode` (gap #6) is ever to check for in-use node types. |
|
|
|
|
|
+
|
|
|
|
|
+**Unsafe / flagged candidates, summarized:**
|
|
|
|
|
+
|
|
|
|
|
+1. **`executions.workflowId -> workflows` with `cascade` or `set_null` is
|
|
|
|
|
+ unsafe and not recommended.** `executions` carries retention TTLs that
|
|
|
|
|
+ `retention_service.cpp` actively restamps (`restamp()`, `retention_service.cpp:187-283`,
|
|
|
|
|
+ called from both `apply()` and `retire()`). The upstream warning is about a
|
|
|
|
|
+ race hitting a `cascade`/`set_null` relation *whose parent* carries a
|
|
|
|
|
+ renewed-after-expiry TTL; here the shoe is on the other foot - `executions`
|
|
|
|
|
+ is the *child*, and it is the child rows that are being TTL-renewed out
|
|
|
|
|
+ from under a delete-time cascade walk over the parent (`workflows`). Either
|
|
|
|
|
+ direction is the same underlying hazard: a delete-time relation walk and an
|
|
|
|
|
+ independent TTL-renewal sweep touching the same rows concurrently. Recommend
|
|
|
|
|
+ `no_action`, keep `retire()` as-is (or route it through `describeDelete`
|
|
|
|
|
+ once the client has it, for visibility), and rely on periodic sweeps like
|
|
|
|
|
+ `a0cf96e` for anything `retire()` misses.
|
|
|
|
|
+2. **`validate_on_write` on `executions.workflowId` is currently safe going
|
|
|
|
|
+ forward** (the empty-string bug was fixed 2026-08-08) **but would reject
|
|
|
|
|
+ re-inserting or re-processing any of the historical empty-`workflowId` rows**
|
|
|
|
|
+ that predate the fix, and would reject the row shape `canSeeExecution` still
|
|
|
|
|
+ defends against. Recommend leaving `validate_on_write` off for this field
|
|
|
|
|
+ until a migration confirms no code path can still produce `workflowId: ""`
|
|
|
|
|
+ (see Part 4 - the `listPending` path pulls raw executions and would be
|
|
|
|
|
+ worth auditing at the same time).
|
|
|
|
|
+3. **`workflow_groups.parentId` self-relation** - not proven unsafe, just
|
|
|
|
|
+ unverified against a self-referential case; test against a real 2.11.0
|
|
|
|
|
+ instance before relying on it for the group hierarchy.
|
|
|
|
|
+
|
|
|
|
|
+## 3. Unique constraint candidates
|
|
|
|
|
+
|
|
|
|
|
+Checked against the live instance at `http://localhost:8090` (admin/admin) on 2026-08-10.
|
|
|
|
|
+
|
|
|
|
|
+| Field | Candidate collection | Live duplicates found | Declarable today? |
|
|
|
|
|
+|---|---|---|---|
|
|
|
|
|
+| `username` | `users` | 0 (only 1 user exists: `admin`) | Yes, trivially - but with only one row this is a weak signal. Re-check once the instance has more than one account. |
|
|
|
|
|
+| `email` | `users` | 0 (same caveat) | Yes, same caveat. |
|
|
|
|
|
+| `refreshToken` | `sessions` | **19** (given - task states this is already known) | **No.** Needs a cleanup pass (dedupe or drop stale sessions) before `unique=true` can be declared; the server will refuse it while duplicates exist. |
|
|
|
|
|
+| `id` | `nodes` | 0 across 86 node definitions | Moot - `id` is already the document's own primary key (`_id`), so a `unique` index is redundant, not a new guarantee. |
|
|
|
|
|
+| `(projectId, name)` | `credentials` | **1 pair**: two credentials named `noreply@shopcall.ai` in project `prj_89c532cf-...` (`cred_53c04169-...` and `cred_a668d3b5-...`) | **No.** Needs the duplicate renamed or merged first. Only 7 credentials total in this instance, so the fix is a two-minute manual rename, not a migration. |
|
|
|
|
|
+| `(projectId, name)` | `workflows` | 0 across 12 workflows | Yes, declarable today. |
|
|
|
|
|
+
|
|
|
|
|
+No `slug`-style field was found anywhere in the schema (workflows, projects,
|
|
|
|
|
+credentials, node definitions all key by generated id, not a human slug), so
|
|
|
|
|
+there is nothing to check there.
|
|
|
|
|
+
|
|
|
|
|
+## 4. The fat execution document
|
|
|
|
|
+
|
|
|
|
|
+**What it looks like today.** `ExecutionResult::toJson()` (`workflow_engine.cpp:210-273`)
|
|
|
|
|
+embeds every node's output as `nodeExecutions[]` directly inside the execution
|
|
|
|
|
+document, plus an optional full `workflowSnapshot` for the pinning feature.
|
|
|
|
|
+Two truncation regimes already exist as a stopgap: a finished execution's node
|
|
|
|
|
+outputs are truncated by `truncateLargeValues` (`workflow_engine.cpp:251`), but
|
|
|
|
|
+a **`Waiting`** execution keeps every node output whole and untruncated
|
|
|
|
|
+(`workflow_engine.cpp:234-240`) - deliberately, because a later resume seeds
|
|
|
|
|
+itself from that stored state. That is precisely the shape of document that
|
|
|
|
|
+gets largest.
|
|
|
|
|
+
|
|
|
|
|
+**Where the current pain actually shows up**, confirmed by reading the read
|
|
|
|
|
+paths rather than assuming:
|
|
|
|
|
+
|
|
|
|
|
+- The list endpoint (`GET /executions`, `execution_controller.cpp:286`) does
|
|
|
|
|
+ **not** suffer this today - it already queries a server-side view,
|
|
|
|
|
+ `executions_summary` (`webserver_service.cpp:460-493`), that projects out
|
|
|
|
|
+ `nodeExecutions` and `workflowSnapshot` entirely at the database, listing
|
|
|
|
|
+ only ten summary fields. This view is the reason listing 336 KB documents
|
|
|
|
|
+ doesn't currently cost what it could.
|
|
|
|
|
+- `ExecutionController::listPending` (`execution_controller.cpp:545-573`,
|
|
|
|
|
+ `GET /executions/pending`), by contrast, queries the **raw `executions`
|
|
|
|
|
+ collection** with a `status=waiting` filter and **no field projection and no
|
|
|
|
|
+ page size cap**. Every waiting execution is exactly the untruncated,
|
|
|
|
|
+ full-node-output case described above. This is the most likely candidate for
|
|
|
|
|
+ "a page of large documents can come back empty because the gRPC message
|
|
|
|
|
+ limit is hit silently" - it is the one remaining raw, unprojected,
|
|
|
|
|
+ unpaginated read of full execution documents in the controller layer.
|
|
|
|
|
+- Single-execution `GET /executions/:id` and the resume path
|
|
|
|
|
+ (`workflow_engine.cpp:1029`, `1060-1111`) legitimately need the full
|
|
|
|
|
+ document - resume literally reconstructs `node_results` by reading
|
|
|
|
|
+ `record["nodeExecutions"]` back off the stored row.
|
|
|
|
|
+
|
|
|
|
|
+**What a split would cost.**
|
|
|
|
|
+
|
|
|
|
|
+- *Schema*: new `execution_node_results` (or similar) collection, one row per
|
|
|
|
|
+ `(executionId, nodeId)` (or one row per execution holding the array - either
|
|
|
|
|
+ way the relation is `execution_node_results.executionId -> executions`).
|
|
|
|
|
+ Given the TTL hazard identified in Part 2 item 1, this new child collection
|
|
|
|
|
+ would sit in the exact configuration the upstream implementor warned about:
|
|
|
|
|
+ its parent (`executions`) is TTL-bearing and is actively restamped after
|
|
|
|
|
+ creation by `retention_service.cpp`. **A `cascade` or `set_null` relation
|
|
|
|
|
+ here inherits the same race** the executions-to-workflows relation was
|
|
|
|
|
+ flagged for above, except now it is the primary, intended use of the split,
|
|
|
|
|
+ not an edge case - so it would need `no_action` plus an explicit sweep
|
|
|
|
|
+ (mirroring what already exists for workflow deletion) rather than relying on
|
|
|
|
|
+ the relation to keep the two collections in sync.
|
|
|
|
|
+- *Write path*: `WorkflowEngine::execute` and every node-completion callback
|
|
|
|
|
+ (`workflow_engine.cpp:1921, 1955, 1981, 2001, 2023`, and the loop-body
|
|
|
|
|
+ variants) would need to write node results as a second collection call
|
|
|
|
|
+ instead of building one in-memory `ExecutionResult` and serializing it once.
|
|
|
|
|
+ That is a real increase in write volume and a new failure mode (execution
|
|
|
|
|
+ row written, node-results write fails or vice versa - no longer atomic
|
|
|
|
|
+ without a transaction the storage client doesn't offer across collections).
|
|
|
|
|
+- *Read paths that would have to change*: `GET /executions/:id` (join in the
|
|
|
|
|
+ child rows), resume (`workflow_engine.cpp:1060-1111`, currently reads
|
|
|
|
|
+ `record["nodeExecutions"]` directly off the stored execution - would need a
|
|
|
|
|
+ second query), the pinning/diff feature that reads `workflowSnapshot` and
|
|
|
|
|
+ node outputs together, `listPending` (would become the thing that benefits
|
|
|
|
|
+ most, since it could stop pulling full documents), and the retention
|
|
|
|
|
+ preview/measure code (`retention_service.cpp:114-159`) which would need to
|
|
|
|
|
+ measure and restamp two collections in lockstep instead of one.
|
|
|
|
|
+- *Migration*: every existing row in `executions` (2,125 documents live today,
|
|
|
|
|
+ 74 MB post-sweep) would need its `nodeExecutions` array split out and
|
|
|
|
|
+ reinserted as child rows, or the split would only apply going forward and
|
|
|
|
|
+ every read path would need an `if (has child rows) else (read inline)`
|
|
|
|
|
+ branch indefinitely. Given how young this collection already is (rebuilt by
|
|
|
|
|
+ the 2026-08-09 sweep) a clean cutover is realistic, but it is still a
|
|
|
|
|
+ one-way migration with no simple rollback once child rows are written and
|
|
|
|
|
+ the inline field is dropped.
|
|
|
|
|
+
|
|
|
|
|
+**Recommendation: not yet, and only partly for the reason originally deferred.**
|
|
|
|
|
+The wait was "for relations" so the split has referential integrity to lean on
|
|
|
|
|
+- but Part 2 and this section both land on the same conclusion: the relation
|
|
|
|
|
+this split would actually use has a TTL-bearing parent, which is on the
|
|
|
|
|
+upstream implementor's own list of races to avoid for `cascade`/`set_null`.
|
|
|
|
|
+Declaring the relation as `no_action` (the safe choice) removes the main thing
|
|
|
|
|
+relations were expected to buy here - automatic child cleanup - leaving a
|
|
|
|
|
+second collection to keep in sync by hand, which is the same kind of sweep
|
|
|
|
|
+`a0cf96e` just had to run once already. The listing-side problem the split was
|
|
|
|
|
+meant to solve is already half-fixed by the `executions_summary` view; the
|
|
|
|
|
+concrete remaining offender (`listPending`) is a much smaller, much cheaper fix
|
|
|
|
|
+on its own - add a page size cap and a field projection to that one query -
|
|
|
|
|
+and would remove most of the "gRPC limit hit silently" risk without touching
|
|
|
|
|
+the schema, the write path, or every read path at once. Revisit the full split
|
|
|
|
|
+once (a) the client is actually on 2.11.0, (b) `describeDelete`/`checkRelation`
|
|
|
|
|
+give an operator a way to see what a `no_action` relation is quietly leaving
|
|
|
|
|
+dangling, and (c) `listPending`-style raw reads have been audited across the
|
|
|
|
|
+codebase to confirm this is the only one - if it is not, the case for the split
|
|
|
|
|
+gets stronger.
|