Skip to content

Fix high I/O writes with shadow state#324

Open
Serveny wants to merge 1 commit into
nekename:mainfrom
Serveny:fix-high-io-write-with-shadow-state
Open

Fix high I/O writes with shadow state#324
Serveny wants to merge 1 commit into
nekename:mainfrom
Serveny:fix-high-io-write-with-shadow-state

Conversation

@Serveny

@Serveny Serveny commented May 7, 2026

Copy link
Copy Markdown

This PR reduces excessive disk writes caused by frequent plugin-driven button updates (Issue: #249).

Previously, every dynamic setTitle, setImage, or setState update mutated the persisted profile state and often caused a full profile JSON write plus repeated exported image writes. This was especially expensive for monitor-style plugins that update buttons multiple times per second.

Now, transient plugin display updates stay in runtime state, while persisted profile state is kept separately. Real configuration changes still persist, but writes are skipped or debounced where possible.

Changes

  • Split ActionInstance into runtime display state and persisted state.
  • setTitle and setImage from plugins no longer persist transient display updates.
  • setState remains persistent, but now uses debounced profile saving.
  • setSettings writes are debounced and skipped when unchanged.
  • Profile JSON writes are skipped when serialized content is unchanged.
  • Exported data: images are only rewritten when file contents changed.
  • Pending debounced profile saves are flushed on exit/profile switching to avoid data loss.

Write Behavior

Plugin setTitle / setImage:
0 profile writes
Runtime state + rendering only

Plugin setState:
At most 1 profile save after 2 seconds of inactivity per context
Repeated setState events reset the debounce timer
Pending saves are flushed on normal app exit/profile switch

Plugin or Property Inspector setSettings:
At most 1 profile save after 2 seconds of inactivity per context
Only scheduled when settings actually changed

Editor/UI changes:
Still saved immediately
Actual disk write is skipped if serialized JSON is unchanged

Even when a debounced profile save fires, *.json.temp is only written if the serialized profile differs from the existing file. Exported data: images are only rewritten if the file content changed.

Before

flowchart TD
    A[Plugin updates button] --> B[setTitle / setImage / setState]
    B --> C[Mutate profile state]
    C --> D[Render updated button]
    C --> E[Save full profile JSON]
    E --> F[Write *.json.temp]
    E --> G[Export data: images to config/images]
    G --> H[Rewrite SVG/PNG files repeatedly]
Loading

After

flowchart TD
    A[Plugin setTitle / setImage] --> B[Mutate runtime state only]
    B --> C[Render updated button]
    B --> D[0 profile writes]

    E[Plugin setState] --> F[Update runtime current_state]
    F --> G[Update persisted current_state]
    G --> H[Debounced profile save after 2s idle]

    I[Plugin/PI setSettings] --> J{Settings changed?}
    J -- No --> K[No save scheduled]
    J -- Yes --> H

    L[Editor/UI change] --> M[Immediate profile save]

    H --> N{Serialized profile changed?}
    M --> N
    N -- No --> O[Skip disk write]
    N -- Yes --> P[Write *.json.temp]
    P --> Q{Exported image changed?}
    Q -- No --> R[Skip image rewrite]
    Q -- Yes --> S[Write image file]
Loading

How I did it?

  1. Analysis + Fix with GPT-5.5
  2. Code review by Claude and me

@CLAassistant

CLAassistant commented May 7, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@nekename

Copy link
Copy Markdown
Owner

I don't understand how the persisted_states and persisted_current_state properties provide any meaningful benefit here. It seems like persisted_states simply results in changes to the states being ignored when the profile is eventually saved (e.g. flushed on exit), which is not what we want to happen, and as far as I can tell, persisted_current_state is doing absolutely nothing as it always mirrors current_state.

In addition, I find it hard to believe that debouncing saves for set_settings, set_title and other small updates makes any meaningful difference, especially as settings are not updated constantly.

I believe that the bulk of the improvement introduced by this PR is likely from the processes of reading the files and comparing the contents to skip useless writes, as well as indefinitely delaying setImage-induced saves. Could you remove the persisted_ properties and restore the original non-debouncing behaviour of the non-image events, then compare the disk usage?

@Serveny
Serveny force-pushed the fix-high-io-write-with-shadow-state branch from bf519eb to 24c15d9 Compare May 24, 2026 09:31
@Serveny

Serveny commented May 24, 2026

Copy link
Copy Markdown
Author

Hi, thanks for the review :)

