Start odoo in threading mode with bus installed. Login in the browser using any internal user. Make sure the browser call the /longpolling/poll uri. While the browser is waiting for a response, stop the server. The server takes up to 50 seconds to stop. When started in threading mode, a request to /longpolling/poll is served by a casual http thread. It searches for messages enqueued in the bus and returns them. If there are no message for the user in the queue yet, it creates a `threading.Event`, attach it to the user in a shared dictionnary and `wait()` on it with a timeout of 50 seconds (hardcoded value). When the bus thread (the one responsible to listen on the database) receives new messages, it `set()` the events which resume any http thread that was waiting. Because when we stop the server, there is no way to server new requests, there are no way new messages arrive in the bus. All the threads that were waiting for a new message will just wait until the event timeouts which slow down the shutdown of the server. Now we actively `set()` all events in order to resume all those workers when we stop the server. The `ImDispatch.poll` signature has been changed too so it is possible to change (via code) the hardcoded default. The function was using the object referenced by `TIMEOUT` at the time the function was defined, using `timeout None` then `if None: timeout=TIMEOUT` ensures we lookup the variable. closes odoo/odoo#64530 Signed-off-by: Julien Castiaux <Julien00859@users.noreply.github.com>
214 lines
7.4 KiB
Python
214 lines
7.4 KiB
Python
# -*- coding: utf-8 -*-
|
|
import datetime
|
|
import json
|
|
import logging
|
|
import random
|
|
import select
|
|
import threading
|
|
import time
|
|
|
|
import odoo
|
|
import odoo.service.server as servermod
|
|
from odoo import api, fields, models, SUPERUSER_ID
|
|
from odoo.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT
|
|
from odoo.tools import date_utils
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# longpolling timeout connection
|
|
TIMEOUT = 50
|
|
|
|
#----------------------------------------------------------
|
|
# Bus
|
|
#----------------------------------------------------------
|
|
def json_dump(v):
|
|
return json.dumps(v, separators=(',', ':'), default=date_utils.json_default)
|
|
|
|
def hashable(key):
|
|
if isinstance(key, list):
|
|
key = tuple(key)
|
|
return key
|
|
|
|
|
|
class ImBus(models.Model):
|
|
|
|
_name = 'bus.bus'
|
|
_description = 'Communication Bus'
|
|
|
|
channel = fields.Char('Channel')
|
|
message = fields.Char('Message')
|
|
|
|
@api.autovacuum
|
|
def _gc_messages(self):
|
|
timeout_ago = datetime.datetime.utcnow()-datetime.timedelta(seconds=TIMEOUT*2)
|
|
domain = [('create_date', '<', timeout_ago.strftime(DEFAULT_SERVER_DATETIME_FORMAT))]
|
|
return self.sudo().search(domain).unlink()
|
|
|
|
@api.model
|
|
def sendmany(self, notifications):
|
|
channels = set()
|
|
for channel, message in notifications:
|
|
channels.add(channel)
|
|
values = {
|
|
"channel": json_dump(channel),
|
|
"message": json_dump(message)
|
|
}
|
|
self.sudo().create(values)
|
|
if channels:
|
|
# We have to wait until the notifications are commited in database.
|
|
# When calling `NOTIFY imbus`, some concurrent threads will be
|
|
# awakened and will fetch the notification in the bus table. If the
|
|
# transaction is not commited yet, there will be nothing to fetch,
|
|
# and the longpolling will return no notification.
|
|
@self.env.cr.postcommit.add
|
|
def notify():
|
|
with odoo.sql_db.db_connect('postgres').cursor() as cr:
|
|
cr.execute("notify imbus, %s", (json_dump(list(channels)),))
|
|
|
|
@api.model
|
|
def sendone(self, channel, message):
|
|
self.sendmany([[channel, message]])
|
|
|
|
@api.model
|
|
def poll(self, channels, last=0, options=None):
|
|
if options is None:
|
|
options = {}
|
|
# first poll return the notification in the 'buffer'
|
|
if last == 0:
|
|
timeout_ago = datetime.datetime.utcnow()-datetime.timedelta(seconds=TIMEOUT)
|
|
domain = [('create_date', '>', timeout_ago.strftime(DEFAULT_SERVER_DATETIME_FORMAT))]
|
|
else: # else returns the unread notifications
|
|
domain = [('id', '>', last)]
|
|
channels = [json_dump(c) for c in channels]
|
|
domain.append(('channel', 'in', channels))
|
|
notifications = self.sudo().search_read(domain)
|
|
# list of notification to return
|
|
result = []
|
|
for notif in notifications:
|
|
result.append({
|
|
'id': notif['id'],
|
|
'channel': json.loads(notif['channel']),
|
|
'message': json.loads(notif['message']),
|
|
})
|
|
return result
|
|
|
|
|
|
#----------------------------------------------------------
|
|
# Dispatcher
|
|
#----------------------------------------------------------
|
|
class ImDispatch(object):
|
|
def __init__(self):
|
|
self.channels = {}
|
|
self.started = False
|
|
|
|
def poll(self, dbname, channels, last, options=None, timeout=None):
|
|
if timeout is None:
|
|
timeout = TIMEOUT
|
|
if options is None:
|
|
options = {}
|
|
# Dont hang ctrl-c for a poll request, we need to bypass private
|
|
# attribute access because we dont know before starting the thread that
|
|
# it will handle a longpolling request
|
|
if not odoo.evented:
|
|
current = threading.current_thread()
|
|
current._daemonic = True
|
|
# rename the thread to avoid tests waiting for a longpolling
|
|
current.setName("openerp.longpolling.request.%s" % current.ident)
|
|
|
|
registry = odoo.registry(dbname)
|
|
|
|
# immediatly returns if past notifications exist
|
|
with registry.cursor() as cr:
|
|
env = api.Environment(cr, SUPERUSER_ID, {})
|
|
notifications = env['bus.bus'].poll(channels, last, options)
|
|
|
|
# immediatly returns in peek mode
|
|
if options.get('peek'):
|
|
return dict(notifications=notifications, channels=channels)
|
|
|
|
# or wait for future ones
|
|
if not notifications:
|
|
if not self.started:
|
|
# Lazy start of events listener
|
|
self.start()
|
|
|
|
event = self.Event()
|
|
for channel in channels:
|
|
self.channels.setdefault(hashable(channel), set()).add(event)
|
|
try:
|
|
event.wait(timeout=timeout)
|
|
with registry.cursor() as cr:
|
|
env = api.Environment(cr, SUPERUSER_ID, {})
|
|
notifications = env['bus.bus'].poll(channels, last, options)
|
|
except Exception:
|
|
# timeout
|
|
pass
|
|
finally:
|
|
# gc pointers to event
|
|
for channel in channels:
|
|
channel_events = self.channels.get(hashable(channel))
|
|
if channel_events and event in channel_events:
|
|
channel_events.remove(event)
|
|
return notifications
|
|
|
|
def loop(self):
|
|
""" Dispatch postgres notifications to the relevant polling threads/greenlets """
|
|
_logger.info("Bus.loop listen imbus on db postgres")
|
|
with odoo.sql_db.db_connect('postgres').cursor() as cr:
|
|
conn = cr._cnx
|
|
cr.execute("listen imbus")
|
|
cr.commit();
|
|
while True:
|
|
if select.select([conn], [], [], TIMEOUT) == ([], [], []):
|
|
pass
|
|
else:
|
|
conn.poll()
|
|
channels = []
|
|
while conn.notifies:
|
|
channels.extend(json.loads(conn.notifies.pop().payload))
|
|
# dispatch to local threads/greenlets
|
|
events = set()
|
|
for channel in channels:
|
|
events.update(self.channels.pop(hashable(channel), set()))
|
|
for event in events:
|
|
event.set()
|
|
|
|
def wakeup_workers(self):
|
|
"""
|
|
Wake up all http workers that are waiting for an event, useful
|
|
on server shutdown when they can't reveive anymore messages.
|
|
"""
|
|
for events in self.channels.values():
|
|
for event in events:
|
|
event.set()
|
|
|
|
def run(self):
|
|
while True:
|
|
try:
|
|
self.loop()
|
|
except Exception as e:
|
|
_logger.exception("Bus.loop error, sleep and retry")
|
|
time.sleep(TIMEOUT)
|
|
|
|
def start(self):
|
|
if odoo.evented:
|
|
# gevent mode
|
|
import gevent
|
|
self.Event = gevent.event.Event
|
|
gevent.spawn(self.run)
|
|
else:
|
|
# threaded mode
|
|
self.Event = threading.Event
|
|
t = threading.Thread(name="%s.Bus" % __name__, target=self.run)
|
|
t.daemon = True
|
|
t.start()
|
|
self.started = True
|
|
return self
|
|
|
|
dispatch = None
|
|
if not odoo.multi_process or odoo.evented:
|
|
# We only use the event dispatcher in threaded and gevent mode
|
|
dispatch = ImDispatch()
|
|
if servermod.server:
|
|
servermod.server.on_stop(dispatch.wakeup_workers)
|