changeset e49423e63825 in trytond:default
details: https://hg.tryton.org/trytond?cmd=changeset&node=e49423e63825
description:
        Add status command

        issue10098
        review347481002
diffstat:

 CHANGELOG                       |    1 +
 bin/trytond-stat                |   84 ++++++++++++++++++++++++++++++++
 setup.py                        |    1 +
 trytond/commandline.py          |    6 ++
 trytond/config.py               |   10 +++-
 trytond/ir/cron.py              |   15 +++--
 trytond/protocols/dispatcher.py |    2 +-
 trytond/protocols/jsonrpc.py    |   10 +++-
 trytond/protocols/wrappers.py   |   20 +++++++
 trytond/status.py               |  103 ++++++++++++++++++++++++++++++++++++++++
 trytond/worker.py               |   15 +++--
 trytond/wsgi.py                 |   13 +++-
 12 files changed, 258 insertions(+), 22 deletions(-)

diffs (456 lines):

diff -r 5d60412205c1 -r e49423e63825 CHANGELOG
--- a/CHANGELOG Sat Mar 27 17:17:00 2021 +0100
+++ b/CHANGELOG Sat Mar 27 19:42:53 2021 +0100
@@ -1,3 +1,4 @@
+* Add status command
 * Add document widget on form view
 * Add full text and similarity search and FullText field
 * Add URL type to icon
diff -r 5d60412205c1 -r e49423e63825 bin/trytond-stat
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/trytond-stat  Sat Mar 27 19:42:53 2021 +0100
@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+# This file is part of Tryton.  The COPYRIGHT file at the top level of
+# this repository contains the full copyright notices and license terms.
+import curses
+import datetime as dt
+import sys
+import os
+
+DIR = os.path.abspath(os.path.normpath(os.path.join(__file__,
+    '..', '..', 'trytond')))
+if os.path.isdir(DIR):
+    sys.path.insert(0, os.path.dirname(DIR))
+
+import trytond.commandline as commandline
+from trytond.config import config
+
+parser = commandline.get_parser_stat()
+options = parser.parse_args()
+config.update_etc(options.configfile)
+
+import trytond.status as status
+
+
+def main(stdscr):
+    global reverse
+    stdscr.nodelay(1)
+    reverse = True
+    processes = {}
+    status_pad = curses.newpad(1, 1)
+
+    def refresh_status():
+        now = dt.datetime.now()
+        height, width = stdscr.getmaxyx()
+
+        def expired(process):
+            return process['expire'] > now
+
+        def format(since, pid, request):
+            since = str(dt.timedelta(seconds=int(since)))
+            return f"{pid:>5} {since:>18} {request}"
+        status_pad.clear()
+        status = [format(*i) for i in sorted(
+                ((msg['since'], p['id'], msg['request'])
+                    for p in filter(expired, processes.values())
+                    for msg in p['status']),
+                reverse=reverse)]
+        prow = max(len(status), height - 2)
+        pcol = width - 2
+        status_pad.resize(prow + 2, pcol)
+        for i, line in enumerate(status, 1):
+            status_pad.addnstr(i, 1, line.ljust(pcol), pcol - 2)
+        status_pad.border(0)
+        status_pad.addnstr(
+            0, 0, ' {pid:>5} {since:>18} {request}'.format(
+                pid="pid",
+                since="TIME" + ('↑' if reverse else '↓'),
+                request="request"
+                ).upper().ljust(pcol),
+            pcol, curses.A_REVERSE)
+        status_pad.refresh(0, 0, 0, 0, height - 1, width)
+
+    def refresh():
+        global reverse
+        refresh_status()
+        stdscr.refresh()
+
+        key = stdscr.getch()
+        if key == ord('q'):
+            sys.exit()
+        elif key == ord('r'):
+            reverse = not reverse
+            refresh()
+
+    def update(data=None):
+        if data:
+            pid = data['id']
+            data['expire'] = dt.datetime.now() + dt.timedelta(seconds=10)
+            processes[pid] = data
+        refresh()
+    refresh()
+    status.listen(config.get('database', 'path'), update)
+
+
+curses.wrapper(main)
diff -r 5d60412205c1 -r e49423e63825 setup.py
--- a/setup.py  Sat Mar 27 17:17:00 2021 +0100
+++ b/setup.py  Sat Mar 27 19:42:53 2021 +0100
@@ -111,6 +111,7 @@
         'bin/trytond-console',
         'bin/trytond-cron',
         'bin/trytond-worker',
