Поиск в приложениях SharePoint. Часть 1.

Одна из наиболее часто возникающих задач при разработке порталов на SharePoint  - создание веб-части , отображающей ближайшие дни рождения пользователей.

В SharePoint 2010 есть служба профилей пользователей, которая хранит данные о пользователях, в том числе дни рождения, в масштабах фермы. Для отображения списка пользователей можно использовать веб-часть "Основные результаты поиска людей". Но если разместить веб-часть на форму, то она не выводит результатов.

image

Чтобы отобразить результаты необходимо "Добавить текст в запрос".

image

Так как используется поиск надо заранее выполнить обход и в службе профилей должны быть записи.

 

Веб-часть отображения результатов поиска наследует DataFormWebPart, поэтому можно настроить отображение в виде xslt. Но для этого надо отключить отображение по-умолчанию.

image

XSL для отображения:

<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:output method="xml" version="1.0" encoding="UTF-8" indent="yes"/>
<xsl:template match="/">
  <table>
    <tr>
      <th>
        User name
      </th>
      <th>
        Account name
      </th>
    </tr>
    <xsl:apply-templates select="All_Results/Result"/>
  </table>
</xsl:template>
  <xsl:template match="Result">
    <tr>
      <td>
        <xsl:value-of select="preferredname"/>
      </td>
      <td>
        <xsl:value-of select="accountname"/>
      </td>
    </tr>
  </xsl:template>
</xsl:stylesheet>

Веб-части поиска выводят управляемые свойства поиска, которые задаются в central administration. Чтобы выводить (и использовать в запросах) дату рождения необходимо создать управляемое свойство, которое отображается на свойство профиля.

image

image

После добавления свойства обязательно надо выполнить полный обход содержимого.

После обходи можно добавить элемент в свойства columns веб-части и дописать xslt для вывод нужного значения.

image

Даты в профиле пользователя вводятся в формате “Месяц День”, 2000 год дописывается сам (чтобы можно было 29 февраля ввести). При выводе вам надо самостоятельно форматировать даты.

Теперь остается две проблемы:

  • Сортировка результатов. По-умолчанию веб часть не поддерживает сортировку по произвольному полю.
  • Динамический запрос. В параметрах веб-части можно ввести только статическую строку и получить текущую дату нельзя.

Для того чтобы победить обе проблемы можно наследоваться от веб-части поиска, но веб-часть поиска людей sealed. Поэтому надо наследоваться от веб-части поиска контента.

Для этого нужно создать веб-часть в visual studio.

public class WebPart1 : CoreResultsWebPart
{
    protected override string DefaultSearchLocation
    {
        get
        {
            //For people search
            return "LocalPeopleSearchIndex";
        }
    }

    protected override void ConfigureDataSourceProperties()
    {
        base.ConfigureDataSourceProperties();

        var ds = this.DataSource as CoreResultsDatasource;
        ds.SortOrder.Clear();
        ds.SortOrder.Add("Birthday", Microsoft.Office.Server.Search.Query.SortDirection.Descending);
    }

    protected override void OnInit(EventArgs e)
    {
        base.OnInit(e);

        var today2000 = new DateTime(2000, DateTime.Today.Month, DateTime.Today.Day);           
        this.AppendedQuery = string.Format("Birthday>=\"{0}\" AND Birthday<\"{1}\" ", 
                                    today2000.ToShortDateString(),
                                    today2000.AddDays(3).ToShortDateString());
    }
}

Свойство AppendedQuery – это “Добавить текст в запрос” в интерфейсе.

Результат после размещения на форме и правки представления:

image

Заключение

Стандартные веб-части поиска – очень мощный механизм вывода данных. Если мощности стандартных веб-частей не хватает, то их очень легко расширить с помощью кода.



Миф о кастомизации страниц в SharePoint

Немного теории. В SharePoint есть виртуальная файловая система, которая содержится в контентной БД. Когда вы в farm solution помещаете в нее файл, с помощью модуля, то файл лежит на диске в {SharePointRoot}\TEMPLATE\Features, а контентной БД появляется ссылка на этот файл.

Таким образом экономится место в БД, ведь один и тот же файл может быть добавлен на несколько сайтов. Кроме того упрощается обновление – достаточно обновить файл на диске.

