Skip to content

worker: memory leak #997

Closed
Closed

Description

@josephjclark

Issue to track the second memory leak the worker

We solved one some time ago, and set up some mitigation in GKE to kill workers frequently and stop memory rising.

But we are still seeing Lost runs as a result of the leak.

Tracking findings and solutions in this issue.

Activity

  1. josephjclark commented on Sep 15, 2025

    @josephjclark
    CollaboratorAuthor

    Here's some long running output, posting one run every 30 seconds

    [SRV] ❯ workflow run-1 complete in 0.779s (114.15mb heap) releasing worker thread
    [SRV] ❯ workflow run-2 complete in 2.936s (74.33mb heap) releasing worker thread
    [SRV] ❯ workflow run-4 complete in 10.241s (91.18mb heap) releasing worker thread
    [SRV] ❯ workflow run-3 complete in 40.287s (91.25mb heap) releasing worker thread
    [SRV] ❯ workflow run-5 complete in 48.26s (91.81mb heap) releasing worker thread
    [SRV] ❯ workflow run-6 complete in 20.201s (72.61mb heap) releasing worker thread
    [SRV] ❯ workflow run-7 complete in 19.593s (72.62mb heap) releasing worker thread
    [SRV] ❯ workflow run-8 complete in 23.375s (72.64mb heap) releasing worker thread
    [SRV] ❯ workflow run-9 complete in 84.488s (111.56mb heap) releasing worker thread
    [SRV] ❯ workflow run-10 complete in 57.992s (82.82mb heap) releasing worker thread
    [SRV] ❯ workflow run-11 complete in 71.783s (141.79mb heap) releasing worker thread
    [SRV] ❯ workflow run-12 complete in 78.102s (141.92mb heap) releasing worker thread
    [SRV] ❯ workflow run-13 complete in 87.199s (102.08mb heap) releasing worker thread
    [SRV] ❯ workflow run-15 complete in 116.523s (111.74mb heap) releasing worker thread
    [SRV] ❯ workflow run-14 complete in 149.33s (111.79mb heap) releasing worker thread
    [SRV] ❯ workflow run-16 complete in 180.181s (121.95mb heap) releasing worker thread
    [SRV] ❯ workflow run-18 complete in 142.575s (161.02mb heap) releasing worker thread
    [SRV] ❯ workflow run-23 complete in 217.14s (141.37mb heap) releasing worker thread
    

    I see this pattern consistently under different setups. Some observations:

    • The first run is always super quick
    • Memory goes up and down but does stay quite high. It's worse with more frequent runs
    • Why does the execution time show down so much?? Is this related?
  2. josephjclark commented on Sep 15, 2025

    @josephjclark
    CollaboratorAuthor

    Just running claims doesn't seem to trigger the leak - memory rises slowly but does get reclaimed (I guess when gc runs, having a bit of trouble getting node diagnostics to run)

    With capacity 1 and a single claim, memory does come right back down:

    [SRV] ❯ [claim] heap memory: 48.49
    [SRV] ❯ [claim] heap memory: 48.55
    [SRV] ❯ [claim] heap memory: 48.58
    [SRV] ❯ [claim] heap memory: 48.60
    [SRV] ❯ [claim] heap memory: 48.64
    [SRV] ❯ [run:start] heap memory: 58.91
    [SRV] ❯ workflow run-1 complete in 0.764s (75.13mb heap) releasing worker thread
    [SRV] ❯ [run:complete] heap memory: 75.15
    [SRV] ❯ [claim] heap memory: 75.18
    [SRV] ❯ [claim] heap memory: 75.21
    [SRV] ❯ [claim] heap memory: 75.23
    [SRV] ❯ [claim] heap memory: 75.25
    [SRV] ❯ [claim] heap memory: 75.29
    [SRV] ❯ [claim] heap memory: 75.31
    [SRV] ❯ [claim] heap memory: 45.85
    [SRV] ❯ [claim] heap memory: 45.87
    

    Also good with 2 claims - but note that, yet again, the second run is super slow

    But with capacity 2 and 10 runs posted in rapid succession, memory doesn't get reclaimed. Not entirely anyway:

    [SRV] ❯ [claim] heap memory: 46.29
    
    [SRV] ❯ [claim] heap memory: 47.99
    [SRV] ❯ [run:start] heap memory: 58.25
    [SRV] ❯ workflow run-1 complete in 0.825s (96.73mb heap) releasing worker thread
    [SRV] ❯ [run:complete] heap memory: 96.75
    [SRV] ❯ [claim] heap memory: 96.78
    [SRV] ❯ [run:start] heap memory: 81.14
    [SRV] ❯ [claim] heap memory: 85.19
    [SRV] ❯ [run:start] heap memory: 142.85
    [SRV] ❯ workflow run-2 complete in 3.85s (95.37mb heap) releasing worker thread
    [SRV] ❯ [run:complete] heap memory: 95.39
    [SRV] ❯ [claim] heap memory: 95.41
    [SRV] ❯ [run:start] heap memory: 152.55
    [SRV] ❯ workflow run-3 complete in 8.129s (94.87mb heap) releasing worker thread
    [SRV] ❯ [run:complete] heap memory: 94.87
    [SRV] ❯ [claim] heap memory: 94.89
    [SRV] ❯ [run:start] heap memory: 104.39
    [SRV] ❯ workflow run-4 complete in 13.381s (123.62mb heap) releasing worker thread
    [SRV] ❯ [run:complete] heap memory: 123.63
    [SRV] ❯ [claim] heap memory: 123.64
    [SRV] ❯ [run:start] heap memory: 101.97
    [SRV] ❯ workflow run-5 complete in 18.393s (121.22mb heap) releasing worker thread
    [SRV] ❯ [run:complete] heap memory: 121.22
    [SRV] ❯ [claim] heap memory: 121.24
    [SRV] ❯ [run:start] heap memory: 122.43
    [SRV] ❯ workflow run-6 complete in 23.157s (91.67mb heap) releasing worker thread
    [SRV] ❯ [run:complete] heap memory: 91.68
    [SRV] ❯ [claim] heap memory: 91.69
    [SRV] ❯ [run:start] heap memory: 101.36
    [SRV] ❯ workflow run-7 complete in 27.939s (91.75mb heap) releasing worker thread
    [SRV] ❯ [run:complete] heap memory: 91.76
    [SRV] ❯ [claim] heap memory: 91.77
    [SRV] ❯ [run:start] heap memory: 122.23
    [SRV] ❯ workflow run-8 complete in 34.653s (92.10mb heap) releasing worker thread
    [SRV] ❯ [run:complete] heap memory: 92.10
    [SRV] ❯ workflow run-9 complete in 29.669s (72.89mb heap) releasing worker thread
    [SRV] ❯ [run:complete] heap memory: 72.90
    [SRV] ❯ [claim] heap memory: 73.19
    [SRV] ❯ [claim] heap memory: 75.02
    [SRV] ❯ [run:start] heap memory: 103.31
    [SRV] ❯ [claim] heap memory: 103.33
    [SRV] ❯ [claim] heap memory: 73.12
    [SRV] ❯ workflow run-10 complete in 30.92s (73.18mb heap) releasing worker thread
    [SRV] ❯ [run:complete] heap memory: 73.18
    [SRV] ❯ [claim] heap memory: 73.20
    [SRV] ❯ [claim] heap memory: 73.57
    

    Same problem with capacity 1 actually

  3. josephjclark commented on Sep 15, 2025

    @josephjclark
    CollaboratorAuthor

    This is quite telling

    Inside the engine, the run duration is quite consistent, around 400ms. But the durations recorded by the worker are very different:

    [SRV] ✔ (heap) duration: 385ms
    [SRV] ❯ workflow run-3 complete in 7.219s (211.27mb heap) releasing worker thread
    

    In this case, there's nearly 7 seconds of something extending that duration

  4. josephjclark commented on Sep 15, 2025

    @josephjclark
    CollaboratorAuthor

    Slowing the claim rate down doesn't seem to help (maybe gc runs a bit better because everything is slower)

  5. josephjclark commented on Sep 15, 2025

    @josephjclark
    CollaboratorAuthor

    I don't think it's GC itself - it seems to be running super fast. Here's the output of the 9th run, which is taking 20 seconds from start to complete (and 400ms to execute)

    [RTE] ℹ starting workflow  run-9
    [R/T] ℹ Executing run-9
    [R/T] ℹ Starting step a
    [R/T] ❯ [linker] loading module @openfn/language-common
    [R/T] ❯ [linker] Loading module @openfn/language-common from /tmp/openfn/worker/repo/node_modules/@openfn/language-common_3.0.3/dist/index.cjs
    [SRV] ℹ cancelling workloop: server at capacity (1/1)
    [R/T] ℹ Resolved adaptor @openfn/language-common to version 3.0.3
    [R/T] ❯ Executing expression (1 operations)
    [R/T] ❯ Starting operation 1
    [R/T] ❯ Operation 1 complete in 76ms
    [R/T] ❯ Expression complete!
    [R/T] ✔ a completed in 238ms
    [R/T] ❯ Cleaning up state. Removing keys: configuration
    [R/T] ❯ Final memory usage: [step 55mb] [system 252mb]
    [319077:0x465c9000]   146339 ms: Scavenge 84.3 (86.7) -> 83.9 (86.7) MB, pooled: 2 MB, 0.27 / 0.00 ms  (average mu = 1.000, current mu = 1.000) allocation failure; 
    [319077:0x465c9000]   146339 ms: Scavenge 84.3 (86.7) -> 84.2 (88.2) MB, pooled: 0 MB, 0.14 / 0.00 ms  (average mu = 1.000, current mu = 1.000) allocation failure; 
    [319077:0x465c9000]   146340 ms: Scavenge 84.8 (88.2) -> 84.7 (87.7) MB, pooled: 1 MB, 0.11 / 0.00 ms  (average mu = 1.000, current mu = 1.000) allocation failure; 
    [319077:0x465c9000]   146341 ms: Scavenge 85.1 (87.7) -> 85.0 (89.2) MB, pooled: 0 MB, 0.26 / 0.00 ms  (average mu = 1.000, current mu = 1.000) allocation failure; 
    [319077:0x465c9000]   146341 ms: Scavenge 85.6 (89.2) -> 85.5 (88.7) MB, pooled: 1 MB, 0.24 / 0.00 ms  (average mu = 1.000, current mu = 1.000) allocation failure; 
    [319077:0x465c9000]   146342 ms: Scavenge 85.9 (88.7) -> 85.8 (92.2) MB, pooled: 0 MB, 0.46 / 0.00 ms  (average mu = 1.000, current mu = 1.000) allocation failure; 
    [319077:0x465c9000]   146342 ms: Scavenge 87.3 (92.2) -> 87.0 (92.5) MB, pooled: 0 MB, 0.28 / 0.00 ms  (average mu = 1.000, current mu = 1.000) allocation failure; 
    [319077:0x465c9000]   146343 ms: Scavenge 87.7 (92.5) -> 87.4 (98.0) MB, pooled: 0 MB, 0.82 / 0.00 ms  (average mu = 1.000, current mu = 1.000) allocation failure; 
    [319077:0x465c9000]   146345 ms: Scavenge 90.8 (98.0) -> 90.5 (98.5) MB, pooled: 0 MB, 0.77 / 0.00 ms  (average mu = 1.000, current mu = 1.000) allocation failure; 
    [319077:0x465c9000]   146348 ms: Scavenge 91.3 (98.5) -> 90.7 (109.7) MB, pooled: 0 MB, 1.77 / 0.00 ms  (average mu = 1.000, current mu = 1.000) allocation failure; 
    [319077:0x465c9000]   146357 ms: Scavenge (interleaved) 103.2 (119.3) -> 102.9 (120.0) MB, pooled: 0 MB, 0.99 / 0.00 ms  (average mu = 1.000, current mu = 1.000) allocation failure; 
    [319077:0x465c9000]   146365 ms: Mark-Compact 112.7 (129.6) -> 81.8 (119.9) MB, pooled: 0 MB, 4.38 / 0.01 ms  (+ 1.1 ms in 0 steps since start of marking, biggest step 0.0 ms, walltime since start of marking 17 ms) (average mu = 1.000, current mu = 1.000) finalize incremental marking via task; GC in old space requested
    [319077:0x465c9000]   146379 ms: Scavenge 103.0 (129.5) -> 102.3 (129.5) MB, pooled: 0 MB, 3.74 / 0.00 ms  (average mu = 1.000, current mu = 1.000) allocation failure; 
    [RTE] ✔ complete workflow  run-9
    [RTE] ❯ pool: finished task in worker 319201
    [SRV] ℹ run-9 :: run:log :: OK
    [SRV] ℹ run-9 :: run:log :: OK
    [SRV] ❯ [run:start] heap memory: 111.91
    [SRV] ℹ run-9 :: run:start :: OK
    [SRV] ℹ run-9 :: run:log :: OK
    [SRV] ℹ run-9 :: run:log :: OK
    [SRV] ℹ run-9 :: step:start :: OK
    [SRV] ℹ run-9 :: run:log :: OK
    [319077:0x465c9000]   154478 ms: Mark-Compact (reduce) 112.0 (139.0) -> 72.0 (76.7) MB, pooled: 0 MB, 4.95 / 0.00 ms  (+ 6.3 ms in 0 steps since start of marking, biggest step 0.0 ms, walltime since start of marking 12 ms) (average mu = 0.999, current mu = 0.999) finalize incremental marking via task; GC in old space requested
    [SRV] ℹ run-9 :: run:log :: OK
    [319077:0x465c9000]   155086 ms: Mark-Compact (reduce) 72.0 (76.7) -> 72.0 (75.9) MB, pooled: 0 MB, 1.76 / 0.00 ms  (+ 5.2 ms in 0 steps since start of marking, biggest step 0.0 ms, walltime since start of marking 8 ms) (average mu = 0.999, current mu = 0.989) finalize incremental marking via task; GC in old space requested
    [SRV] ℹ run-9 :: run:log :: OK
    // ...
    [SRV] ℹ run-9 :: run:log :: OK
    [319077:0x465c9000]   162250 ms: Scavenge 81.7 (85.5) -> 81.6 (86.5) MB, pooled: 0 MB, 0.35 / 0.00 ms  (average mu = 0.999, current mu = 0.989) allocation failure; 
    [319077:0x465c9000]   162271 ms: Mark-Compact 91.1 (96.0) -> 81.5 (88.5) MB, pooled: 0 MB, 2.43 / 0.00 ms  (+ 0.0 ms in 0 steps since start of marking, biggest step 0.0 ms, walltime since start of marking 21 ms) (average mu = 0.999, current mu = 1.000) finalize incremental marking via task; GC in old space requested
    [SRV] ℹ run-9 :: step:complete :: OK
    [SRV] ✔ (heap) duration: 396ms
    [SRV] ❯ workflow run-9 complete in 20.043s (81.64mb heap) releasing worker thread
    

    I suppose there are a lot of GC calls there, but when they're running so quickly I don't really think that's a problem?

  6. josephjclark commented on Sep 15, 2025

    @josephjclark
    CollaboratorAuthor

    Running some tests against the engine. If we run something multiple times, do we see a slow down? This bypasses most of the runtime and worker stuff and just looks at the core engine and how quickly it can spin up worker threads.

    Running a simple loop in nodejs shows a tiny variance over 500 iterations, less than 5ms between the fastests and slowest run.

    The same code in the engine - spinning up a worker thread each time - shows a bit more variance, around 20ms. It#s hard to see a pattern over 500 runs bit I think it's consistently quicker at the start and consistently slower at the end.

    Setting a timeout in the engine doesn't make much difference - the variance stays about the same.

    So maybe the engine runs a bit more slowly under load, after many iterations. And maybe if you thicken the runtime environment, with module loaders and stuff, that scales manifests as a slowing down.

    Maybe. Not actually sold on this.

  7. moved this from New Issues to In progress in Coreon Sep 16, 2025
  8. self-assigned this
    on Sep 16, 2025
  9. josephjclark commented on Sep 16, 2025

    @josephjclark
    CollaboratorAuthor

    Profiler for the main thread suggests its mostly idle. Top functions appear to be event messaging - parsing strings and pushing events through the socket.

    Image

    But yeah, looking at the trace, it's just waiting. What are we waiting for? Is this just debug stuff?

    No insight here yet.

  10. pinned this issue on Sep 16, 2025
  11. josephjclark commented on Sep 16, 2025

    @josephjclark
    CollaboratorAuthor

    Ok here's something:

    >> run-1: worker: join run channel: 3.229ms
    >> run-1: worker: execute prep: 1.516ms
    >> run-1: worker: complete: 804.562ms
    
    >> run-7: worker: join run channel: 592.244ms
    >> run-7: worker: execute prep: 1.26ms
    >> run-7: worker: complete: 13.683s
    

    The time it takes to join the run channel is increasing every time = from 3ms -> 600ms. Is every event slowing down that much?

    Is this happening in prod?? Is it related to the memory leak? Am I just seeing contention on the websocket?

  12. josephjclark commented on Sep 16, 2025

    @josephjclark
    CollaboratorAuthor

    Ok this is interesting: if I disable the log event - which is loud and spammy - the 10th run completes in half the time, and the memory seems to get reclaimed.

    So I could be looking at:

    • entirely local env issues with the websocket
    • a bug in the phoenix websocket - something about load/congestion?
    • a bug in the log event itself

    Updated phoenix js,restored logs and ran again. Still takes ages memory doesn't seem to recover.

  13. josephjclark commented on Sep 16, 2025

    @josephjclark
    CollaboratorAuthor

    Dang, it's feeling like a phoenix issue. If I do everything apart from send the log event to phoenix, runs are significantly more quick and memory is reclaimed after. When I sent the event - even with a redacted payload - we get significant slowdown and memory is locked out.

    Something about the socket performance under load?

  14. josephjclark commented on Sep 17, 2025

    @josephjclark
    CollaboratorAuthor

    Well maybe not. I've got a simple phoenix server with a websocket and I'm calling it from a standalone .mjs, sending in loads of data every 1ms. And the memory is absolutely fine - it spikes up and down but always settles.

    This is after 10k events

    all done
    idle | heap memory: 64.70
    idle | heap memory: 64.75
    idle | heap memory: 64.81
    idle | heap memory: 65.11
    idle | heap memory: 65.17
    idle | heap memory: 65.21
    idle | heap memory: 65.25
    idle | heap memory: 65.30
    idle | heap memory: 65.34
    idle | heap memory: 65.40
    idle | heap memory: 65.55
    idle | heap memory: 65.60
    idle | heap memory: 8.50
    

    Options from here? Could be a setting on the socket. Could be the transport (I'm pretty sure its just a standard WebSocket). I could be chasing a dev artifact.

    EDIT: I've matched the parameter and used the same ws transport (and the same version). All fine.

  15. josephjclark commented on Sep 19, 2025

    @josephjclark
    CollaboratorAuthor

    Might be getting onto something new.

    I've been bypassing the engine and trying to call the runtime directly from the worker. There's a lot of work involved in this because the events all need wiring up at least roughly to get a minimal run working.

    But very quicky, without even the logs hooked up, I'm seeing a memory leak when running just two runs through the worker back-to-back. Very strangely, it's like it's the second run that does it, even if there's a long delay between them. Maybe each successive run is adding a bit of memory that doesn't get reclaimed.

    Ah, this is just an artifact of using the runtime directly - I just wasn't running the cleanup code yet because some evens weren't wired up

    EDIT: Hmm. I've introduced a new problem here because I think each time I call the runtime, it increases my memory usage a little. It's not really a fair test: in the engine we burn the thread each time so we know that any runtime memory is reclaimed.

    In hmain thread, I think I'm seeing the memory creep up because of repeated runtime calls - but its irrelevant.

    Not sure if this thread is worth continuing.

  16. josephjclark commented on Sep 22, 2025

    @josephjclark
    CollaboratorAuthor

    I'm trying to use clinic.js to get better a visualisation of what's happening internally. I've created a standalone script, which is requires, but I'm getting burned right now by this open issue: clinicjs/node-clinic#444

    EDIT: Got a workaround using the awkward env var WORKER_DISABLE_EXIT_LISTENERS

    EDIT: First hit tells me that it's message parsing in the lightning mock chewing up the memory. Pretty dumb -I've taken that out of process in my script now.

    Second hit tells me that most memory is being used in:

    • parseChannelMessages -> that's the main worker thread loading large json files from the runs. Fine. Doesn't tell me anything about deallocation (the problem isn't that a lot of memory is used reading events - the problem is I don't seem to get the memory back)
    • loading ESM modules. I don't think this is hte soruceTextModule stuff used by the runtime - that's all happening in worker threads. So this must just be the memory overhead of loading the main thead VM. We can ignore that - unless this is weirdly high
    • The next highest memory functions are all module loading
    • Until we hit Typescript, which is the compiler (wait, is it? I think just for parsing adaptor adaptors - but no adaptors are in use in these jobs)

    The problem is that the heap profile tool is just telling me the biggest allocations (i'm still hazy on whether this is total or single shot). But my problem really is that the memory isn't freed up.

    The most obviously suspicious thing I see here is the compiler. Let me try and cut that out of the loop.

  17. josephjclark commented on Sep 23, 2025

    @josephjclark
    CollaboratorAuthor

    It's the bloody compiler!!!!

    I've just switched the compiler off my test case and memory comes right back down.

    So I need to work out why the compiler is leaking memory.

    I've also been thinking for ages that I'd love to run the compiler in a dedicated child process. Compilation of large files is likely to take a lot of memory and it would be good to insulate CPU and memory from the main worker thread while compilation runs. That doesn't really help the memory leak though because it'll still cause kubernetes to kill the pod if it chews up memory

  18. josephjclark commented on Sep 23, 2025

    @josephjclark
    CollaboratorAuthor

    Nope. Getting confused.

    • Running the compiler in isolation, it's quite happy reclaiming its memory (about 20mb for a small file)
    • Memory spikes a bit when calling preloadAdaptorExports, which loads the typescript engine from describe-adaptors
    • There is a cache in describe-adaptors, and in a long running processes that may well account for the memory leak
    • If I run my test script once, in total isolation, it currently seems to recover memory (with the compiler enabled). But if I re-run without running killall node -9, it does NOT seem to reclaim memory. What?!
    • This seems consistent: restarting the process seems to trigger the memory leak. Is this just local changes on my branch? Or is it telling me something profound? (EDIT: not consistent after all - could it literally be that the leak is random? Sometimes memory is reclaimed and sometimes it isn't?)

    So currently my local test basically isn't feeling very reliable and I can't account for that. Sometimes it's ok, sometimes it's not.

    Tempted just to go on and implement #1002 and just see what happens

  19. josephjclark commented on Sep 26, 2025

    @josephjclark
    CollaboratorAuthor

    I'm gonna declare this irrelevant now: if there IS still a memory leak, it does not affect the worker or the CLI.

  20. moved this from In progress to Done in Coreon Sep 26, 2025
  21. unpinned this issue on Oct 11, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

wontfixThis will not be worked on

Type

No type

Projects

  • Status
    Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions