Add status command

issue10098
review347481002
This commit is contained in:
Cédric Krier
2021-03-27 19:42:53 +01:00
parent c848ca1d2d
commit 05137a72a4
12 changed files with 258 additions and 22 deletions
+1
View File
@@ -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
+84
View File
@@ -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)
+1
View File
@@ -111,6 +111,7 @@ setup(name=name,
'bin/trytond-console',
'bin/trytond-cron',
'bin/trytond-worker',
'bin/trytond-stat',
],
classifiers=[
'Development Status :: 5 - Production/Stable',
+6
View File
@@ -117,6 +117,12 @@ def get_parser_console():
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)
+8 -2
View File
@@ -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 urllib.parse
import logging
import os
import urllib.parse
from . import status
__all__ = ['config', 'get_hostname', 'get_port', 'split_netloc',
'parse_listen', 'parse_uri']
@@ -151,3 +154,6 @@ class TrytonConfigParser(configparser.ConfigParser):
config = TrytonConfigParser()
if os.path.basename(main.__file__) != 'trytond-stat':
status.start(config.get('database', 'path'))
+9 -6
View File
@@ -11,6 +11,7 @@ from trytond.model import (
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 @@ class Cron(DeactivableMixin, ModelSQL, ModelView):
])
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()
+1 -1
View File
@@ -163,7 +163,7 @@ def _dispatch(request, pool, *args, **kwargs):
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):
+8 -2
View File
@@ -135,11 +135,17 @@ class JSONRequest(Request):
@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:
+20
View File
@@ -30,6 +30,24 @@ class Request(_Request):
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 @@ class Request(_Request):
@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
+103
View File
@@ -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)
+9 -6
View File
@@ -13,6 +13,7 @@ from sql import Flavor
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 @@ def run_task(pool, task_id):
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 @@ def run_task(pool, task_id):
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 @@ def run_task(pool, task_id):
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)
+8 -5
View File
@@ -40,6 +40,7 @@ from trytond.config import config
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 @@ class TrytondWSGI(object):
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 @@ class TrytondWSGI(object):
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