3976a8ec6b27464bd64e93916821dbcb52cd7671
hiram
  Thu Apr 30 23:01:52 2026 -0700
this actually might be working all the way through now refs #31811

diff --git src/hg/utils/otto/userRequests/ottoRequest.py src/hg/utils/otto/userRequests/ottoRequest.py
index 2df6933c138..ebfb5dc8b68 100755
--- src/hg/utils/otto/userRequests/ottoRequest.py
+++ src/hg/utils/otto/userRequests/ottoRequest.py
@@ -1,41 +1,68 @@
 #!/usr/bin/env python3
 """ottoRequest.py - check ottoRequest table for pending requests
 and send email notification for each one found.
 
 Intended to run from cron.  Reads the notification email address and
 table name from an hg.conf file.  Uses hgsql for database access.
 
 Usage:
     ottoRequest.py [-c /path/to/hg.conf]
 
 Options:
     -c, --conf    Path to hg.conf  [default: /usr/local/apache/cgi-bin/hg.conf]
 """
 
 import argparse
+import fcntl
 import os
 import re
 import subprocess
 import sys
 
 NOTIFY_FROM = 'genome-www@soe.ucsc.edu'
 BCC_BY_TYPE = {
     'liftOver': 'chain-file-request-group@ucsc.edu',
     'assembly': 'genark-request-group@ucsc.edu',
 }
 
+scriptDir = os.path.dirname(os.path.abspath(__file__))
+lockPath  = os.path.join(scriptDir, "ottoRequest.lock")
+
+
+def acquireSingletonLock():
+    """Ensure only one instance of this script runs at a time.  Holds an
+    exclusive flock on lockPath for the lifetime of the process; the
+    kernel releases it on exit (including crash / kill -9), so no stale
+    lock cleanup is needed.  Returns the open file handle, which the
+    caller must keep alive."""
+    # "a+" opens read+write without truncating (and creates if missing),
+    # so a second instance that fails to lock doesn't wipe the running
+    # instance's PID from the file before exiting.
+    fh = open(lockPath, "a+")
+    try:
+        fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
+    except BlockingIOError:
+        sys.exit(0)
+    # we own the lock truncate and write our PID for information
+    fh.seek(0)
+    fh.truncate()
+    fh.write("%d\n" % os.getpid())
+    fh.flush()
+    return fh
+    ### FYI: can also see the locking process via: lsof ottoRequest.lock
+
 
 def parseHgConf(path):
     """Return a dict of key=value pairs from an hg.conf file.
     Handles 'include' directives with paths relative to the
     directory of the file containing the include."""
     conf = {}
     confDir = os.path.dirname(os.path.abspath(path))
     try:
         with open(path) as fh:
             for line in fh:
                 line = line.strip()
                 if not line or line.startswith('#'):
                     continue
                 if line.startswith('include '):
                     inclPath = line.split(None, 1)[1]
@@ -171,30 +198,33 @@
     if result.returncode != 0:
         print(f"Warning: sendmail failed: {result.stderr.strip()}",
               file=sys.stderr)
         return False
     return True
 
 
 def main():
     parser = argparse.ArgumentParser(
         description='Process pending ottoRequest entries.')
     parser.add_argument('-c', '--conf',
                         default='/usr/local/apache/cgi-bin/hg.conf',
                         help='Path to hg.conf [default: %(default)s]')
     args = parser.parse_args()
 
+    # bind to a local so the FD stays open for the lifetime of main()
+    _lock = acquireSingletonLock()  # noqa: F841
+
     conf = parseHgConf(args.conf)
 
     dbName = conf.get('central.db')
     if not dbName:
         sys.exit("Error: central.db not defined in config")
 
     table = conf.get('ottoTable', 'ottoRequest')
 
     # find pending requests
     sql = (f"SELECT id, requestType, fromDb, toDb, email, comment, "
            f"requestTime FROM {table} WHERE status = 0")
     pending = hgsqlQuery(dbName, sql)
 
     if not pending:
         return  # nothing to do -- silent for cron