I partially agree. You were right about persisted_current_state: it was only mirroring current_state, so I removed it.

For persisted_states, I think it still provides a meaningful benefit. It keeps runtime-only state changes, such as frequent setTitle/setImage updates, out of the persisted profile representation. Without that separation, the profile JSON keeps changing, so the file-content comparison still has to read the existing files and often still has to write because the serialized profile is genuinely different.

In my test, keeping only the file comparisons reduced writes, but introduced many reads and still left repeated profile/image writes. With persisted_states, those dynamic runtime updates no longer affected the profile file at all, which removed almost all of that disk activity.

Before

  write_MB   writes    read_MB    reads  file
     2.098       84      0.000        0  ~/.config/opendeck/profiles/sd-AL15I1C01181/Default.json
     0.347       84      0.000        0  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.10.0/0.png
     0.025       84      0.000        0  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.2.0/0.svg
     0.025       84      0.000        0  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.12.0/0.svg
     0.025       84      0.000        0  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.8.0/0.svg
     0.025       84      0.000        0  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.6.0/0.svg
     0.025       84      0.000        0  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.3.0/0.svg
     0.025       84      0.000        0  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.13.0/0.svg
     0.025       84      0.000        0  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.5.0/0.svg
     0.025       84      0.000        0  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.11.0/0.svg
     0.025       84      0.000        0  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.1.0/0.svg
     0.025       84      0.000        0  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.0.0/0.svg
     0.025       84      0.000        0  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.7.0/0.svg
     0.025       84      0.000        0  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.9.0/0.svg
     0.025       84      0.000        0  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.4.0/0.svg
     0.025       84      0.000        0  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.14.0/0.svg
     0.000        0      0.001        2  ~/.config/opendeck/settings.json

With file compare only

  write_MB   writes    read_MB    reads  file
     0.500       20      2.048       82  ~/.config/opendeck/profiles/sd-AL15I1C01181/Default.json
     0.086       20      0.355       82  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.10.0/0.png
     0.004       12      0.024       82  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.0.0/0.svg
     0.004       12      0.024       82  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.5.0/0.svg
     0.003       10      0.024       82  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.6.0/0.svg
     0.003       10      0.024       82  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.14.0/0.svg
     0.003       10      0.024       82  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.1.0/0.svg
     0.003        9      0.024       82  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.7.0/0.svg
     0.003        9      0.024       82  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.11.0/0.svg
     0.003        9      0.024       82  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.9.0/0.svg
     0.003        9      0.024       82  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.2.0/0.svg
     0.002        8      0.024       82  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.12.0/0.svg
     0.002        8      0.024       82  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.4.0/0.svg
     0.002        7      0.024       82  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.3.0/0.svg
     0.002        7      0.024       82  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.13.0/0.svg
     0.002        6      0.024       82  ~/.config/opendeck/images/sd-AL15I1C01181/Default/Keypad.8.0/0.svg
     0.000        0      0.001        2  ~/.config/opendeck/settings.json

With persisted_states & file compare

  write_MB   writes    read_MB    reads  file
     0.000        0      0.001        2  ~/.config/opendeck/settings.json

How I tested

  1. Ran deno run tauri dev in Terminal 1, waited until build finished and UI opened
output
  1. Ran tracking command in Terminal 2 for 20s:
set OD ~/.config/opendeck
set RUN_ID (date +%Y%m%d-%H%M%S)
set TRACE_DIR /tmp/opendeck-io-runs/$RUN_ID
mkdir -p $TRACE_DIR

set PIDS (pgrep -f 'target/debug/.*opendeck|/opendeck$|OpenDeck')

set STRACE_PIDS
for pid in $PIDS
    set -a STRACE_PIDS -p $pid
end