Такие файлы называются uncustomized, ghosted (в терминологии 2007 шарика) или просто некастомизироваными. Если же попробовать внести какое-либо изменение в файл в виртуальной файловой системе (!), то измененное состояние файла записывается в контентную БД и SharePoint перестает обращаться к диску за файлом. такие файлы называются customized, unghosted или кастомизированные.

Причем это относится не только к файлам в виртуальной ФС, но и к типам контента, шаблонам списков итд. Но об этом другой раз…

Миф

Кастомизированные страницы работают медленнее некастомизированных.

В сети довольно много рассуждений на эту тему, что для кастомизированных страниц не работают какие-то механизмы кеширования, требуется перекомпиляция каждый раз итд.

Я решил просто померить как изменяется скорость работы SharePoint от кастомизации страниц. Сделал простой веб-тест в Visual Studio, который обращается к странице по-умолчанию, а потом создал нагрузочный тест и замерял Page Response Time.

Вот график (кликабельно):

Центральный пик - сохранение кастомизированной страницы, слева - время ответда для некастомизированной старницы, справа - время ответа кастомизированной страницы. Среднее время ответа некастомизированной страницы – 1.7 сек, кастомизироанной – 1.9 сек. Разница около 10%. Если на странице будут “тяжелые” контролы, то разница станет еще менее заметной.

При кастомизации мастер страницы график еще интереснее:

Большой пик – кастомизация мастер-страницы. Справа пик поменьше – было сделано Revert To Definition для мастер-страницы. Получается результаты почти одинаковые, за исключением того, что для некастомизированного masterpage результаты гораздо менее стабильны.

Заключение

Использовать исключительно некастомизированные страницы имеет смысл только для hiload публичных сайтов. Хотя кто использует sharepoint для hiload сценариев???….



Оптимизация процессинга в Windows Azure. Часть 3.

В предыдущем посте я описал подход, позволяющий существенно сократить количество вызовов к azure storage, который может сэкономить много денег. Но тем не менее ваши воркеры продолжают поедать ваши деньги.

А нужны ли вообще воркеры?

Оказывается не нужны. Если у вас небольшое приложение и вы используете очереди для надежной (reliable) асинхронной обработки, причем сама обработка не требует больших вычислительных затрат, то вам и не нужны воркеры. Можете использовать пару методов ToObserver\ToObservable из предыдущего поста, а для оповещений обычный Subject<Unit>.

Отказываться  от очередей в данном случае не надо, так как при масштабировании работу подхватят воркеры.

Scale Down

Как вы уже могли догадаться возможность масштабировать “вниз” в облаке не менее важна, чем масштабирование “вверх”. С учетом всех ранее перечисленных подходов можно любое приложение развернуть на одном Extra Small Instance в Windows Azure за $30 и тысячей транзакций хранилища (меньше $0.01) в месяц, если к нему будет мало обращений. Это уже сопоставимо с ценой shared-хостинга.

На этом история scale down заканчивается и начинается история…

Scale Up\Out

Сразу же рекомендую посмотреть на Autoscale Application Block (кодовое имя WASABi) из комплекта Enterprise Library. Ссылка на Enterprise Library 5.0 Windows Azure Integration Pack. Этот модуль позволяет задавать правила в соответствии с которыми будет изменяться количество экземпляров ролей в вашем приложении.

Но количество ролей позволяет выдерживать вычислительную нагрузку, хотя в большинстве веб-приложений хранилище становится узким местом.

К сожалению Windows Azure тут не исключение. В блоге Windows Azure Storage описаны scalability targets. Вы можете обнаружить очень интересные сведения о том что максимальное количество сообщений очереди, обрабатываемых в секунду – 500 (по другим сведениям это количество транзакций в секунду). Это очень-очень  мало. И надо не забывать что это предельное значение, на практике его достигнуть будет непросто. Кроме того латентность очереди может достигать 100ms.

Первое что необходимо чтобы избежать высокой латентности на маленьких сообщениях в очереди - установить ServicePointManager.UseNagleAlgorithm значение false.

Следующая проблема – максимальный размер сообщения в очереди – 8KB, так как для передачи используется Base64 кодировка, то реально данных можно передать около 6KB, кстати строки по-умолчанию не кодируются. Добрые люди уже придумали как решать такую проблему: http://msdn.microsoft.com/en-us/library/windowsazure/hh690942(v=VS.103).aspx

