Skip to content

Give the create flows one attempt object instead of by-reference locals - #220

Merged
Jaggob merged 3 commits into
mainfrom
refactor/199-create-attempt
Sep 4, 2026
Merged

Give the create flows one attempt object instead of by-reference locals#220
Jaggob merged 3 commits into
mainfrom
refactor/199-create-attempt

Conversation

@Jaggob

@Jaggob Jaggob commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Closes #199.

The four create flows each declared $padId, the created-file record and — in one case — $path as locals, then threaded them through three closures by reference: the action writes them, the rollback reads them, the log formatters read some of them. Twelve capture sites that had to stay in step, with nothing enforcing it.

Two defects had already come out of that, both found in review rather than by a test: a flow that never recorded its file, so a failure after the file existed reached the rollback with nothing to clean up; and a flow that recorded one and then discovered it was somebody else's pad — correct only because a variable was un-set a few lines above the throw.

withCreateRollback() now makes a PadCreateAttempt and hands it to every closure. There is one owner of what has been created, and nothing to keep in step. "This file is not mine after all" is disownFile() rather than an assignment whose position decides the answer.

Two details worth naming:

  • Create-by-parent is the only flow that does not know its path up front. It recorded that path after requireFileId() and the ownership check, so a failure in either logged an empty path. It is recorded as soon as the node exists.
  • The template flow keeps its file-only rollback, and now says why in code: it records its pad in order to log it, because materializeTemplateInto() removes that pad itself before rethrowing. Different resource ownership, not drift.

Behaviour is unchanged, and that is checkable

The 921 existing tests pass, and no existing test file is modified by this PR — the only test file it touches is the new one for PadCreateAttempt. For a pure state refactor that property is the strongest evidence available, so it was worth keeping intact.

The new tests state the two defects above as properties of the object rather than as orderings inside a closure.

What this is not

This unifies the state, not yet the flow. Production code grows by about 80 lines net: the four flows remain, their variables now live in the attempt. It is groundwork for a shared create core rather than the core itself.

What remains, in the order I would do it:

  1. Claim and check the file once instead of four times. Every flow repeats: read the id, register the claim, check existing content and binding, disown and abort. That ordering is security-relevant and is currently written correctly four times rather than centrally. It can be made behaviour-preserving, so the "existing tests untouched" check still applies.
  2. Reduce create() and createInParent() to one path. After the target is chosen they do the same thing. The public methods stay as thin entry points; folder resolution and parent_folder_id are their differences. This one changes flow, so it wants its own review.
  3. Consolidate the eight logging closures. Mostly building similar messages and context arrays, with the warning/error distinction preserved.

Templates and external pads should not be pressed into the same path: templates clean up their own Etherpad resources, and external pads must never be deleted. Shared file handling yes; differing resource ownership stays visible.

Verification

925 PHP tests / 3078 assertions, 261 JS tests, Psalm clean, Playwright 35 passed / 1 skipped.

One regression was introduced and fixed during review: splitting the docblock left two of them, and PHP attaches only the nearest — so @template T / @return T stopped applying and each flow's result was no longer checked against its declared return shape. Counter-checked in a throwaway worktree by returning pad_id => false: main reports 3 errors, this branch reported 0 before the fix and 2 after it.

Closes #199.

The four flows each declared `$padId`, the file record and — in one case —
`$path` as locals, and threaded them through three closures by reference:
the action writes them, the rollback reads them, the log formatters read
some of them. Twelve capture sites that had to stay in step, with nothing
enforcing it. Two defects had already come out of that, both found in
review rather than by a test: a flow that never recorded its file, and a
flow that recorded one and then discovered it was somebody else's pad —
correct only because a variable was un-set a few lines above the throw.

`withCreateRollback()` now makes a `PadCreateAttempt` and hands it to
every closure, so there is one owner of what has been created and nothing
to keep in step. "This file is not mine after all" is `disownFile()`
rather than an assignment whose position decides the answer.

The template flow keeps its file-only rollback, and now says why in code:
it records its pad to log it, because `materializeTemplateInto()` removes
that pad itself before rethrowing.

Behaviour is unchanged, and the 921 existing tests pass untouched — no
test file is modified by this commit. The new ones cover the attempt
itself, including the two defects above stated as properties of the object.
…ssed

Splitting the docblock left two of them: PHP attaches only the nearest, so
`@template T` / `@return T` stopped applying and the method returned
`mixed`. Psalm then stopped checking each flow's result against its own
declared return shape. Counter-checked in a throwaway worktree with
`pad_id => false`: main reports 3 errors, this branch reported 0 before
the fix and 2 after it.

Create-by-parent recorded its path after `requireFileId()` and the
ownership check, so a failure in either still logged an empty path. It is
recorded as soon as the node exists.
@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Centralize create-flow rollback state in PadCreateAttempt

✨ Enhancement 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Replaces by-reference create state with one shared, typed attempt object.
• Makes file ownership, rollback scope, and failure logging explicit across four create flows.
• Adds focused tests for disowning files and preserving rollback write evidence.
Diagram