+        'bin/trytond-stat',
         ],
     classifiers=[
         'Development Status :: 5 - Production/Stable',
diff -r 5d60412205c1 -r e49423e63825 trytond/commandline.py
--- a/trytond/commandline.py    Sat Mar 27 17:17:00 2021 +0100
+++ b/trytond/commandline.py    Sat Mar 27 19:42:53 2021 +0100
@@ -117,6 +117,12 @@
     return parser
 
 
+def get_parser_stat():
+    parser = get_base_parser()
+    parser.epilog = "To exit press 'q', to inverse sort order press 'r'."
+    return parser
+
+
 def config_log(options):
     if options.logconf:
         logging.config.fileConfig(options.logconf)
diff -r 5d60412205c1 -r e49423e63825 trytond/config.py
--- a/trytond/config.py Sat Mar 27 17:17:00 2021 +0100
+++ b/trytond/config.py Sat Mar 27 19:42:53 2021 +0100
@@ -1,9 +1,12 @@
 # This file is part of Tryton.  The COPYRIGHT file at the top level of
 # this repository contains the full copyright notices and license terms.
-import os
+import __main__ as main
 import configparser
+import logging
+import os
 import urllib.parse
-import logging
+
+from . import status
 
 __all__ = ['config', 'get_hostname', 'get_port', 'split_netloc',
     'parse_listen', 'parse_uri']
@@ -151,3 +154,6 @@
 
 
 config = TrytonConfigParser()
+
+if os.path.basename(main.__file__) != 'trytond-stat':
+    status.start(config.get('database', 'path'))
diff -r 5d60412205c1 -r e49423e63825 trytond/ir/cron.py
--- a/trytond/ir/cron.py        Sat Mar 27 17:17:00 2021 +0100
+++ b/trytond/ir/cron.py        Sat Mar 27 19:42:53 2021 +0100
@@ -11,6 +11,7 @@
     ModelView, ModelSQL, DeactivableMixin, fields, dualmethod)
 from trytond.pool import Pool
 from trytond.pyson import Eval
+from trytond.status import processing
 from trytond.transaction import Transaction
 from trytond.worker import run_task
 
@@ -140,21 +141,23 @@
                     ])
 
             for cron in crons:
-                logger.info("Run cron %s", cron.id)
+                name = '<Cron %s@%s %s>' % (cron.id, db_name, cron.method)
+                logger.info("%s started", name)
                 for count in range(retry, -1, -1):
                     if count != retry:
                         time.sleep(0.02 * (retry - count))
                     try:
-                        cron.run_once()
-                        cron.next_call = cron.compute_next_call(now)
-                        cron.save()
-                        transaction.commit()
+                        with processing(name):
+                            cron.run_once()
+                            cron.next_call = cron.compute_next_call(now)
+                            cron.save()
+                            transaction.commit()
                     except Exception as e:
                         transaction.rollback()
                         if (isinstance(e, backend.DatabaseOperationalError)
                                 and count):
                             continue
-                        logger.error('Running cron %s', cron.id, exc_info=True)
+                        logger.error('%s failed', name, exc_info=True)
                     break
         while transaction.tasks:
             task_id = transaction.tasks.pop()
diff -r 5d60412205c1 -r e49423e63825 trytond/protocols/dispatcher.py
--- a/trytond/protocols/dispatcher.py   Sat Mar 27 17:17:00 2021 +0100
+++ b/trytond/protocols/dispatcher.py   Sat Mar 27 19:42:53 2021 +0100
@@ -163,7 +163,7 @@
         username = username.decode('utf-8')
     log_args = (
         obj, method, args, kwargs, username, request.remote_addr, request.path)