Масштабирование воркеров

Как вы думаете что будет если взять “наивную” реализацию воркера, как в первом посте и запустить на Extra Large Instance, насколько быстрее будет работать?

На самом деле вообще не будет быстрее. С этой точки зрения большое количество маленьких воркеров лучше чем один большой. Хотя тоже не лучший вариант по словам представителей Microsoft. С другой стороны куча маленьких воркеров будут пинать Azure Storage гораздо чаще, что несомненно отразится на ценнике. Того же можно добиться если запустить вручную несколько потоков с наивным циклом в воркере, развернутом на Medium instance или более крутой машине.

Чтобы этого избежать надо использовать метод CloudQueue.GetMessages. Пример ниже показывает кусок кода для итератора, который потом обрабатывается Rx.

while (true)
{
    var msgsObs = getMessages(32).ToListObservable();
    yield return msgsObs;
    var msgs = msgsObs[0];

    var hasMessages = false;
    foreach (var msg in msgs)
    {
        hasMessages = true;
        idleCount = 0;

        result.OnNext(msg);
    }

    if (!hasMessages)
    {
        var delay = CalulateDelay(idleCount++, MinimumIdleIntervalMs, MaximumIdleIntervalMs, 100);
        if (delay.TotalMilliseconds >= MaximumIdleIntervalMs)
        {
            yield break;
        }

        yield return Observable.Timer(delay).ToListObservable();
    }
}

Обратите внимание что вызов OnNext должен быть упорядочен, чтобы не возникало Race Condition. Это требование указано в Rx Design Guidelines, и если вы его не читали, то крайне рекомендую это сделать.

Кроме того удаление сообщения из очереди в таком коде возлагается на внешний код.

Пример:

from m in queue.ToObservable(notifications)
from _1 in Observable.Start(() => /*work*/, Scheduler.TaskPool)
from _2 in queue.DeleteMessageAsync(m)
select Unit.Default;

Само это выражение не приводит ни к какому эффекту. Для него надо выполнить Subscribe чтобы запустить вычисления. Тогда будет использоваться TaskPool, который довольно эффективно распределяет вычисления по процессорам. Если вычисления длительные (более 10ms - 100ms), то лучше использовать Scheduler.NewThread. Если же у вас IO-bound код, то лучше будет использовать Scheduler.ThreadPool.

Подходы, описанные выше помогут выжать максимум из очереди Windows Azure, оптимально расходуя ресурсы виртуальных машин при этом. Но что делать когда код упрется в ограничение количества сообщений в секунду. Ни добавление воркеров, ни увеличение толщины инстансов не поможет.  В таком случае может помочь секционирование.

Вместо одной очереди вы создаете N очередей. При добавлении сообщения в очередь выбираете случайную. Считываете сразу из всех. Надо как-то разбираться из какой очереди пришло сообщение. Реализация такого нетривиальна и уже есть первый подобный проект на codeplex: http://partitioncloudqueue.codeplex.com/.

Но Rx как всегда рулит и с его помощью очень просто сделать такой partitioning.

На клиенте:

List<CloudQueue> queues = /*...*/;
var observers = queues.Select(q => q.ToObserver(/*notifier*/))
                      .ToList();

var rnd = new Random();
var partitionedObserver = Observer.Create<CloudQueueMessage>(
        m => observers[rnd.Next(observers.Count)].OnNext(m),
        e => observers.ForEach(obs => obs.OnError(e)),
        () => observers.ForEach(obs => obs.OnCompleted())
    );
partitionedObserver.OnNext(new CloudQueueMessage(/*message*/));

На сервере:

IObservable<Unit> ProcessMessages(CloudQueue queue, /*notifier*/, /*scheduler*/)
{
    return from m in queue.ToObservable(/*notifier*/)
           from _1 in Observable.Start(/*action*/, /*scheduler*/)
           from _2 in queue.DeleteMessageAsync(m)
           select Unit.Default;
}

/*.....*/

List<CloudQueue> queues = /*...*/;
queues.Select(q => ProcessMessages(q,/*notifier*/, /*scheduler*/))
      .Merge()
      .Subscribe();

