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

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

Раньше бэкенд календарей работал на Microsoft Exchange. Но летом 2023 года мы задумались над тем, чтобы перейти на свой бэкенд. В этой статье я поделюсь причинами перехода, решением возникших проблем и опытом бета-тестирования с помощью фича-флагов. 

Почему вообще решили перейти на новый бэк

Главная причина – это нестабильность времени ответов от Exchange. Он мог отвечать на запрос календарей как 300-700 мс, так и несколько секунд. В офисах с большим количеством переговорок нестабильность чувствовалась сильнее: время ответа превышало 5 секунд. Тормознутость сказывалась и на пользовательском опыте, и на нас, так как метрики p95 времени ответов начинали плясать только из-за этих эндпоинтов.

Вот как это было на Exchange:

И как стало после перехода:

Редкие всплески есть, но чаще всего они связаны с релизами, когда перекатывается АПИ.

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

Старый бэк:

Новый бэк (другой масштаб):

Еще одна причина перехода – до нового бэкенда в нашей внутренней соцсети календари «умирали» вместе с Exchange. Сейчас же большинство пользователей даже не замечает, если были какие-то проблемы. 

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

Проблемы, с которыми мы столкнулись

Проблем было достаточно и с клиентской библиотекой, и с кейсами использования Exchange, и с работой самого Exchange. Я расскажу об основных, которые больше всего запомнились. Разделю проблемы на две категории: до бета-тестирования и во время. Кстати бета-тестировали мы фича-флагами, о чем тоже подробно расскажу.

До бета-тестирования

Путаница с идентификаторами встреч

Можно выделить три основных идентификатора встреч:

  1. UniqueId – уникальный идентификатор встречи в календаре пользователя. У каждого участника одной встречи он будет разный.

  2. StorageUniqueId (ICalUid) – уникальный идентификатор встречи, общий для всех участников. Также он общий и у всех экземпляров регулярной встречи.

  3. ID встречи, получаемый из GetUserAvailability, который достает базовую информацию по «занятости». То есть, при закрытом календаре ID дает только факт того, что в такое-то время занят слот. В зависимости от типа приватности конкретного календаря может предоставится и тема встречи.

Что же выбрать?

По ID встречи (из п.3) можно получить полную информацию по встрече, если календарь закрытый или открытый. Но если встреча частная, он может не прийти, либо вообще ничего не вернется. А если это ID от экземпляра регулярной встречи, то при попытке получить полную информацию о встрече, Exchange может отдать случайную встречу из регулярного ряда.

StorageUniqueID (из п.2) выглядит заманчивым, ведь через него можно получать нужную встречу в календаре организатора, а значит и актуальное состояние. Но, оказалось, штатно нельзя получить встречу по этому ID. В клиенте Microsoft.Exchange.WebSevices даже нет такого Property. Поэтому, чтобы получать встречу по этому ID, пришлось искать на просторах StackOverflow рецепт объявления ExtendedPropertyDefiniton в бинарном формате для возможности поиска встреч по StorageUniqueID. Стандартного решения у них в клиенте не было.

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

Сложности в C# библиотеке Microsoft.Exchange.WebSevices

Microsoft неохотно публикует клиентские библиотеки к своим продуктам. Либо они есть, но не развиваются, либо реализация оставляет желать лучшего. В нашем случае библиотека есть, но давно не развивается и работает только по .NET 4. На github есть адаптированные под NetStandard форки. Они особо далеко не ушли, зато их можно использовать и в Linux.

Так вот, при релизе в прод к нам пришли из Отдела системного администрирования с фразой: «Вы DDOS'ите нам DNS'ы!»

Что произошло: коннектор слушает события календарей всех пользователей и делает много запросов, но на общем фоне нагрузки на Exchange это не много. При просмотре, какие домены резолвились, видно как и запросы на резолв самого Exchange, так и запросы к SRV записям kerberos. Начал разбираться с запросами SRV – доля в паразитном трафике у резолвов _tcp.kerberos  и _udp.kerberos была 90%. Авторизация в Exchange по АПИ идет с использованием NTLMv2 и Kerberos, а чтобы из Linux контейнера наше приложение могло авторизоваться в Exchange, используется gss-ntlmssp. Взяв strace, пошел смотреть, что происходит под капотом.  

По результатам strace стало видно, как клиент Exchange (а точнее HttpClient в dotnet) использует либу gss-ntlmssp. Делается сначала resolve SRV записей (tcp и udp), чтобы узнать, где там kerberos. Затем у kerberos креды обменивались на тикет, и он уже использовался при обращении к Exchange. Но этот тикет нигде не сохранялся – непонятно только почему, ведь gss-ntlmssp может брать уже выпущенные тикеты (к примеру, через kinit). В итоге, каждый запрос в Exchange оборачивался несколькими запросами к DNS, запросом за тикетом и уже в конце делался запрос в Exchange.

Вот что показал strace после фильтраций
Вот что показал strace после фильтраций

Решилось все просто – при запуске контейнера сейчас сразу получается тикет kerberos через kinit. И запросы за тикетом и к DNS за kerberos исчезли. 

