* = auth_signup, calendar, im_livechat, snailmail_account, survey, test_mail,
web_editor, website_crm_iap_reveal, website_livechat
The aim of this PR is to improve/fix various flaws and limitation of the current
API, to make it easier to use and more efficient.
Notification are now defined with 3 distinct parts:
- the channel determines which client(s) should receive it
- the type determines how it should be handled
- the payload determines any extra information helpful for handling it
Channel
=======
Business code
-------------
- Record channel is introduced for ease of subscribing to and sending
notifications to specific partners, channels, documents, ...
- String channel is still supported (but it is converted internally to the tuple
channel).
- Tuple channel is still supported without any change (but should be avoided
whenever possible due to its complex syntax).
The channel is no longer sent to the client. When the channel was used for
business purpose, the information it contained has been moved into either the
new type, or the payload itself.
Technical note
--------------
All channels are now internally converted to the tuple (db, ...) channel, which
is necessary for the platform code (saas/sh).
Internally, the bus.bus table is not changed, type and payload are grouped
together into what was (and still is) called message.
Type
====
Type is introduced to uniformize the way notifications are sent and handled.
All existing notifications already had some kind of manually-built type in them.
This is now officially supported at the bus API.
In client code this will allow (to be done in future commits) to register one
handler per specific type, instead of having to iterate and to filter all
received notifications on every handler.
Payload
=======
Payload (ex message) did not change, it can still be anything depending on
business needs.
Few adaptations:
- When the type was included on the payload, the type has been moved to the new
type parameter.
- When the channel was used in business code, its data has been copied into the
payload.
task-1891151
closes odoo/odoo#79201
X-original-commit: 543af27c7d6836ffac9e80ff8490b6ddbd849221
Related: odoo/enterprise#21998
Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
227 lines
8.0 KiB
Python
227 lines
8.0 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
|
|
|
|
|
|
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`, 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, notification_type, message):
|
|
self._sendmany([[channel, notification_type, 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(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 ImDispatch(object):
|
|
def __init__(self):
|
|
self.channels = {}
|
|
self.started = False
|
|
|
|
def poll(self, dbname, channels, last, options=None, timeout=None):
|
|
channels = [channel_with_db(dbname, channel) for channel in channels]
|
|
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)
|