Другой подход, позволяющий решить проблему ограничения на количество сообщений – пакетная передача. Вместо создания множества очередей, вы записываете множество сообщений в один пакет и предаете его. Для этих целей можно использовать CloudBlockBlob. Можно отдельными блоками загружать отдельные сообщения, а потом получить список блоков из блоба. В сообщении при этом передавать только url блоба.

Заключение

Все описанные выше способы помогут вам более эффективно реализовывать процессинг в Windows Azure. Для тех кто дочитал до сюда – сюрприз. Весь код с примерами использования есть на codeplex, а также библиотека для работы с очередями доступна в NuGet.



Оптимизация процессинга в Windows Azure. Часть 2.

В первой части я показал сколько стоит использование воркер-ролей и очередей в Windows Azure и что с этим можно сделать.

Довольной хороший подход – адаптировать интервал опроса новых сообщений и отключать опрос в случае их отсутствия продолжительное время. Но после выключения надо как-то включать.

Для этого был создан extension-метод:

public static IObservable<CloudQueueMessage> ToObservable<T>(
                                                        this CloudQueue queue, 
                                                        IObservable<T> haveMoreMessages)

Этот метод возвращает сообщения очереди в виде IObservable коллекции. Включение опроса осуществляется появлением элемента в последовательности haveMoreMessages.

Теперь о том как реализовать последовательность haveMoreMessages.

Самый дешевый вариант взаимодействия между экземплярами ролей это internal wcf communication. Для того чтобы работать с WCF необходимо определить контракты.

[ServiceContract]
public interface IQueueNotifier
{
    [OperationContract(IsOneWay = true)]
    void MessageAdded(string queueName);

    [OperationContract(IsOneWay = true)]
    void NoMoreMessages(string queueName);
}

Контракт содержит всего два метода оповещения о новом сообщении в очереди и об окончании сообщений.

Реализация тоже тривиальна:

public class QueueNotifier : IQueueNotifier
{
    private ISubject<string> moreMessages = new Subject<string>();
    private ISubject<string> queueCompleted = new Subject<string>();

    public IObservable<string> MoreMessages
    {
        get
        {
            return this.moreMessages;
        }
    }

    public IObservable<string> QueueCompleted
    {
        get
        {
            return this.queueCompleted;
        }
    }

    public void MessageAdded(string queueName)
    {
        moreMessages.OnNext(queueName);
    }

    public void NoMoreMessages(string queueName)
    {
        queueCompleted.OnNext(queueName);
    }
}

Далее комбинируя два потока получаем IObservable<Unit> пригодный для метода, описанного в начале поста.

public static IObservable<Unit> GetQueueNotifications(this QueueNotifier service, string queueName)
{
    return Observable.Create<Unit>(obs =>
    {
        var sub1 = service.MoreMessages
                          .Where(q => q == queueName)
                          .Subscribe(q => obs.OnNext(Unit.Default));

        var sub2 = service.QueueCompleted
                          .Where(q => q == queueName)
                          .Subscribe(q => obs.OnCompleted());

        return new CompositeDisposable(sub1, sub2);
    });
}

Теперь захостив QueueNotifier в воркере можно передавать ему оповещения из других ролей.

Клиентская сторона

Чтобы отправлять оповещения нужно создать ChannelFactory<IQueueNotifier> и получить экземпляр прокси на клиенте.

Далее надо получить IObserver:

public static IObserver<Unit> CreateQueueNotifierObserver(this IQueueNotifier proxy, string queueName)
{
    return Observer.Create<Unit>(
                _ => proxy.MessageAdded(queueName),
                _ => proxy.NoMoreMessages(queueName),
                () => proxy.NoMoreMessages(queueName)
            );
}

Надо помнить что экземпляров воркера может быть много и у вас получится по одному наблюдателю на каждый инстанс воркера. При этом не надо передавать оповещение каждому воркеру, достаточно передать оповещение одному (случайному). В случае высокой нагрузки оповещения будут распределяться равномерно межу воркерами и никто их них не будет “спать”. В случае низкой нагрузки просыпаться будет только один воркер, экономя деньги.

public static IObserver<Unit> CombineObservers(List<IObserver<Unit>> notifiers)
{
    var rnd = new Random();

    return Observer.Create<Unit>(
            u => notifiers[rnd.Next(notifiers.Count)].OnNext(u),
            e => notifiers.ForEach(obs => obs.OnError(e)),
            () => notifiers.ForEach(obs => obs.OnCompleted())
        );
}