В общем, библиотека проектировалась для работы под Windows –  у него есть локальный кеш DNS и на винде таких проблем нет, да и kerberos тикет в системе есть почти всегда. Но вот на Linux в контейнерах каждый запрос приводил к резолву DNS имени Exchange сервера. А все потому, что там для каждого запроса создается HttpClientHandler, а кеш резолва обычно хранится в нем. Так как он каждый раз новый, вот и источник постоянных резолвов. 

Решили сделать самый простой и топорный фикс: сделали в клиентской библиотеке кеширующую обертку для HttpClientHandler, которая сама резолвит DNS имена, записывает их в кеш на N времени. Потом при запросе берет случайный адрес из тех, что закешированы для этого DNS имени, и подставляет его. Это решило последнюю проблему с DDOS'ом DNS резолверов внутри сети. 

Ограничение количества одновременных запросов под пользователем

Еще на стадии тестирования прилетела интересная ошибка: 

ErrorExceededConnectionCount: You have exceeded the available concurrent connections for your account. Try again once your other requests have completed. 

Оказалось, в Exchange есть ограничение на количество одновременных запросов для пользователя (EWSMaxConcurrency). 

У нас установлено ограничение на пользователя - максимум 27 одновременных запросов. Поэтому решили не создавать каждый раз контекст с клиентом для пользователя, а сделать Pool с ограничением максимального количества клиентов. У пользователя теперь может создаваться и использоваться не более 5 контекстов => максимум 5 параллельных запросов. 

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

Во время бета-тестирования

Распространить такое масштабное обновление в подсистеме календарей – дело сложное. Понадобилось:

  1. Найти добровольцев для участия в бета-тесте. Мы просто позвали в сообщество бета-тестеров. Откликнулось почти 500 человек!

  2. Сделать процесс тестирования удобным. Значит, нужно запускать бету на проде.

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

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

  5. После раскатки на всю компанию иметь рубильник для переключения на старый бэк в любой момент.

Все эти тезисы приводили к мысли – нам нужны фича-флаги! 

Поэтому одним из первых этапов работы над новым бэкендом календарей стало создание и внедрение подсистемы фича-флагов. Как показала практика, бета-сообщество и изменение интерфейса (прокачка, обновление и наращивание функционала) через фича-флаги делает разработку новых фич более плавной и с гораздо меньшим количеством багов. А еще пользователи ощущают свой вклад, так как мы улучшали инструменты именно на основе их обратной связи.

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

И подробнее об этих проблемах.

Нужны Watermarks, чтобы не пропускать события при релизах

В процессе обкатки в ОБТ возник вопрос, что делать с релизами или перезапусками коннекторов. Ведь при перезапуске нужно 8 минут, чтобы переподписаться на все 12 000 календарей. Ускорить время – больно, как для тредпула коннектора, так и для Exchange. Поэтому был настроен троттлинг подписок небольшими порциями.

Фортуна мне улыбнулась! Бесцельно блуждая по документации Exchange во втором часу ночи, я совершенно случайно нашел нужный мне функционал – возможность закладок в ленте событий. Вот только она называется не Offset, не bookmark, не id… Microsoft назвал это Watermark. 

Этот Watermark указывает на место в подписке на события, до которого они были прочитаны. И если после перезапуска создать подписку с указанием последнего Watermark, то мы продолжим читать события, на которых остановились. 

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

В общем, решили, что сойдет. Сделали сохранение в MongoDB этого Watermark для каждого пользователя и выпустили. Эффект получился нужный – теперь падения коннектора и релизы не приводили к потере событий, можно было релизить в рабочее время. Но появился сайд эффект: утилизация диска в кластере монги начала иногда прыгать до 100%. А раз кластер монги коммунальный, то мы принесли серьезную нагрузку, делая 12к обновлений в минуту. 

Пришлось поумерить пыл. Решили вместо mongo воспользоваться кластером Redis. После тестирования Redis симуляцией нагрузки Watermark в большем объеме, он не заметил этой нагрузки. Следующим же ночным релизом мы переключились на Redis.

Пропадающие подписки и watermarks

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

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

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

Регулярные встречи – это боль

Регулярные встречи – очень интересная сущность. Ведь по факту экземпляры регулярных встреч хоть и «существуют», но по большей части они виртуальные (по крайней мере, они такими ощущаются). 

Интересные факты о регулярных встречах:

  • События по изменениям и отменам экземпляров регулярной встречи (occurrence) происходят через базовую встречу. То есть, чтобы понять, что именно произошло, нужно сравнить изменения базовой встречи. И если их нет, то получить базовую встречу с полями ModifiedOccurences и DeletedOccurences и уже смотреть, обновилось или добавилось ли что-то в них.

  • Нельзя просто взять и получить список всех экземпляров регулярной встречи по порядку (с начала и до конца или до бесконечности). Можно только проитерироваться, получая их последовательно. И самое важное, первый элемент не с индексом 0, а с индексом 1.

  • Мы не знаем, сколько экземпляров встреч в регулярном ряде. Поэтому нужно просто пытаться получить столько экземпляров, сколько нам нужно, то есть итерироваться по одной до нужного количества. Либо пока не получим ошибку ErrorCalendarOccurrenceIndexIsOutOfRecurrenceRange, которая говорит, что мы дошли до конца.

  • Нельзя понять, каких встреч там уже нет (к примеру какие-то экземпляры отменяли), мы можем только обработать ошибку ErrorCalendarOccurrenceIsDeletedFromRecurrence и идти дальше.

  • Нельзя просто из календаря получить экземпляр регулярной встречи и узнать, какая она по порядку в регулярном ряде. Такой возможности нет. Только если самому не рассчитывать на основе RecurringPattern, в какие даты должны быть встречи, и не посчитать, какая это по счету на основе рассчитанных дат.

