4 мая 2010 г.

Блокировка объектов при редактировании в админке

Одна из недавних встреч питонеров (Moscow Python meetup) была посвещена теме NoSQL. Я отношу скептически к повсеместному переходу на NoSQL, но всё же нахожу ему применение в отдельных задачах. Так, на встрече я рассказал про блокирование редактируемых объектов на базе memcache. Проблема вполне типичная для всех редакторских интерфейсов в CMS. Один и тот же объект могут одновременно начать редактировать несколько пользователей, в этом случае правки одного из пользователей перетираются другим. Более того, иногда возникают ситуации, когда у одного пользователя оказываются открыты несколько окон редактирования одного объекта и он перетирает собственные изменения.
В моём варианте решения при открытии страницы редактирования берётся блокировка (если объект ещё не заблокирован), а затем со страницы периодически шлётся AJAX запрос на её обновление. Ответ может быть как успешный, так и нет, если другой редактор насильно перехватил блокировку. При завершении редактирования или уходе со страницы блокировка снимается. Кроме того, если блокировку не обновлять, то она через некоторое время протухает автоматически — это решает проблему снятия блокировки, хоть и с запозданием, при закрытии окна (падении браузера, выключении питания у компьютера и т.д.). Переменная edit_session необходима для решения проблемы нескольких открытых окон редактирования одного объекта, фактически она содержит идентификатор одного такого окна. Для обновления блокировки используются команды memcache gets и cas, чтобы обеспечить атомарность операций (исключить условие гонки). Ниже приведена серверная часть, слегка переписанная, чтобы оторвать от контекста нашего движка (функции и переменные сделаны глобальными).
import os, logging
from time import time

logger = logging.getLogger(__name__)

class LockError(Exception):
    def __str__(self):
        return 'Problems with object lock'

class LockedByOther(LockError):
    def __init__(self, user):
        LockError.__init__(self, user)
        self.user = user
    def __str__(self):
        return 'Object is already locked by user: %s (%s)' % \
                                (self.user.name, self.user.login)

class LockIsLost(LockError):
    def __str__(self):
        return 'The object lock is lost'

def create_lock(obj_key, user, force=False):
    '''Marks model object as editted. Returns edit session on success or
    raises exception. obj_key is global identifier of object. When force is
    True the current lock is ignored.'''
    CACHE.clear_cas()
    edit_session = os.urandom(5).encode('hex')
    value = dict(edit_session=edit_session,
                 user_id=user.id,
                 time=time())
    if force:
        if CACHE.set(obj_key, value, time=MODEL_LOCK_TIMEOUT):
            return edit_session
        else:
            raise LockError()
    for i in range(3):
        if CACHE.add(obj_key, value, time=MODEL_LOCK_TIMEOUT):
            return edit_session
        old_value = CACHE.gets(obj_key)
        if old_value is None:
            # Should try add() again
            continue
        if not old_value or time()-old_value['time'] > MODEL_LOCK_TIMEOUT:
            # Somebody's lock is already expired
            if CACHE.cas(obj_key, value, time=MODEL_LOCK_TIMEOUT):
                return edit_session
            else:
                continue
        # Somebody holds active lock, no farther attempts
        break
    else:
        logger.error('Failed to lock model object. Problem with memcached?')
        raise LockError()
    lock_user = get_user(id=old_value['user_id'])
    assert lock_user is not None
    raise LockedByOther(lock_user)

def update_lock(obj_key, user, edit_session):
    '''Updates model object lock as being active. Raises exception on
    error. obj_key is global identifier of object.'''
    CACHE.clear_cas()
    for i in range(3):
        old_value = CACHE.gets(obj_key)
        if not old_value:
            raise LockIsLost()
        elif old_value['edit_session']!=edit_session:
            lock_user = get_user(id=old_value['user_id'])
            assert lock_user is not None
            raise LockedByOther(lock_user)
        new_value = dict(edit_session=edit_session,
                         user_id=user.id,
                         time=time())
        if CACHE.cas(obj_key, new_value, time=MODEL_LOCK_TIMEOUT):
            return
    else:
        # No runtime error here since we want to give user a chance to
        # restore lock.
        raise LockIsLost()

def remove_lock(obj_key, edit_session):
    '''Removes lock for model object. obj_key is global identifier of
    object.'''
    CACHE.clear_cas()
    # We can't garuantee memcache's delete method will remove only our
    # lock, so we update the record with empty value and minimal (1 sec)
    # timeout.
    old_value = CACHE.gets(obj_key)
    # It's too late to do something in case of error, so we just ignore
    # returned value.
    if old_value and old_value['edit_session']==edit_session:
        CACHE.cas(obj_key, '', time=1)
Надеюсь, назначение и работа методов понятна из названий и комментариев.
А какие способы используете вы для организации одновременного редактирования объектов?

2 комментария:

Анонимный комментирует...

А можно пояснить, зачем три попытки поставить блокировку?

Unknown комментирует...

Данные в memcache не протухают мгновенно после истечения таймаута. Поэтому создание блокировки может идти по двум сценариям: коммандой add, если записи с данным ключём нет, или командой cas, если она есть, но "протухшая". В memcache пару операций можно сделать атомарной только по оптимистичному сценарию, то есть вторая операция может провалиться, если ситуация с момента выполнения первой изменилась. В таких случаях нужно повторить попытку.