sudo timeout 20s strace -ff -tt -yy -s 120 \
  -o $TRACE_DIR/trace \
  -e trace=read,write,pread64,pwrite64,readv,writev,fsync,fdatasync,openat,openat2,creat,rename,renameat,renameat2,unlink,unlinkat \
  $STRACE_PIDS
  1. Read out file changes:
env OD=$OD perl -ne '
BEGIN { $root = $ENV{"OD"}; }
if (/\b(read|pread64|readv|write|pwrite64|writev)\(\d+<([^>]+)>.*\)\s+=\s+(\d+)/) {
  $op = $1;
  $path = $2;
  $bytes = $3;
  next if index($path, $root) != 0;
  next if $bytes <= 0;

  $path =~ s/\.json\.(temp|bak)$/.json/;

  if ($op =~ /read/) {
    $r{$path} += $bytes;
    $rc{$path}++;
  } else {
    $w{$path} += $bytes;
    $wc{$path}++;
  }

  $files{$path} = 1;
}
END {
  printf "%10s %8s %10s %8s  %s\n", "write_MB", "writes", "read_MB", "reads", "file";
  for $f (sort { ($w{$b}||0) <=> ($w{$a}||0) || ($r{$b}||0) <=> ($r{$a}||0) } keys %files) {
    printf "%10.3f %8d %10.3f %8d  %s\n",
      ($w{$f}||0)/1048576, ($wc{$f}||0),
      ($r{$f}||0)/1048576, ($rc{$f}||0),
      $f;
  }
}
' $TRACE_DIR/trace.*

@nekename

Copy link
Copy Markdown
Owner

runtime-only state changes, such as frequent setTitle/setImage updates

But as far as I know, setTitle, setImage and setState updates are intended to be persisted across restarts of OpenDeck. If I'm wrong about that, feel free to correct me with some evidence, but otherwise, discarding those changes entirely doesn't seem correct to me.

@Serveny

Serveny commented Jun 27, 2026

Copy link
Copy Markdown
Author

But as far as I know, setTitle, setImage and setState updates are intended to be persisted across restarts of OpenDeck. If I'm wrong about that, feel free to correct me with some evidence, but otherwise, discarding those changes entirely doesn't seem correct to me.

The state, title, and images are indeed saved persistently, though only after two seconds without further changes or when the program terminates. In my view, there is really no other solution, as the setter functions would otherwise trigger an immediate change on the hard drive.

@nekename

Copy link
Copy Markdown
Owner

Yes, but with this code, iirc, they are never saved (not after 2 seconds and not when it terminates - persisted_states is discarded).

That's just from my memory from looking at this last though

@Serveny

Serveny commented Jun 28, 2026

Copy link
Copy Markdown
Author

I tested it and the persisted_states actually is being saved. The code for this is in src-tauri/src/store/simplified_profile.rs in line 78:

let mut states = value.persisted_states.clone();

In line 135 the variable gets part of the returned struct which is serialized to JSON later:

Self {
	context: disk_context,
	action: value.action,
	states,
	current_state: value.current_state,
	settings: value.settings,
	children: value.children.map(|c| c.into_iter().map(|v| v.into()).collect()),
}

That being said, I totally understand your caution. I'm not deeply familiar with the codebase, and my implementation could have flaws, that I can't see. Would it be possible for you or another maintainer to take over this issue? You could use this PR as inspiration and create a more well-thought-out fix :)

@Serveny
Serveny marked this pull request as draft June 28, 2026 09:13
@nekename

Copy link
Copy Markdown
Owner

Ah, I see it there! OK, that makes more sense then. There are no other maintainers ;) but I'll keep this open and review it when I get time. Currently taking a break from this project to work on something new.

@nekename
nekename marked this pull request as ready for review June 28, 2026 13:31
@Serveny

Serveny commented Jun 28, 2026

Copy link
Copy Markdown
Author

There are no other maintainers

Oh, then an extra thank you for your service! 🫡

Currently taking a break from this project

I understand, variety is important. When there is time, I will also examine the effects of the changes in more detail. I could formulate a few tests and run review agents over it to get a deeper understanding or something like that.

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.

3 participants