Обратите внимание что OnCompleted рассылается всем воркерам, чтобы можно было остановить обработку сообщений.

Остается только скомбинировать отправку сообщения в очередь с отправкой оповещения.

public static IObserver<CloudQueueMessage> ToObserver(this CloudQueue queue, IObserver<Unit> notifier)
{
    var addMessage = Observable.FromAsyncPattern<CloudQueueMessage>(queue.BeginAddMessage, queue.EndAddMessage);

    return Observer.Create<CloudQueueMessage>(
            m => addMessage(m).Subscribe(notifier.OnNext, notifier.OnError),
            notifier.OnError,
            notifier.OnCompleted);
}

Таким образом получается достигнуть того что воркеры не обращаются постоянно к Azure Storage, экономя деньги и ресурсы виртуальных машин. При этом мы получили на клиенте и сервере очень простые интерфейсы, позволяющие тем не менее выполнять сложные действия  с ними.

В следующей части дальнейшая оптимизация, библиотека и пример приложения.



Оптимизация процессинга в Windows Azure. Часть 1.

Для тех кто не в курсе: Windows Azure – “облачная” платформа Microsoft. Создавая приложения, работающие “в облаке”, у вас есть возможность разделять систему на “роли”. Бывают веб-роли, которые представляют из себя обычные веб-приложения,  бывают также worker-роли (далее воркеры), предназначенные для вычислений.

Для увеличения масштабируемости приложения используется очереди. Сообщения в очередях обрабатываются воркерами, а ставят сообщения чаще всего веб-роли или другие воркеры. Таким образом можно разбить какие-либо длительные операции на небольшие и обрабатывать их асинхронно на любом количестве узлов, так как очереди в Windows Azure специально проектировали для сценария множественных потребителей.

Типовой код для воркера Windows Azure на C# такой:

while (true)
{
    var msg = queue.GetMessage();
    if (msg != null)
    {
        //do some work
        queue.DeleteMessage(msg);
    }
    else
    {
        Thread.Sleep(10000);
    }

    Trace.WriteLine("Working", "Information");
}

Как вы думаете сколько стоит этот воркер. В смысле реальных денег потребляемых таким приложением, развернутым на Windows Azure.

Для этого надо посмотреть цены: https://www.windowsazure.com/en-us/pricing/details/.

Если задеплоить такую роль в одном small экземпляре, то получится $2,88 в день/$86,4 в месяц/~2600 рублей в месяц. Так? А вот и нет…

Есть еще “скрытая” стоимость такой архитектуры, заключается она в том что транзакции к хранилищу тоже оплачиваются https://www.windowsazure.com/en-us/pricing/details/#storage. Всего  $0.01 за 10,000 транзакций. Каждая транзакция – это один запрос к azure storage.

Код выше выполняет один запрос каждые 10 секунд даже если нету никаких сообщений в очереди.
Стоимость такого кода получается 60*60*24*30/(10 * 1000) = $25,92 в месяц. вместе со стоимостью compute hours это выходит $112,32 в месяц. И это даже если код не выполняет никакой работы!

Кроме того SLA гарантирует работоспособность роли 99,95% только при наличии минимум двух инстансов, так что для устойчивости надо еще умножить цену на 2. Итого $250 в месяц.

Вывод

Архитектура, которую предлагает Microsoft для масштабирования довольно дорого стоит. Используйте код из примеров очень осторожно, он может увести ваш проект в большой минус.

Что делать?

Вариант первый – использовать service bus, в нем тоже есть очереди, но API позволяет в одной транзакции ожидать сообщения, а не сразу null возвращать при его отсутствии.

Вариант второй – использовать адаптивную подстройку интервала опроса очереди и выключать опрос в случае отсутствия сообщений.

Второй вариант кажется хорошей идеей так как позволяет масштабировать подход как “вниз”, так и “вверх”. Но тут возникает вопрос, а если мы прекратим прием сообщений, то как его потом возобновить? Видимо надо передать сообщение… Приходим снова к той же проблеме.

Но сигнал к “пробуждению” читателя сообщений можно передавать по более дешевому каналу, например через wcf internal endpoint.

Реализация

