Before the websockets were introduced, longpolling coroutines were sleeping until postgres notify. Each coroutine was then wake up and notifications were fetched. Since the websocket introduction, the main loop, responsible for listening to postgres sends the notifications itself. This means, the postgres loop is blocked during message fetch/dispatching and notifications are dispatched in a sequential fashion resulting in a slow message dispatching. In order to solve this issue, websocket coroutines are now responsible to fetch/dispatch notifications, letting the main loop free to relay notifications as they come and allowing notifications to be sent simultaneously. When instructed to dispatch available notifications, the websocket coroutines will try to acquire a cursor. Each coroutine will try up to `MAX_TRY_ON_POOL_ERROR` times, sleeping between each try. If no cursor can be acquired, the connection is closed with the TRY_LATER` close code. closes odoo/odoo#98880 Signed-off-by: Antony Lesuisse <al@odoo.com>
179 lines
6.3 KiB
Python
179 lines
6.3 KiB
Python
# -*- coding: utf-8 -*-
|
|
import datetime
|
|
import json
|
|
import logging
|
|
import random
|
|
import selectors
|
|
import threading
|
|
import time
|
|
|
|
import odoo
|
|
from odoo import api, fields, models
|
|
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
|
|
|
|
|
|
def channel_with_db(dbname, channel):
|
|
if isinstance(channel, models.Model):
|
|
return (dbname, channel._name, channel.id)
|
|
if isinstance(channel, str):
|
|
return (dbname, channel)
|
|
return channel
|
|
|
|
|
|
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()
|
|
values = []
|
|
for target, notification_type, message in notifications:
|
|
channel = channel_with_db(self.env.cr.dbname, target)
|
|
channels.add(channel)
|
|
values.append({
|
|
'channel': json_dump(channel),
|
|
'message': json_dump({
|
|
'type': notification_type,
|
|
'payload': message,
|
|
})
|
|
})
|
|
self.sudo().create(values)
|
|
if channels:
|
|
# We have to wait until the notifications are commited in database.
|
|
# When calling `NOTIFY imbus`, notifications will be fetched in the
|
|
# bus table. If the transaction is not commited yet, there will be
|
|
# nothing to fetch, and the websocket 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, notification_type, message):
|
|
self._sendmany([[channel, notification_type, message]])
|
|
|
|
@api.model
|
|
def _poll(self, channels, last=0):
|
|
# 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(channel_with_db(self.env.cr.dbname, 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'],
|
|
'message': json.loads(notif['message']),
|
|
})
|
|
return result
|
|
|
|
|
|
#----------------------------------------------------------
|
|
# Dispatcher
|
|
#----------------------------------------------------------
|
|
|
|
class BusSubscription:
|
|
def __init__(self, channels, last):
|
|
self.last_notification_id = last
|
|
self.channels = channels
|
|
|
|
|
|
class ImDispatch(threading.Thread):
|
|
def __init__(self):
|
|
super().__init__(daemon=True, name=f'{__name__}.Bus')
|
|
self._channels_to_ws = {}
|
|
|
|
def subscribe(self, channels, last, db, websocket):
|
|
"""
|
|
Subcribe to bus notifications. Every notification related to the
|
|
given channels will be sent through the websocket. If a subscription
|
|
is already present, overwrite it.
|
|
"""
|
|
channels = {hashable(channel_with_db(db, c)) for c in channels}
|
|
for channel in channels:
|
|
self._channels_to_ws.setdefault(channel, set()).add(websocket)
|
|
outdated_channels = websocket._channels - channels
|
|
self._clear_outdated_channels(websocket, outdated_channels)
|
|
websocket.subscribe(channels, last)
|
|
if not self.is_alive():
|
|
self.start()
|
|
|
|
def unsubscribe(self, websocket):
|
|
self._clear_outdated_channels(websocket, websocket._channels)
|
|
|
|
def _clear_outdated_channels(self, websocket, outdated_channels):
|
|
""" Remove channels from channel to websocket map. """
|
|
for channel in outdated_channels:
|
|
self._channels_to_ws[channel].remove(websocket)
|
|
if not self._channels_to_ws[channel]:
|
|
self._channels_to_ws.pop(channel)
|
|
|
|
def loop(self):
|
|
""" Dispatch postgres notifications to the relevant websockets """
|
|
_logger.info("Bus.loop listen imbus on db postgres")
|
|
with odoo.sql_db.db_connect('postgres').cursor() as cr, \
|
|
selectors.DefaultSelector() as sel:
|
|
cr.execute("listen imbus")
|
|
cr.commit()
|
|
conn = cr._cnx
|
|
sel.register(conn, selectors.EVENT_READ)
|
|
while True:
|
|
if sel.select(TIMEOUT):
|
|
conn.poll()
|
|
channels = []
|
|
while conn.notifies:
|
|
channels.extend(json.loads(conn.notifies.pop().payload))
|
|
# relay notifications to websockets that have
|
|
# subscribed to the corresponding channels.
|
|
websockets = set()
|
|
for channel in channels:
|
|
websockets.update(self._channels_to_ws.get(hashable(channel), []))
|
|
for websocket in websockets:
|
|
websocket.trigger_notification_dispatching()
|
|
|
|
def run(self):
|
|
while True:
|
|
try:
|
|
self.loop()
|
|
except Exception:
|
|
_logger.exception("Bus.loop error, sleep and retry")
|
|
time.sleep(TIMEOUT)
|
|
|
|
dispatch = None
|
|
if not odoo.multi_process or odoo.evented:
|
|
# We only use the event dispatcher in threaded and gevent mode
|
|
dispatch = ImDispatch()
|