-    logger.info(log_message, *log_args)
+    logger.debug(log_message, *log_args)
 
     retry = config.getint('database', 'retry')
     for count in range(retry, -1, -1):
diff -r 5d60412205c1 -r e49423e63825 trytond/protocols/jsonrpc.py
--- a/trytond/protocols/jsonrpc.py      Sat Mar 27 17:17:00 2021 +0100
+++ b/trytond/protocols/jsonrpc.py      Sat Mar 27 19:42:53 2021 +0100
@@ -135,11 +135,17 @@
 
     @cached_property
     def rpc_method(self):
-        return self.parsed_data['method']
+        try:
+            return self.parsed_data['method']
+        except Exception:
+            pass
 
     @cached_property
     def rpc_params(self):
-        return self.parsed_data['params']
+        try:
+            return self.parsed_data['params']
+        except Exception:
+            pass
 
 
 class JSONProtocol:
diff -r 5d60412205c1 -r e49423e63825 trytond/protocols/wrappers.py
--- a/trytond/protocols/wrappers.py     Sat Mar 27 17:17:00 2021 +0100
+++ b/trytond/protocols/wrappers.py     Sat Mar 27 19:42:53 2021 +0100
@@ -30,6 +30,24 @@
 
     view_args = None
 
+    def __repr__(self):
+        args = []
+        try:
+            if self.url is None or isinstance(self.url, str):
+                url = self.url
+            else:
+                url = self.url.decode(self.url_charset)
+            auth = self.authorization
+            args.append("%s@%s" % (
+                    auth.get('userid', auth.username), self.remote_addr))
+            args.append("'%s'" % url)
+            args.append("[%s]" % self.method)
+            args.append("%s" % (self.rpc_method or ''))
+        except Exception:
+            args.append("(invalid WSGI environ)")
+        return "<%s %s>" % (
+            self.__class__.__name__, " ".join(filter(None, args)))
+
     @property
     def decoded_data(self):
         if self.content_encoding == 'gzip':
@@ -60,6 +78,8 @@
 
     @cached_property
     def user_id(self):
+        if not self.view_args:
+            return None
         database_name = self.view_args.get('database_name')
         if not database_name:
             return None
diff -r 5d60412205c1 -r e49423e63825 trytond/status.py
--- /dev/null   Thu Jan 01 00:00:00 1970 +0000
+++ b/trytond/status.py Sat Mar 27 19:42:53 2021 +0100
@@ -0,0 +1,103 @@
+# This file is part of Tryton.  The COPYRIGHT file at the top level of
+# this repository contains the full copyright notices and license terms.
+import json
+import logging
+import os
+import socket
+import threading
+import time
+from collections import namedtuple
+from contextlib import contextmanager
+
+status = dict()
+logger = logging.getLogger(__name__)
+address = 'trytond-stat.socket'
+
+Process = namedtuple('Process', ['start_time', 'request'])
+
+
+@contextmanager
+def processing(request):
+    process = Process(time.perf_counter(), request)
+    status[id(process)] = process
+    try:
+        yield
+    finally:
+        status.pop(id(process), None)
+
+
+def log():
+    msg = []
+    now = time.perf_counter()
+    for process in sorted(status.copy().values(), key=lambda p: p.start_time):
+        msg.append({
+                'since': now - process.start_time,
+                'request': str(process.request),
+                })
+    return {
+        'id': os.getpid(),
+        'status': msg,
+        }
+
+
+def dump(path):
+    sock = socket.socket(socket.AF_UNIX)
+    try:
+        try:
+            sock.connect(os.path.join(path, address))
+        except socket.error:
+            return False
+        try:
+            sock.sendall(json.dumps(log()).encode('utf-8'))
+        except socket.error:
+            pass
+    finally:
+        sock.close()
+    return True
+
+
+def dumper(path):
+    while True:
+        if dump(path):
+            time.sleep(5)
+        else:
+            time.sleep(60)
+
+
+def start(path):
+    threading.Thread(target=dumper, args=(path,), daemon=True).start()
+
+
+def listen(path, callback=None):
+    sock = socket.socket(socket.AF_UNIX)
+    socket_file = os.path.join(path, address)
+    try:
+        sock.bind(socket_file)
+        sock.listen(1)
+        sock.settimeout(5)
+        while True:
+            try:
+                connection, client_address = sock.accept()
+            except socket.timeout:
+                if callback:
+                    callback()
+                continue
+            try:
+                data = b''
+                while True:
+                    chunk = connection.recv(1024)
+                    if not chunk:
+                        break
+                    else:
+                        data += chunk
+                try:
+                    data = json.loads(data)
+                except ValueError:
+                    continue
+                if callback:
+                    callback(data)
+            finally:
+                connection.close()
+    finally:
+        sock.close()
+        os.unlink(socket_file)
diff -r 5d60412205c1 -r e49423e63825 trytond/worker.py
--- a/trytond/worker.py Sat Mar 27 17:17:00 2021 +0100
+++ b/trytond/worker.py Sat Mar 27 19:42:53 2021 +0100
@@ -13,6 +13,7 @@
 from trytond import backend
 from trytond.config import config
 from trytond.pool import Pool
