Skip to content

Commit efa4846

Browse files
authored
fix(flows): stop the flow migration copying non-flow objects, and remove the rows it already wrote (#3408)
MigrateRegisterFlowsToTable asked ObjectService::findAll() for register `flows` / schema `flow` at the TOP LEVEL of the config. prepareFindAllConfig() reads $config['filters']['register'] and $config['filters']['schema'] and no other key, so both were inert: setRegister()/setSchema() were never called and the read ran against whatever $currentRegister/$currentSchema the SHARED ObjectService instance was still carrying. saveObject() sets that context and never restores it - find() puts it back in a finally, saveObject() has no such block - and ImportCredentialBrokerRegister runs four repair steps ahead of the migration, saving credential_broker_register.json's two example objects through it. So the migration inherited credential-broker / brokeredcredential and copied both examples into openregister_flows: empty nodes, empty edges, no trigger, owner __system__ (which is only what OpenRegister stamps on a sessionless write, not a convention). Every other findAll() caller under lib/ nests the pair under filters. This step was the outlier, and no test looked at the argument because every fixture mocked findAll() to answer regardless of what was asked. Two independent guards, because they fail independently: - selection: the pair moves under `filters`, with _rbac/_multitenancy off since a repair step has no session; - validity: isFlowDefinition() refuses any row with no nodes, no edges and no trigger. It depends on nothing but the row in front of it, where the scoping guard depends on shared mutable service state staying correct for a whole occ upgrade - which is what broke. PurgePhantomMigratedFlows removes what already shipped, on the full conjunction only: openregister-owned, disabled, no nodes, no edges, no trigger/triggerRegister/triggerSchema/cron, never dispatched (lastRun* all null AND zero rows in openregister_flow_runs), AND its uuid still resolves to a register object whose schema is not `flow`. It spares a row with one node or one edge, a graph-less draft that names a trigger, any enabled flow, anything with run history, any other app's flow, and the ambiguous case - an unrunnable shell resolving to nothing or to a real flow object is named in the output, never deleted. Erring toward keeping an unrunnable row is recoverable; erring the other way is not. Idempotent; post-migration only, since the fixed migration cannot produce these rows on a fresh install. Also completes Flow's @method roster: status, statusMessage and the four lastRun* accessors were added as columns by Version1Date20260805100000 and never declared, so phpstan could not see them.
1 parent cabd410 commit efa4846

7 files changed

Lines changed: 1024 additions & 7 deletions

File tree

appinfo/info.xml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,27 @@ Vrij en open source onder de EUPL-licentie.
217217
Copies the register-authored ones across, disabled, leaving the
218218
register rows in place so the step stays reversible. -->
219219
<step>OCA\OpenRegister\Repair\MigrateRegisterFlowsToTable</step>
220+
<!-- CLEANS UP AFTER THE VERSION OF THE STEP ABOVE THAT DID NOT SCOPE
221+
ITS READ. It asked findAll() for register `flows` / schema `flow`
222+
at the TOP LEVEL of the config; ObjectService reads that pair
223+
from `filters` and nowhere else, so both keys were inert and the
224+
read ran against whatever register/schema the SHARED service was
225+
still carrying. saveObject() sets that context and never restores
226+
it, and ImportCredentialBrokerRegister saves two example objects
227+
through it four steps earlier — so the migration copied both
228+
`brokeredcredential` examples into openregister_flows: no nodes,
229+
no edges, no trigger, owner `__system__`.
230+
231+
Post-migration ONLY. A fresh install runs the fixed migration,
232+
which cannot produce these rows, so listing this under <install>
233+
would be a guaranteed no-op.
234+
235+
Removes a row ONLY on the full conjunction: openregister-owned,
236+
disabled, no nodes, no edges, no trigger/cron, never dispatched
237+
(lastRun* null AND zero run rows), AND its uuid still resolves to
238+
a register object whose schema is not `flow`. Anything ambiguous
239+
is REPORTED by uuid, not deleted. Idempotent. -->
240+
<step>OCA\OpenRegister\Repair\PurgePhantomMigratedFlows</step>
220241
<step>OCA\OpenRegister\Repair\ImportTrustConfigurationRegister</step>
221242
<step>OCA\OpenRegister\Repair\SeedVocabularyRegister</step>
222243
<step>OCA\OpenRegister\Repair\RegisterOpenRegisterWithDoriath</step>

lib/Db/Flow.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,18 @@
9696
* @method void setCreated(?DateTime $created)
9797
* @method DateTime|null getUpdated()
9898
* @method void setUpdated(?DateTime $updated)
99+
* @method string|null getStatus()
100+
* @method void setStatus(?string $status)
101+
* @method string|null getStatusMessage()
102+
* @method void setStatusMessage(?string $statusMessage)
103+
* @method string|null getLastRunUuid()
104+
* @method void setLastRunUuid(?string $lastRunUuid)
105+
* @method string|null getLastRunStatus()
106+
* @method void setLastRunStatus(?string $lastRunStatus)
107+
* @method string|null getLastRunMessage()
108+
* @method void setLastRunMessage(?string $lastRunMessage)
109+
* @method DateTime|null getLastRunAt()
110+
* @method void setLastRunAt(?DateTime $lastRunAt)
99111
*
100112
* An Entity mirrors its table one field per column, so the field count below is
101113
* the schema's rather than a design choice. Splitting it would need a second

lib/Db/FlowRunMapper.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,41 @@ public function hasActiveRun(string $flowId): bool {
674674
return $row !== false;
675675
}//end hasActiveRun()
676676

677+
/**
678+
* How many runs this flow has EVER had, terminal ones included.
679+
*
680+
* {@see hasActiveRun} answers a different question — whether a run is going
681+
* right now — and a flow that ran to completion last year has none. Deleting
682+
* a flow is irreversible, so the guard that authorises it has to ask about
683+
* the flow's whole history, not its current activity.
684+
*
685+
* Used by {@see \OCA\OpenRegister\Repair\PurgePhantomMigratedFlows} as one
686+
* conjunct of the never-dispatched proof: a row with even one run row has
687+
* been part of something that happened and is never removed.
688+
*
689+
* @param string $flowId The flow's uuid.
690+
*
691+
* @return integer The number of run rows recorded against this flow.
692+
*
693+
* @spec openspec/changes/flow-engine-unification/specs/flow-storage/spec.md
694+
*/
695+
public function countRunsForFlow(string $flowId): int {
696+
if (trim($flowId) === '') {
697+
return 0;
698+
}
699+
700+
$qb = $this->db->getQueryBuilder();
701+
$qb->select($qb->createFunction('COUNT(*) AS `total`'))
702+
->from($this->getTableName())
703+
->where($qb->expr()->eq('flow_id', $qb->createNamedParameter($flowId)));
704+
705+
$result = $qb->executeQuery();
706+
$row = $result->fetch();
707+
$result->closeCursor();
708+
709+
return (int)($row['total'] ?? 0);
710+
}//end countRunsForFlow()
711+
677712
/**
678713
* How many runs are still going, for one organisation.
679714
*

lib/Repair/MigrateRegisterFlowsToTable.php

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ public function run(IOutput $output): void {
175175
$moved = 0;
176176
$already = 0;
177177
$failed = 0;
178+
$notFlows = 0;
178179

179180
foreach ($objects as $object) {
180181
$row = $this->normalise(row: $object);
@@ -184,6 +185,16 @@ public function run(IOutput $output): void {
184185
continue;
185186
}
186187

188+
// The second, independent guard — see isFlowDefinition().
189+
if ($this->isFlowDefinition(object: $row['data']) === false) {
190+
$notFlows++;
191+
$this->logger->warning(
192+
'[MigrateRegisterFlowsToTable] refused ' . $uuid
193+
. ': no nodes, no edges and no trigger, so it is not a flow definition'
194+
);
195+
continue;
196+
}
197+
187198
try {
188199
$this->flowMapper->findByUuid($uuid);
189200
$already++;
@@ -211,26 +222,87 @@ public function run(IOutput $output): void {
211222
// the defect this repairs stayed invisible.
212223
$output->info(
213224
sprintf(
214-
'Register-authored flows: %d migrated, %d already in the table, %d failed (register rows left in place).',
225+
'Register-authored flows: %d migrated, %d already in the table, %d not a flow definition, %d failed (register rows left in place).',
215226
$moved,
216227
$already,
228+
$notFlows,
217229
$failed
218230
)
219231
);
220232
}//end run()
221233

234+
/**
235+
* Whether this object is a flow definition at all.
236+
*
237+
* 🔴 BOTH GUARDS, DELIBERATELY. Scoping the read (see registerFlows()) fixes
238+
* the wrong POPULATION being selected; this fixes a wrong ROW inside the
239+
* selected one being written. They fail independently, which is the point:
240+
* the scoping guard depends on shared, mutable service state staying correct
241+
* for the whole of an `occ upgrade`, and that state is exactly what broke.
242+
* This one depends on nothing but the row in front of it.
243+
*
244+
* The test is what the flow engine needs to do anything: a graph to walk
245+
* (`nodes` or `edges`) or something to start it (`trigger`). A row with none
246+
* of the three cannot run, cannot be made to run, and is not a flow — it is
247+
* whatever else the read handed us. It is not a completeness check: an empty
248+
* graph that already names a trigger is a half-authored flow and crosses.
249+
*
250+
* @param array<string, mixed> $object The register object's payload.
251+
*
252+
* @return boolean True when the payload is a flow definition.
253+
*/
254+
private function isFlowDefinition(array $object): bool {
255+
if (empty($object['nodes']) === false) {
256+
return true;
257+
}
258+
259+
if (empty($object['edges']) === false) {
260+
return true;
261+
}
262+
263+
$trigger = ($object['trigger'] ?? null);
264+
265+
return is_string($trigger) === true && trim($trigger) !== '';
266+
}//end isFlowDefinition()
267+
222268
/**
223269
* Every object in the flows register.
224270
*
225271
* @return array<int, mixed> The rows, each an ObjectEntity (see normalise()).
226272
*/
227273
private function registerFlows(): array {
274+
// 🔴 `register` / `schema` GO UNDER `filters`, AND NOWHERE ELSE.
275+
//
276+
// This step used to pass them at the top level of the config, which
277+
// reads correctly and does nothing: `ObjectService::prepareFindAllConfig()`
278+
// inspects `$config['filters']['register']` and `$config['filters']['schema']`
279+
// and no other key, so `setRegister()` / `setSchema()` were never called
280+
// and the read ran against whatever `$currentRegister` / `$currentSchema`
281+
// the SHARED ObjectService instance was still carrying.
282+
//
283+
// MEASURED, not inferred. `saveObject()` sets that context and never
284+
// restores it — `find()` does, in a `finally`; `saveObject()` has no such
285+
// block. `ImportCredentialBrokerRegister` runs four repair steps ahead of
286+
// this one and saves `credential_broker_register.json`'s two example
287+
// objects through it. So this step inherited `credential-broker` /
288+
// `brokeredcredential` and copied both examples into `openregister_flows`:
289+
// empty nodes, empty edges, no trigger, `_owner` = `__system__` (which is
290+
// only what OpenRegister stamps on a sessionless write, not a convention).
291+
//
292+
// Every other `findAll()` caller under `lib/` nests the pair. This step
293+
// was the outlier, and `_rbac` / `_multitenancy` are off because a repair
294+
// step has no session — `occ` runs as Anonymous, so a scoped read would
295+
// return nothing at all.
228296
$result = $this->objectService->findAll(
229-
[
230-
'register' => self::FLOW_REGISTER,
231-
'schema' => self::FLOW_SCHEMA,
232-
'limit' => 1000,
233-
]
297+
config: [
298+
'filters' => [
299+
'register' => self::FLOW_REGISTER,
300+
'schema' => self::FLOW_SCHEMA,
301+
],
302+
'limit' => 1000,
303+
],
304+
_rbac: false,
305+
_multitenancy: false
234306
);
235307

236308
// No `is_array($result)` guard: `findAll()` is typed to return an array,

0 commit comments

Comments
 (0)