Чтобы абстрагироваться от всех деталей с сообщениями, таймаутами и каналами удобно использовать библиотеку Rx. Я использую Experimental версию так как в ней собрано много нужных комбинаторов.

Для начала надо вписать код в концепцию Rx. Длительные операции, вроде вызовов методов Cloud Storage и тайматуов сделать в виде IObservable.

public static IObservable<CloudQueueMessage> ObserveMessages(this CloudQueue queue)
{
    return Observable.Create<CloudQueueMessage>(obs => Iterator(obs, queue));
}

private static IEnumerable<IObservable<object>> Iterator(
                                                    IObserver<CloudQueueMessage> result, 
                                                    CloudQueue queue)
{
    //Observable queue.GetMessage
    var getMessage = Observable.FromAsyncPattern<CloudQueueMessage>(
                                        queue.BeginGetMessage,
                                        queue.EndGetMessage);
    //Observable queue.DeleteMessage
    var deleteMessage = Observable.FromAsyncPattern<CloudQueueMessage>(
                                        queue.BeginDeleteMessage,
                                        queue.EndDeleteMessage);

    while (true)
    {
        //var msg = queue.GetMessage();               
        var msgObs = getMessage().ToListObservable();
        yield return msgObs;
        var msg = msgObs[0];

        if (msg != null)
        {
            //do some work
            result.OnNext(msg);

            //queue.DeleteMessage(msg);                     
            yield return deleteMessage(msg).ToListObservable();
        }
        else
        {
            //Thread.Sleep(10000);
            //Same pattern as above
            yield return Observable.Timer(TimeSpan.FromSeconds(10))
                                   .ToListObservable();
        }

        Trace.WriteLine("Working", "Information");
    }
}

Теперь надо немного изменить код, сделав таймаут адаптивным.

Функция вычисления таймаута:

private static TimeSpan CalulateDelay(int idleCount, int minimumIdleIntervalMs,  int maximumIdleIntervalMs, int deltaBackoffMs)
{
    // Calculate a new sleep interval value that will follow a random exponential back-off curve.
    int delta = (int)((Math.Pow(2.0, (double)idleCount) - 1.0) * (new Random()).Next((int)(deltaBackoffMs * 0.8), (int)(deltaBackoffMs * 1.2)));
    int interval = Math.Min(minimumIdleIntervalMs + delta, maximumIdleIntervalMs);

    // Pass the calculated interval to the dequeue task to enable it to enter into a sleep state for the specified duration.
    return TimeSpan.FromMilliseconds((double)interval);            
}

Честно украдена отсюда.

Сам код воркера:

var idleCount = 0;
while (true)
{
    var msgObs = getMessage().ToListObservable();
    yield return msgObs;
    var msg = msgObs[0];

    if (msg != null)
    {
        idleCount = 0;

        //do some work
        result.OnNext(msg);

        yield return deleteMessage(msg).ToListObservable();
    }
    else
    {
        var delay = 
                CalulateDelay(idleCount++, 
                              MinimumIdleIntervalMs, 
                              MaximumIdleIntervalMs, 
                              100);
        if (delay.TotalMilliseconds >= MaximumIdleIntervalMs)
        {
            yield break;
        }

        yield return Observable.Timer(delay).ToListObservable();
    }
}

Выключать цикл опроса сообщений мы научились, теперь попробуем научиться его включать. Будем считать что “внешний раздражитель”, который будет будить цикл выборки сообщений, выглядит как IObservable<T>.

public static IObservable<CloudQueueMessage> ObserveMessages<T>(
                                                this CloudQueue queue, 
                                                IObservable<T> haveMoreMessages)
{
    var iterator = Observable.Create<CloudQueueMessage>(
                                  obs => Iterator(obs, queue));
    IDisposable subscription = null;

    return Observable.Create<CloudQueueMessage>(
        obs => haveMoreMessages.Subscribe(
            _ =>
            {
                if (subscription == null)
                {
                    subscription = iterator.Subscribe(
                                                obs.OnNext, 
                                                obs.OnError, 
                                                () => subscription = null);
                }
            }, 
            () => subscription.Dispose() ));
}

Код получился запутанный, но при некоторой сноровке читается очень хорошо.

На сегодня все. В следующей части я расскажу как сделать  пробуждение воркеров по сигналу и какими еще способами можно оптимизировать стоимость решения для Windows Azure.