Показаны сообщения с ярлыком web. Показать все сообщения
Показаны сообщения с ярлыком web. Показать все сообщения

3 мая 2012 г.

Automatic filtering in SQLAlchemy: motivation

Server side code of web project usually has 3 layers:

  • data classes mapped to relational database,
  • request handlers for each URL pattern,
  • templates used to render pages.

Simple request handlers contain code like the following:

item = session.query(Entry).get(item_id)
or
items = session.query(Entry)[:limit]

When Entry class has public attribute and objects should be shown when Entry.public is True only (the simplest example of publicity condition; in real life it might be composite and even involve related tables) we have to include this condition in queries:

item = session.query(Entry).filter_by(public=True, id=item_id).scalar()
or
items = session.query(Entry).filter_by(public=True)[:limit]

Note, that we already violate DRY principle (the same condition should be used every time we query Entry), but it’s still not problem. Now let’s add relation to some Child class that has similar condition for publicity. If we pass only item or items to template, we have to be careful using their data:

{% for child in item.children %}…{% endfor %}
must be replaced with
{% for child in item.children %}
{% if child.public %}…{% endif %}
{% endfor %}

In real life it becomes even more complex: a simple test for empty list is already not so simple. Do we have other options? Yes, we can pass each relation as separate variable and move filtering to the code. This will prevent mess in templates, but this won’t prevent us from using relations directly by mistake. Do you think this shouldn’t happen? We are lazy, and I doubt anybody will define separate variable for relation that doesn’t have publicity condition (yet). But life changes and eventually we might need this condition. Now one developer adds new field to the database, changes all related request handlers and (if he is a responsible person) even templates. Simultaneously (or even later, since people remember code patterns they often used) other person adds usage of this relation unfiltered in some other place and we have unpublished data leaked to public. International scandal, world war III begins (joke).

In fact, templates developer shouldn’t care about publicity of data. Unpublished data must not reach templates.
Constructing some data structures specially for templates leads to verbose request handler code instead for concise single line:
item = session.query(Entry).filter_by(public=True, id=item_id).scalar()
data = {‘id’: item.id,
        ‘title’: item.title,
        ‘date’: item.date,
        ‘body’: item.body}
data[‘children’] = children = []
for child in item.children:
    if not child.public:
        continue
    child_data = {‘id’: child.id,
                  ‘title’: child.title,
                  ‘data’: child.data,
                  ‘body’: child.body}
    if child.author and child.author.public:
        child_data[‘author’] = author = {‘id’: child.author.id,
                                         ‘name’: child.author.name}
        if child.author.company and child.author.company.public:
            author[‘company’] = {‘id’: child.author.company.id,
                                 ‘title’: child.author.company.title}

Here is statistics from one big project where I’m involved in development. The numbers below cover public segment only (internal services like editor interface are not included).

  • 458 templates
  • 6 databases with 210 tables
  • 135 mapped classes, 5 of them are bases for inheritance trees
  • Data for 63 mapped classes must not go to public unless some condition is met (15 of them indirectly through inheritance). Those are only conditions that can’t be applied when replicating data from internal segment to public without significant impact on performance (changing state field of parent object would trigger publication or deletion of a huge list of children; using publication time in future requires some scheduler to trigger publication), the rest is filtered out before reaching database for public sites.


Having we can’t change relations behavior in request handler (this breaks ORM’s single object for each identity rule) I see the following 2 ways to solve the problem:

  • define separate mapped classes for public site,
  • instruct session to filter all ORM queries.

Both ways have problems and require separate analysis.

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)
Надеюсь, назначение и работа методов понятна из названий и комментариев.
А какие способы используете вы для организации одновременного редактирования объектов?