sequenceDiagram
    actor C as Caller
    participant P as Creation Service
    participant A as Attempt State
    participant R as Resource Services
    participant G as Logger
    C->>P: Start create
    P->>A: Initialize attempt
    P->>R: Create resources
    P->>A: Record ownership
    alt Create succeeds
        P-->>C: Return result
    else Create fails
        P->>G: Log attempt context
        P->>R: Roll back owned resources
        P--xC: Rethrow error
    end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Consolidate the create flows immediately
  • ➕ Centralizes repeated file claiming and ownership checks now
  • ➕ Removes more duplication than a state-only refactor
  • ➖ Combines state and control-flow changes in one riskier review
  • ➖ Could obscure behavior differences for templates and external pads
  • ➖ Would require broader regression coverage
2. Use a shared associative state array
  • ➕ Requires less new domain code
  • ➕ Can be passed to all callbacks without reference captures
  • ➖ Provides no typed ownership operations
  • ➖ Allows missing or misspelled keys
  • ➖ Makes disowning a file an implicit assignment again

Recommendation: Keep the PR's typed attempt object as an incremental foundation. It makes ownership transitions explicit without prematurely merging flows whose Etherpad cleanup responsibilities differ; shared claiming logic can be extracted separately after this state model is established.

Files changed (3) +200 / -58

Refactor (2) +135 / -58
PadCreateAttempt.phpIntroduce shared state for one pad creation attempt +71/-0

Introduce shared state for one pad creation attempt

• Adds a typed owner for the created-file claim, pad ID, and resolved path. Explicit claim and disown operations define whether rollback may remove a file while preserving mutable write evidence on the same claim instance.

lib/Service/PadCreateAttempt.php

PadCreationService.phpPass PadCreateAttempt through all creation callbacks +64/-58

Pass PadCreateAttempt through all creation callbacks

• Refactors the four create flows, rollback handlers, and relevant log formatters to consume one shared attempt instead of by-reference locals. It records parent-based paths immediately after node creation and preserves distinct cleanup ownership for template and external-pad flows.

lib/Service/PadCreationService.php

Tests (1) +65 / -0
PadCreateAttemptTest.phpVerify creation ownership and rollback state properties +65/-0

Verify creation ownership and rollback state properties

• Tests empty initial state, explicit file disowning, identity-preserving claim access, and path/pad metadata recording. These properties cover the rollback defects that motivated the state object.

tests/phpunit/unit/PadCreateAttemptTest.php

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Sep 4, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. createInParent() can leak files ✓ Resolved 📎 Requirement gap ☼ Reliability
Description
createInParent() resolves the newly created node's user path before recording its ownership claim,
even though toUserAbsolutePath() can throw. If it does, rollback receives a null claim and leaves
the new .pad file behind, violating consistent state-recording and rollback-ownership
requirements.
Code

lib/Service/PadCreationService.php[R129-132]

+				$path = $this->userNodeResolver->toUserAbsolutePath($uid, $fileNode);
+				$attempt->recordPath($path);
  		$fileId = $this->requireFileId($fileNode, $path);
-				$claim = new CreatedFileClaim($uid, $fileId);
+				$claim = $attempt->claimFile($uid, $fileId);
Evidence
Compliance rules 2 and 3 require every create flow to record created resources so rollback can clean
up resources owned by the attempt. The file is created in PadCreationService.php at line 125, but
its claim is not recorded until line 132; between those operations, toUserAbsolutePath() can throw
when the node cannot be mapped into the user's file tree, after which rollback receives
attempt->claim() and rollbackCreatedFileOnly() immediately skips cleanup because that claim is
null.

Apply the state-object pattern consistently to all create flows
Preserve rollback ownership semantics
lib/Service/PadCreationService.php[125-132]
lib/Service/UserNodeResolver.php[196-203]
lib/Service/PadCreateRollbackService.php[60-63]
lib/Service/PadCreationService.php[123-132]
lib/Service/UserNodeResolver.php[193-203]
lib/Service/PadCreationService.php[159-162]
lib/Service/PadCreateRollbackService.php[60-66]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`createInParent()` creates a file and then calls the throwable `toUserAbsolutePath()` before registering a `CreatedFileClaim`. If path resolution fails, rollback receives a null claim and cannot delete the newly created `.pad` file.
## Issue Context
The production resolver can throw when the node cannot be mapped into the user's file tree. Establish rollback ownership immediately after obtaining the created file ID and before invoking path resolution, while preserving early path recording when resolution succeeds and retaining the path for rollback logging; rollback must be able to remove the empty created file on every post-creation exception path.
## Fix Focus Areas
- lib/Service/PadCreationService.php[125-132]
- lib/Service/UserNodeResolver.php[193-203]
- lib/Service/PadCreateRollbackService.php[60-75]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can route each action level your way: inline, summary, both, or drop

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread lib/Service/PadCreationService.php Outdated
Recording the path first was the wrong half of the trade.
`toUserAbsolutePath()` throws when the node cannot be mapped into the
user's tree, and by then `newFile()` has already made the file — so the
rollback was handed a null claim and left the empty .pad behind.

The id is what the rollback needs to remove the file at all, so the claim
comes first; the path follows and still reaches the log lines whenever it
resolves. When it does not, the log loses a path and the file is still
cleaned up, which is the right way round.

Counter-checked in a throwaway worktree: with the previous order the new
test fails with a null claim.
@Jaggob
Jaggob merged commit 95e892e into main Sep 4, 2026
17 checks passed
@Jaggob
Jaggob deleted the refactor/199-create-attempt branch September 4, 2026 14:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Give the create flows one state object instead of by-reference locals

1 participant