Uploaded image for project: 'MariaDB ColumnStore'
  1. MariaDB ColumnStore
  2. MCOL-6603

PrimProc hangs forever in exit() after the ExeMgr memory guard fires (CAL0044): static ThreadPool destructor joins a prune thread lost across fork()

    XMLWordPrintable

Details

    • Bug
    • Status: In Review (View Workflow)
    • Critical
    • Resolution: Unresolved
    • 23.02.24
    • None
    • PrimProc
    • None
    • Rocky Linux 8 (cgroup v1), MariaDB Enterprise Server 10.6.15-10, ColumnStore 23.02.24
    • 2026-11, 2026-12

    Description

      Summary

      When PrimProc's ExeMgr memory guard (RssMonFcn) decides RSS is too high, it logs CAL0044 ("Too much memory allocated!") and calls exit(1). That exit(1) never finishes. The process stays alive with its full RSS, so systemd never restarts it (Restart=on-failure never triggers).

      The effect in production: the node keeps thrashing until it is unresponsive or CMAPI fails it over. Queries on the other PMs wait on it indefinitely, which users see as stuck queries and an unresponsive node. In one of our runs, the node that took over the failed node's dbroot then hit the same guard and wedged too, until it was rebooted.

      The hang is deterministic and doesn't need memory pressure. Any exit() in the forked PrimProc child hits it. The quick repro below takes about 5 minutes on an idle node.

      Why it hangs

      All links point to tag 23.02.24. The same code is unchanged on stable-23.10 (ab3d31bd1, checked 2026-10-06).

      1. A namespace-scope static FEMsgHandler::threadPool is defined at femsghandler.cpp:28. Its constructor (threadpool.cpp:40) calls init(), which starts fPruneThread (threadpool.cpp:75). This happens during static initialization, before main().
      2. main() → ServicePrimProc::Run() → Service::RunForking() calls fork() (service.h:53). The systemd unit starts PrimProc without -f, so production always forks. Only the forking thread exists in the child, and the child is the real PrimProc. The prune thread is gone.
      3. Nothing re-creates it in the child: init() is private and only called from constructors, and there is no pthread_atfork handler anywhere in the tree.
      4. RssMonFcn::operator() calls exit(1) (rssmonfcn.cpp:43 when MaxPct >= 95, rssmonfcn.cpp:53 otherwise). exit() runs static destructors: ~ThreadPool() → stop() → fPruneThread->join() (threadpool.cpp:135). That is an untimed boost join waiting on a done flag that fork copied as false and that no thread in the child will ever set. It waits forever.

      This hazard is already known and worked around in one place. ServiceDMLProc::Parent() calls jobstepThreadPool.stop() with the comment "otherwise it would get stuck when trying to join fPruneThread" (dmlproc.cpp:116-124). That fix only covers DMLProc's parent side.

      How it got here

      Commit Date Change
      e2d66222c 2017-01-25 MCOL-513: FEMsgHandler::threadPool becomes a static
      94dfacfe2 2018-09-28 MCOL-1750: ThreadPool constructor starts fPruneThread
      ab44ef6dd 2020-10-07 MCOL-4170: adds Service::RunForking() / fork(). This introduced the hang.
      e17469635 2022-03-02 MCOL-5001: ExeMgr merged into PrimProc, which inherits both pools and both memory guards

      Fixing FEMsgHandler's pool alone isn't enough

      • A second pre-fork pool. JobStep::jobstepThreadPool (jobstep.cpp:61, in libjoblist.so, linked into PrimProc) is also a static with a prune thread started before the fork. Shared-library statics are destroyed last, so today exit() blocks on FEMsgHandler's pool first. Fix that one and it blocks on this one next.
      • stop() also joins busy workers. fThreads.join_all() (threadpool.cpp:138) waits for workers that may be mid-query. In our vmcore one FEMsgHandler worker was still in threadFcn.
      • Other exit() sites reachable in the PrimProc child should hang the same way:
      • Signal-driven exits are not affected, because crashtrace re-raises with SIG_DFL.

      How to reproduce (no data needed, about 5 minutes)

      Tested on Rocky 8 (cgroup v1), ES 10.6.15-10 / ColumnStore 23.02.24, idle PrimProc. This drives the real RssMonFcn code path with no debugger injection. fMemTotal comes from the cgroup named in SystemConfig/CGroup, so we give PrimProc a small cgroup limit and a 1% threshold, which makes the guard fire right at startup.

      # Back up the config first.
      sudo cp -p /etc/columnstore/Columnstore.xml /etc/columnstore/Columnstore.xml.pre-exittest
      sudo systemctl stop mcs-primproc
      F=/etc/columnstore/Columnstore.xml
       
      # 1. ExeMgr guard threshold to 1%. The section is ExeMgr1 (resourcemanager.cpp:59), not ExeMgr.
      sudo sed -i "0,/<ExeMgr1>/s|<ExeMgr1>|<ExeMgr1>\n        <MaxPct>1</MaxPct>|" $F
       
      # 2. Make CGroupConfigurator read the unit's cgroup, then cap it at 2 GB (runtime only).
      sudo sed -i "0,/<SystemConfig>/s|<SystemConfig>|<SystemConfig>\n        <CGroup>system.slice/mcs-primproc.service</CGroup>|" $F
      sudo systemctl set-property --runtime mcs-primproc MemoryLimit=2G
       
      # 3. Start PrimProc. Idle RSS (~300 MB) is ~14% of 2 GB, so the guard fires immediately.
      sudo systemctl start mcs-primproc
      P=$(systemctl show -p MainPID --value mcs-primproc); echo "PID=$P"
      sudo journalctl -u mcs-primproc -f    # wait for "Too much memory allocated!" + CAL0044, then Ctrl-C
       
      # 4. Observe: same PID minutes later and NRestarts=0. A real exit would have produced a new PID.
      systemctl show -p MainPID -p NRestarts mcs-primproc
      sudo eu-stack -p $P | grep -B3 -A8 __run_exit_handlers
       
      # 5. Clean up.
      sudo systemctl stop mcs-primproc
      sudo systemctl revert mcs-primproc && sudo systemctl daemon-reload
      sudo cp -p /etc/columnstore/Columnstore.xml.pre-exittest $F

      Expected: PrimProc exits after CAL0044, and systemd restarts it with a new PID.
      Actual: CAL0044 was logged at 23:00:46. At 23:05:45 the same PID was still alive with NRestarts=0. The exiting thread is parked here:

      #3  threadpool::ThreadPool::stop()
      #4  threadpool::ThreadPool::~ThreadPool()
      #5  __run_exit_handlers
      #6  exit
      #7  exemgr::RssMonFcn::operator()() const
      #8  std::thread::_State_impl<std::thread::_Invoker<std::tuple<exemgr::RssMonFcn> > >::_M_run()

      Full stack of every thread in the hung process: live-exittest-eustack.txt

      Confirmed under a real workload too

      We hit the same thing on a 3-node cluster (m5.4xlarge, 62 GiB, Rocky 8, CMAPI failover on, EFS shared storage) running a large daily aggregation with TotalUmMemory 60% and NumBlocksPct 30. About 27-30 minutes in, one node's swap fills, the ExeMgr guard logs CAL0044, and PrimProc fails to exit. We captured a full vmcore with sysrq-c 60 s after CAL0044 and extracted a PrimProc core from it (gdb excerpt: vmcore-run3-exit-thread-bt.txt):

      • The exiting LWP is in RssMonFcn → exit(1) → __run_exit_handlers → ~ThreadPool(this=FEMsgHandler::threadPool) → stop() → boost::thread::join (futex wait), with fStop == true.
      • The prune thread's boost thread_data has done == 0. Its pthread handle now belongs to a different live thread (the ExeMgr MessageQueueServer::accept loop): glibc reclaimed the pre-fork thread's stack and reused it. No thread runs pruneThread() for this pool.

      Related but separate: DistributedEngineComm::read has no timeout, so one hung PM stalls queries cluster-wide.

      Possible resolution (for the assigned engineer to decide)

      These are suggestions from the investigation. The right approach is up to the engineer who picks this up.

      • Narrow hotfix: call _exit() (or quick_exit() with no handlers registered) instead of exit() at the fatal sites listed above, so static destructors never run in the child. That avoids both pools and the join_all. Logging at those sites is a synchronous syslog call and cerr is unbuffered, so nothing should be lost.
      • Structural hardening: don't start threads in a static object's constructor. For example, start the prune thread lazily on the first invoke() (which would also cover jobstepThreadPool, shared by DMLProc/DDLProc), or construct these pools after the fork.
      • Probably not enough on its own: a pid check inside ThreadPool::stop(). It would fix the prune-thread join but not fThreads.join_all().

      Attachments

        Issue Links

          Activity

            People

              alexey.antipovsky Aleksei Antipovskii
              kyle.hutchinson Kyle Hutchinson
              Votes:
              0 Vote for this issue
              Watchers:
              3 Start watching this issue

              Dates

                Created:
                Updated:

                Time Tracking

                  Estimated:
                  Original Estimate - 15d 5h 2m
                  15d 5h 2m
                  Remaining:
                  Time Spent - 4d Remaining Estimate - 1d
                  1d
                  Logged:
                  Time Spent - 4d Remaining Estimate - 1d Time Not Required
                  4d

                  Git Integration

                    Error rendering 'com.xiplink.jira.git.jira_git_plugin:git-issue-webpanel'. Please contact your Jira administrators.