|
| 1 | +# Known gaps and tradeoffs |
| 2 | + |
| 3 | +The resident-worker prototype (`_internal` package) trades some simplicity |
| 4 | +for speed. This file records what it gives up, what is still missing, and |
| 5 | +what would fix each item properly, so that the tradeoffs are chosen on |
| 6 | +purpose when the code moves to its permanent homes (see [Where the code |
| 7 | +goes](#where-the-code-goes)). |
| 8 | + |
| 9 | +## Concurrency: one worker per environment |
| 10 | + |
| 11 | +**Now:** Runs on the same *worker* execute one at a time, because Appose |
| 12 | +reports worker output without saying which task produced it. Serializing |
| 13 | +runs per worker is the only way to attribute output correctly. |
| 14 | +`ResidentWorkerService` gives each environment exactly one worker, so in |
| 15 | +practice runs on the same environment are serialized too. Runs on different |
| 16 | +environments proceed in parallel. |
| 17 | + |
| 18 | +**Why not a pool:** `LazyEnvironment` and `ResidentWorker` already allow |
| 19 | +several workers per environment. The one-worker rule is only a policy in |
| 20 | +`ResidentWorkerService`. It stays because a pool has costs that users would |
| 21 | +notice: |
| 22 | + |
| 23 | +- Each worker holds its own copy of whatever a script loads. Two workers |
| 24 | + running a GPU model need twice the GPU memory. |
| 25 | +- State kept with `task.export(...)` lives in one worker. A run that lands |
| 26 | + on a different worker loads the model again, so run times would vary |
| 27 | + unpredictably. |
| 28 | + |
| 29 | +**Proper fix:** Have Appose attribute output to tasks, e.g. by having the |
| 30 | +Python worker redirect `sys.stdout`/`sys.stderr` per task thread and tag |
| 31 | +each line with the task's UUID. Then a worker could run tasks concurrently, |
| 32 | +and serialization would be needed only when the script itself is not |
| 33 | +thread-safe (which is common for GPU models). A pool, if ever wanted, should |
| 34 | +be opt-in per environment and capped. |
| 35 | + |
| 36 | +## Worker lifetime and memory |
| 37 | + |
| 38 | +**Now:** A worker lives until one of these happens: |
| 39 | + |
| 40 | +- the application exits; |
| 41 | +- the SciJava context is disposed; |
| 42 | +- its environment's configuration changes (the old worker is released); |
| 43 | +- it is killed by the cancel timeout (see below); |
| 44 | +- it crashes. |
| 45 | + |
| 46 | +Until then it keeps all of its memory, including GPU memory. |
| 47 | +`ResidentWorkerService.releaseAll()` exists, but nothing in the UI calls it. |
| 48 | + |
| 49 | +**Gaps:** |
| 50 | + |
| 51 | +- **No way for a user to free memory.** A *Plugins ▸ Scripting ▸ Release |
| 52 | + Python Workers* command is a few lines, and should come first. |
| 53 | +- **Edited helper modules are not reloaded.** Each run gets a fresh |
| 54 | + namespace, but `sys.modules` persists. If a script imports a local |
| 55 | + `helpers.py` and the user edits it, the old version keeps running until the |
| 56 | + worker restarts. This will confuse people during development. The release |
| 57 | + command fixes it by hand. Checking the modification times of imported |
| 58 | + local modules could fix it automatically, but may not be worth the effort. |
| 59 | +- **No idle timeout, on purpose.** From inside the process, "idle" and |
| 60 | + "finished" look the same (scikit-ops design 0019 reaches the same |
| 61 | + conclusion). Freeing memory should be an explicit action or a rule |
| 62 | + applied before a known-heavy task, not a timer. |
| 63 | + |
| 64 | +## Cancelation |
| 65 | + |
| 66 | +**Now:** Canceling a run's SciJava task sends a cancel request to the Appose |
| 67 | +task. Scripts that check `task.cancel_requested` stop by themselves, and |
| 68 | +their worker stays alive. If the script has not stopped after |
| 69 | +`SciJavaTasks.CANCEL_GRACE_MILLIS` (3 s), its worker process is killed. |
| 70 | + |
| 71 | +**Tradeoffs:** |
| 72 | + |
| 73 | +- Killing the worker also throws away everything it held, including models |
| 74 | + kept with `task.export(...)`, so the next run starts cold. That is the |
| 75 | + right trade for a script that ignores cancelation, but users may not |
| 76 | + expect it. |
| 77 | +- The 3-second grace period is a guess. A script that checks |
| 78 | + `cancel_requested` only between slow steps (e.g. once per image of a |
| 79 | + batch) may get killed even though it would have stopped on its own. |
| 80 | + Making the grace period configurable per script (e.g. a `#@script` |
| 81 | + attribute) would be cheap. |
| 82 | + |
| 83 | +**Missing:** Builds cannot be canceled at all. The build task's Cancel button |
| 84 | +does nothing. This needs Appose core support: the builders run pixi, uv or |
| 85 | +micromamba as subprocesses, which could be destroyed, but the environment |
| 86 | +directory would then need cleaning up. |
| 87 | + |
| 88 | +## Environment builds |
| 89 | + |
| 90 | +**Now:** `LazyEnvironment` builds each environment once per instance. |
| 91 | +Builds of same-named environments are serialized, because they share a |
| 92 | +directory: e.g. an environment file edited while its previous version is |
| 93 | +still building. |
| 94 | + |
| 95 | +**Gaps:** |
| 96 | + |
| 97 | +- **The lock only works within one JVM.** Two processes building the same |
| 98 | + environment at once can still collide, e.g. two Fiji instances, or Fiji |
| 99 | + and a Python front end using the same Appose directory. That needs a |
| 100 | + file lock in Appose core. |
| 101 | +- **Changes are detected by content only.** A worker is replaced when the |
| 102 | + environment file's *contents* change (compared in memory). Appose's own |
| 103 | + up-to-date check handles whether the directory on disk needs rebuilding. |
| 104 | +- **Up-to-date checks show a task too.** The first run in a session shows a |
| 105 | + "Building environment" task even when nothing needs building, because the |
| 106 | + up-to-date check happens inside the build. It usually vanishes within a |
| 107 | + second, but it flickers. Appose core could report "already up to date" |
| 108 | + before a build starts. |
| 109 | + |
| 110 | +## Output routing |
| 111 | + |
| 112 | +**Now:** Worker stdout and stderr reach the script's output panes by parsing |
| 113 | +Appose's debug messages (`[WORKER-n] line`, `[SERVICE-n] <INVALID> line`). |
| 114 | +Because stderr is read separately from stdout, the wrapper writes a unique |
| 115 | +end-marker line to stderr, and the engine waits up to 2 s for it before |
| 116 | +returning, so that late stderr lines are not lost or misattributed. |
| 117 | + |
| 118 | +**Fragile because:** The whole mechanism depends on debug message formats, |
| 119 | +which are not API. Appose core should offer structured stdout/stderr |
| 120 | +callbacks on `Service`, ideally tagged by task (see |
| 121 | +[Concurrency](#concurrency-one-worker-per-environment)), making both the |
| 122 | +parsing and the end marker unnecessary. |
| 123 | + |
| 124 | +## Shared memory |
| 125 | + |
| 126 | +**Now:** Image outputs converted to another type (e.g. `Img`) are copied, |
| 127 | +then their shared memory is unlinked. By Appose convention, the Java side |
| 128 | +frees shared memory, even memory the worker allocated. Before the resident |
| 129 | +worker, every such output leaked one block. |
| 130 | + |
| 131 | +**Gaps:** |
| 132 | + |
| 133 | +- **Outputs kept as `NDArray` are never unlinked.** Outputs declared as |
| 134 | + `NDArray` or `Object` are handed to the caller as is. The caller must call |
| 135 | + `shm().unlinkOnClose(true)` before closing them, and nothing tells them |
| 136 | + so. |
| 137 | +- **Exported inputs pin memory.** If a script exports an object that |
| 138 | + references an input array (e.g. `task.export(img=image)`), the worker keeps |
| 139 | + that shared memory mapped after Java has unlinked it. On POSIX the memory |
| 140 | + stays valid but held; on Windows the named block stays alive. It is freed |
| 141 | + when the worker exits. |
| 142 | +- **Not verified.** The unlink fix is covered only indirectly by tests; |
| 143 | + nothing checks directly that blocks are freed. A shared memory pool in |
| 144 | + Appose core (planned) would make ownership explicit. |
| 145 | + |
| 146 | +## Not yet tried |
| 147 | + |
| 148 | +- **Real Fiji GUI.** All tests are headless. The build and run tasks have not |
| 149 | + been looked at in Fiji's task widget, and cancel has not been clicked |
| 150 | + there. |
| 151 | +- **Windows.** Neither the numpy init workaround with a resident worker, nor |
| 152 | + killing workers, nor the end-marker handshake has been tested there. |
| 153 | +- **Long sessions.** Memory growth over many runs (e.g. exported state, |
| 154 | + leaked blocks) has not been measured. |
| 155 | + |
| 156 | +## Where the code goes |
| 157 | + |
| 158 | +| Class | Destination | |
| 159 | +| --- | --- | |
| 160 | +| `BuildListener`, `LazyEnvironment`, `ResidentWorker` | Appose core | |
| 161 | +| `ResidentWorkerService`, `SciJavaTasks` | `scijava/scijava-appose` | |
| 162 | +| `NDArrayToImgConverter`, `RAIToNDArrayConverter`, ImageJ2 dependencies | `fiji/fiji-appose` | |
| 163 | + |
| 164 | +Planned changes in Appose core that would retire workarounds here: |
| 165 | + |
| 166 | +- structured, task-tagged worker output; |
| 167 | +- build cancelation; |
| 168 | +- a file lock for concurrent builds; |
| 169 | +- a report that an environment is already up to date, before the build |
| 170 | + starts; |
| 171 | +- a shared memory pool. |
0 commit comments