И многие другие интересные факты, которые уже забылись…  

Не все удаления встреч – это событие Delete

При написании коннектора выяснилось, что не всегда удаление встреч идет с событием удаления (Deleted). Например, OWA/Outlook, любят удалять, просто делая Move в папку корзины. 

И вроде все понятно – раз удаления могут быть еще и через перемещение в корзину, тогда слушаем все события Move. И если NewFolderId == TrashFolderId, тогда считаем, что это удаление, и обрабатываем его соответственно. Но в процессе бета-теста выяснилось следующее: не все события Move в корзину – это удаления! 

К примеру, автоматизация обработки встреч для переговорок (ClaendarProcessing), в особенности с ручным одобрением встреч, иногда просто перекидывало встречу в корзину, но не отменяла ее. Неожиданное поведение испортило нам график занятости переговорок, но не критично (бета-тест же). Добавили проверку подглядыванием в календарь на это время. И если встреча в календаре в Exchange всё еще есть, значит, тревога ложная, и можно пропустить событие. После исправления, проблемы с исчезанием прекратились. Но осадочек на поведение остался.

Итоги

Даже после релиза на всю компанию остались мелкие доработки. Например, еще доделываем обработку регулярных встреч. Она у нас не идеальна, но 90% кейсов закрывает. Но, чую, это не конец списка интересностей в работе с регулярными встречами… С удалением встреч и Deleted тоже порой случаются разовые баги у отдельных пользователей, но восстановить картину целиком пока не получается. 

Результаты переключения 100% сотрудников на новый бэк «на лицо»:

Поэтому переводом на новый бэк мы довольны.

P.S.: Я сделал работу календарей настолько быстрой, что пользователи не успевали нормально увидеть лоадер при загрузке таймлайна своего календаря. Таймлайн мелькал и они думали, что это баг :)

Комментарии (5)


  1. ildarz
    13.08.2024 08:22
    +5

    А что конкретно сделали-то? :) Как общая архитектура выглядела "до" и "после"? Из описания у меня создается впечатление, что вы написали некий "бэкэнд" (для чего?), который работает быстрее, чем Exchange, но при этом сам работает через Exchange, т.е. по сути является оптимизирующим работу прокси.


    1. AMEST Автор
      13.08.2024 08:22
      +1

      Привет!) Да, это забыли написать в статье)
      Мы написали внутреннюю реализацию календаря, которая полностью обслуживает запросы пользователей и интеграции из других внутренних сервисов или пользовательских скриптов.

      Внутренний календарь синхронизируется через коннектор с Exchange через события. Коннектор слушает события происходящие в Exchange и в нашем календаре и пересылает их между системами.

      Это позволило увеличить производительность и стабильность календарей и актуальность отображения информации по встречам для всех участников; Пользователи не замечают проблем в случае неисправности с Exchange (к примеру сегодня одна нода EWS приболела и часть запросов падало, но пользователи которые работают с календарями через внутреннюю соц сеть даже не заметили (а таких большинство).

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

      Как работало до: мы просто ходили напрямую в Exchange)


      1. Grand_piano
        13.08.2024 08:22
        +1

        Эммм. А просто забалансить ноды Exchange вашим админам религия не позволила? Хотя ладно... Зачем я это пишу.

        Велосипед знатный, опыта хапнули не по детски, что тоже хорошо.


        1. modsamara
          13.08.2024 08:22

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

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


          1. AMEST Автор
            13.08.2024 08:22

            Видимо мы не правильно выразились, "нестабильность" здесь имеется ввиду (что подкреплено графиками) временем ответов за календарями, а не "постоянными падениями";
            Да, бывают проблемы у EWS но чаще всего это связано с плановыми работами, которые увы иногда проходят в рабочее время либо пошли не по плану. (Повлиять на время работ мы не можем)

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

            Календарь мы не просто разгоняли, а реализовывали свой и синхронизировали его с Exchange (потому что он в текущий момент первоисточник) , чтобы решить не только проблемы производительности, но и минимум еще несколько озвученных в статье цели:

            1. Подстраховаться на случай миграции на другое решение отличное от Exchange, с реализацией только коннектора к другому решению без внесения изменений в логику и код сервиса;

            2. Автономность внутреннего календаря - в случае неисправности внешней системы или в принципе перехода на другое решение, функционал бронирования помещений и планирование занятости (календарь) не должны быть деградированны

            3. Изоляция действия на тестовых площадках (по факту дополнение к п.2)