+from trytond.status import processing
 from trytond.transaction import Transaction
 
 __all__ = ['work']
@@ -98,7 +99,8 @@
     if not isinstance(pool, Pool):
         pool = Pool(pool)
     Queue = pool.get('ir.queue')
-    logger.info('task "%d" started', task_id)
+    name = '<Task %s@%s>' % (task_id, pool.database_name)
+    logger.info('%s started', name)
     retry = config.getint('database', 'retry')
     try:
         for count in range(retry, -1, -1):
@@ -111,16 +113,17 @@
                     except ValueError:
                         # the task was rollbacked, nothing to do
                         break
-                    task.run()
+                    with processing(name):
+                        task.run()
                     break
                 except backend.DatabaseOperationalError:
                     if count:
                         transaction.rollback()
                         continue
                     raise
-        logger.info('task "%d" done', task_id)
+        logger.info('%s done', name)
     except backend.DatabaseOperationalError:
-        logger.info('task "%d" failed, retrying', task_id, exc_info=True)
+        logger.info('%s failed, retrying', name, exc_info=True)
         if not config.getboolean('queue', 'worker', default=False):
             time.sleep(0.02 * retry)
         try:
@@ -132,6 +135,6 @@
                 Queue.push(task.name, task.data, scheduled_at=scheduled_at)
         except Exception:
             logger.critical(
-                'rescheduling task "%d" failed', task_id, exc_info=True)
+                'rescheduling %s failed', name, exc_info=True)
     except Exception:
-        logger.critical('task "%d" failed', task_id, exc_info=True)
+        logger.critical('%s failed', name, exc_info=True)
diff -r 5d60412205c1 -r e49423e63825 trytond/wsgi.py
--- a/trytond/wsgi.py   Sat Mar 27 17:17:00 2021 +0100
+++ b/trytond/wsgi.py   Sat Mar 27 19:42:53 2021 +0100
@@ -40,6 +40,7 @@
 from trytond.protocols.wrappers import Request
 from trytond.protocols.jsonrpc import JSONProtocol
 from trytond.protocols.xmlrpc import XMLProtocol
+from trytond.status import processing
 from trytond.tools import resolve
 
 __all__ = ['TrytondWSGI', 'app']
@@ -163,6 +164,7 @@
                 break
         else:
             request = Request(environ)
+        logger.info('%s', request)
 
         origin = request.headers.get('Origin')
         origin_host = urllib.parse.urlparse(origin).netloc if origin else ''
@@ -178,11 +180,12 @@
             if not getattr(endpoint, 'allow_null_origin', False):
                 abort(HTTPStatus.FORBIDDEN)
 
-        data = self.dispatch_request(request)
-        if not isinstance(data, (Response, HTTPException)):
-            response = self.make_response(request, data)
-        else:
-            response = data
+        with processing(request):
+            data = self.dispatch_request(request)
+            if not isinstance(data, (Response, HTTPException)):
+                response = self.make_response(request, data)
+            else:
+                response = data
 
         if origin and isinstance(response, Response):
             response.headers['Access-Control-Allow-Origin'] = origin

Reply via email to