[{"data":1,"prerenderedAt":898},["ShallowReactive",2],{"page:\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_valhalla":3,"translations:\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_valhalla":860,"series:ru:cuando":865},{"id":4,"title":5,"author":6,"body":7,"date":844,"description":845,"draft":846,"extension":847,"image":848,"listed":512,"meta":849,"navigation":512,"part":850,"path":851,"seo":852,"series":853,"stem":854,"tags":855,"theme":853,"toc":512,"__hash__":859},"content_ru\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_valhalla.md","Все лампочки горели зелёным","slnt_opp",{"type":8,"value":9,"toc":835},"minimark",[10,14,17,22,25,28,39,43,71,77,320,328,331,334,337,340,343,346,349,352,359,372,380,386,392,406,416,434,569,576,590,600,609,619,623,626,671,674,678,681,691,741,748,755,759,762,769,773,793,831],[11,12,13],"p",{},"Сегодня днём настоящий роутинг-движок впервые построил нам поездку на автобусе: маршрут 06-2, 2071 секунда от двери до двери против 2585 пешком. Восемь с половиной минут выигрыша. Не разгром, но это автобус, и взялся он из расписания, которое мы написали сами.",[11,15,16],{},"Это расписание — GTFS-фид, и мы пытаемся публиковать его каждое утро буднего дня с марта. До сегодняшнего дня ничто в Cuando ни разу не спросило у него автобус. Этот пост — о пяти месяцах посередине, об одном длинном дне, который ушёл на то, чтобы их закрыть, и о том, как всё это время всё горело зелёным.",[18,19,21],"h2",{"id":20},"зачем-роутинг-движок","Зачем роутинг-движок",[11,23,24],{},"Четвёртая часть закончилась тем, что поиск пути переехал в память Worker: граф из остановок и рёбер плюс цикл, который перечисляет пути максимум с двумя пересадками. Работает. Но он сначала находит пути, а расписание спрашивает потом. Кандидатов он набирает, считая остановки и пересадки, затем смотрит, когда по ним уходят автобусы, и сортирует по общему времени вместе с ожиданием. Путь, которого нет среди кандидатов, выиграть не может, как бы скоро ни уходил его автобус.",[11,26,27],{},"Транспортный роутер делает наоборот. Он ищет по самому расписанию, так что маршрут с двадцатью минутами ожидания проигрывает более длинной поездке, которая отправляется прямо сейчас.",[11,29,30,31,38],{},"Такой у нас уже есть. ",[32,33,37],"a",{"href":34,"rel":35},"https:\u002F\u002Fgithub.com\u002Fvalhalla\u002Fvalhalla",[36],"nofollow","Valhalla"," — открытый роутинг-движок, и с прошлого ноября она считает пешие части наших маршрутов. Крутится она на маленькой виртуалке, которая у нас и так была, — на той же машине, что рисует тайлы карты из второй части. Общественный транспорт Valhalla тоже умеет. Надо только скормить ей GTFS.",[18,40,42],{"id":41},"расписание-которого-никто-не-публикует","Расписание, которого никто не публикует",[11,44,45,46,50,51,54,55,58,59,62,63,66,67,70],{},"GTFS — это формат, в котором транспортные компании публикуют расписания: zip с CSV-файлами. ",[47,48,49],"code",{},"stops.txt"," говорит, где остановки, а ",[47,52,53],{},"routes.txt"," перечисляет маршруты. В ",[47,56,57],{},"trips.txt"," одна строка на каждый рейс автобуса, ",[47,60,61],{},"stop_times.txt"," говорит, когда рейс проходит каждую остановку, а ",[47,64,65],{},"calendar.txt"," — по каким дням. ",[47,68,69],{},"shapes.txt"," рисует дорогу, по которой едет автобус. Его читает любой роутинг-движок. Для Торревьехи, насколько мы знаем, его никто не публикует.",[11,72,73,74,76],{},"Но у нас есть всё, из чего GTFS-фид состоит. Нарисованная руками сеть из первой части даёт остановки, маршруты и их форму. Шаблоны расписаний («от C\u002F Mayor в 07:30, каждые 30 минут, до 20:01, по будням») дают отправления. А трюк из первой части — время отправления плюс секунды от каждой остановки до следующей — даёт каждое прибытие. ",[47,75,61],{}," — это та самая арифметика, расписанная для каждого отправления и каждой остановки. Слегка упрощённо:",[78,79,84],"pre",{"className":80,"code":81,"language":82,"meta":83,"style":83},"language-ts shiki shiki-themes monokai","for (const dep of departures) {\n  const vec = vectors.get(`${dep.line}:${dep.from}`) \u002F\u002F seconds from each stop to the next\n  let t = dep.departure_time * 60\n  for (let i = 0; i \u003C vec.length; i++) {\n    t += vec[i] \u002F\u002F vec[0] is 0 for the first stop\n    stopTimes.push({\n      trip_id: tripId,\n      stop_id: lineStations[fromIdx + i],\n      arrival_time: formatTime(t),\n      departure_time: formatTime(t),\n      stop_sequence: i + 1,\n    })\n  }\n}\n","ts","",[47,85,86,112,166,187,221,236,248,254,266,278,288,302,308,314],{"__ignoreMap":83},[87,88,91,95,99,103,106,109],"span",{"class":89,"line":90},"line",1,[87,92,94],{"class":93},"s8I7P","for",[87,96,98],{"class":97},"sCdxs"," (",[87,100,102],{"class":101},"sOx1s","const",[87,104,105],{"class":97}," dep ",[87,107,108],{"class":93},"of",[87,110,111],{"class":97}," departures) {\n",[87,113,115,118,121,124,127,131,134,138,141,144,147,150,152,155,157,159,162],{"class":89,"line":114},2,[87,116,117],{"class":101},"  const",[87,119,120],{"class":97}," vec ",[87,122,123],{"class":93},"=",[87,125,126],{"class":97}," vectors.",[87,128,130],{"class":129},"sHkqI","get",[87,132,133],{"class":97},"(",[87,135,137],{"class":136},"s_Ekj","`",[87,139,140],{"class":93},"${",[87,142,143],{"class":97},"dep.line",[87,145,146],{"class":93},"}",[87,148,149],{"class":136},":",[87,151,140],{"class":93},[87,153,154],{"class":97},"dep.from",[87,156,146],{"class":93},[87,158,137],{"class":136},[87,160,161],{"class":97},") ",[87,163,165],{"class":164},"snpHw","\u002F\u002F seconds from each stop to the next\n",[87,167,169,172,175,177,180,183],{"class":89,"line":168},3,[87,170,171],{"class":101},"  let",[87,173,174],{"class":97}," t ",[87,176,123],{"class":93},[87,178,179],{"class":97}," dep.departure_time ",[87,181,182],{"class":93},"*",[87,184,186],{"class":185},"s7s5_"," 60\n",[87,188,190,193,195,198,201,203,206,209,212,215,218],{"class":89,"line":189},4,[87,191,192],{"class":93},"  for",[87,194,98],{"class":97},[87,196,197],{"class":101},"let",[87,199,200],{"class":97}," i ",[87,202,123],{"class":93},[87,204,205],{"class":185}," 0",[87,207,208],{"class":97},"; i ",[87,210,211],{"class":93},"\u003C",[87,213,214],{"class":97}," vec.length; i",[87,216,217],{"class":93},"++",[87,219,220],{"class":97},") {\n",[87,222,224,227,230,233],{"class":89,"line":223},5,[87,225,226],{"class":97},"    t ",[87,228,229],{"class":93},"+=",[87,231,232],{"class":97}," vec[i] ",[87,234,235],{"class":164},"\u002F\u002F vec[0] is 0 for the first stop\n",[87,237,239,242,245],{"class":89,"line":238},6,[87,240,241],{"class":97},"    stopTimes.",[87,243,244],{"class":129},"push",[87,246,247],{"class":97},"({\n",[87,249,251],{"class":89,"line":250},7,[87,252,253],{"class":97},"      trip_id: tripId,\n",[87,255,257,260,263],{"class":89,"line":256},8,[87,258,259],{"class":97},"      stop_id: lineStations[fromIdx ",[87,261,262],{"class":93},"+",[87,264,265],{"class":97}," i],\n",[87,267,269,272,275],{"class":89,"line":268},9,[87,270,271],{"class":97},"      arrival_time: ",[87,273,274],{"class":129},"formatTime",[87,276,277],{"class":97},"(t),\n",[87,279,281,284,286],{"class":89,"line":280},10,[87,282,283],{"class":97},"      departure_time: ",[87,285,274],{"class":129},[87,287,277],{"class":97},[87,289,291,294,296,299],{"class":89,"line":290},11,[87,292,293],{"class":97},"      stop_sequence: i ",[87,295,262],{"class":93},[87,297,298],{"class":185}," 1",[87,300,301],{"class":97},",\n",[87,303,305],{"class":89,"line":304},12,[87,306,307],{"class":97},"    })\n",[87,309,311],{"class":89,"line":310},13,[87,312,313],{"class":97},"  }\n",[87,315,317],{"class":89,"line":316},14,[87,318,319],{"class":97},"}\n",[11,321,322,323,327],{},"Это чистый JavaScript внутри Worker плюс маленькая библиотека для zip. Как сказано в шапке файла, ",[324,325,326],"em",{},"«ничему здесь не нужны ни бинарник, ни контейнер, ни раннер GitHub Actions»",".",[11,329,330],{},"Первую версию я написал 6 марта. Каждый будний день в 04:20 UTC Worker собирает фид и загружает его в R2, но только если изменился его хеш. Через десять минут cron на виртуалке должен был сверить хеш, скачать zip, если он новый, распаковать его для Valhalla и перезапустить её. Фид закрыт токеном: остановки, маршруты и их форма — это данные, которые мы собирали руками.",[11,332,333],{},"В том же коммите у Worker появилась функция, которая спрашивает у Valhalla маршрут на автобусе и пешком.",[11,335,336],{},"Её никто не вызывал.",[11,338,339],{},"Приложение продолжало обращаться к поиску по графу из четвёртой части, а фид продолжал уходить каждое утро буднего дня — ну или пытался. Сегодня мы переключили поиск пути на Valhalla и начали выяснять, что же мы всё это время публиковали.",[18,341,5],{"id":342},"все-лампочки-горели-зелёным",[11,344,345],{},"Вот что стояло между фидом и автобусом в приложении.",[11,347,348],{},"Первая проблема хотя бы выглядела как поломка. Собрать фид — значит развернуть каждое отправление в рейс, а каждый рейс — в строку на каждую остановку, потом всё это заархивировать, и всё это должно было уложиться в один запуск Worker. Когда не укладывалось, запуск умирал на полпути, никто не записывал, чем он закончился, и в нашем списке джоб он становился «осиротевшим». В R2 оставалось то, что записал последний удачный запуск. Теперь эта джоба — durable Workflow в Cloudflare из пяти шагов, и у каждого шага свой свежий бюджет.",[11,350,351],{},"Все остальные — причина, по которой у поста такое название. Каждой из них по отдельности хватает, чтобы потерять все автобусы или скрыть, что они потеряны, и ни одна ничего не красит в красный.",[11,353,354],{},[355,356],"img",{"alt":357,"src":358},"Каждый шаг от расписания до приложения: что он показывал и что происходило на самом деле. Все проверки проходят, а на выходе — пеший маршрут","\u002Fimages\u002Fcuando_valhalla\u002Fpipeline-ru.png",[11,360,361,365,366,368,369,149],{},[362,363,364],"strong",{},"Одно пустое число выкидывает все автобусы."," Читалка GTFS в Valhalla разбирает числа сразу, и одна пустая координата в ",[47,367,69],{}," роняет ",[47,370,371],{},"std::stod",[78,373,378],{"className":374,"code":376,"language":377},[375],"language-text","[ERROR] Couldn't find a required file for feed cuando: stod while adding item from shapes.txt\n[ERROR] Couldn't find any usable GTFS feeds.\n","text",[47,379,376],{"__ignoreMap":83},[11,381,382,383,385],{},"На этом сборка не заканчивается. Valhalla едет дальше без транспорта и строит совершенно нормальный граф, в котором нет ни одного автобуса. А пустая координата в нашем ",[47,384,69],{}," была не одна. Больше половины строк выглядели вот так:",[78,387,390],{"className":388,"code":389,"language":377},[375],"guardamar-hospital,,,1\n",[47,391,389],{"__ignoreMap":83},[11,393,394,395,398,399,402,403,405],{},"Каждое ребро хранит дорогу, по которой идёт, в виде списка точек, и у наших рёбер эти точки записаны в двух кодировках: парами ",[47,396,397],{},"[lat, lng]"," и объектами ",[47,400,401],{},"{latitude, longitude}",". Обе законные, обе встречаются сплошь и рядом, и карта в приложении читает обе. Сборщик фида читал только объекты, а пары записывал как пустоту. Лучшее тут — объявление типа в самом сборщике: в комментарии к нему сказано, что в колонке лежат пары ",[47,404,397],{},". Комментарий был прав насчёт половины строк, а код — насчёт другой половины.",[11,407,408,411,412,415],{},[362,409,410],{},"Zip — тоже фид."," Образ ищет фиды в ",[47,413,414],{},"\u002Fgtfs_feeds"," — этот путь зашит в его скрипты — и хочет, чтобы каждый был распакован в отдельную папку. Скрипт, который мы закоммитили в марте, распаковывал фид туда как положено, а потом оставлял рядом сам zip. Всё остальное в этой папке считается отдельным фидом и валит загрузку.",[11,417,418,421,422,425,426,429,430,433],{},[362,419,420],{},"Перезапуск ничего не подхватывает."," Valhalla у нас работает из её Docker-образа ",[47,423,424],{},"valhalla-scripted",", который настраивается переменными окружения, и они значат не то, что написано. ",[47,427,428],{},"build_transit=True"," строит транспорт, только если транспортных тайлов ещё нет. ",[47,431,432],{},"build_tar=True"," собирает архив тайлов, только если архива ещё нет. А сервис отдаёт именно этот архив, а не свежие тайлы рядом с ним. Из скриптов самого образа:",[78,435,439],{"className":436,"code":437,"language":438,"meta":83,"style":83},"language-bash shiki shiki-themes monokai","# configure_valhalla.sh: transit builds only if the directory is missing or empty\nif [[ \"${build_transit}\" == \"Force\" ]] || (! [[ -d ${TRANSIT_DIR} ]] && [[ \"${build_transit}\" == \"True\" ]]) || ...\n\n# docker-entrypoint.sh: the archive is built only if it does not already exist\nif ([[ \"${build_tar_local}\" == \"True\" && ! -f $TILE_TAR ]]) || [[ \"${build_tar_local}\" == \"Force\" ]]; then\n","bash",[47,440,441,446,508,514,519],{"__ignoreMap":83},[87,442,443],{"class":89,"line":90},[87,444,445],{"class":164},"# configure_valhalla.sh: transit builds only if the directory is missing or empty\n",[87,447,448,451,454,457,460,463,466,469,472,475,477,480,482,485,488,490,492,494,496,499,502,504],{"class":89,"line":114},[87,449,450],{"class":93},"if",[87,452,453],{"class":97}," [[ ",[87,455,456],{"class":136},"\"${",[87,458,459],{"class":97},"build_transit",[87,461,462],{"class":136},"}\"",[87,464,465],{"class":93}," ==",[87,467,468],{"class":136}," \"Force\"",[87,470,471],{"class":97}," ]] ",[87,473,474],{"class":93},"||",[87,476,98],{"class":97},[87,478,479],{"class":93},"!",[87,481,453],{"class":97},[87,483,484],{"class":93},"-d",[87,486,487],{"class":97}," ${TRANSIT_DIR} ]] && [[ ",[87,489,456],{"class":136},[87,491,459],{"class":97},[87,493,462],{"class":136},[87,495,465],{"class":93},[87,497,498],{"class":136}," \"True\"",[87,500,501],{"class":97}," ]]) ",[87,503,474],{"class":93},[87,505,507],{"class":506},"sYf5A"," ...\n",[87,509,510],{"class":89,"line":168},[87,511,513],{"emptyLinePlaceholder":512},true,"\n",[87,515,516],{"class":89,"line":189},[87,517,518],{"class":164},"# docker-entrypoint.sh: the archive is built only if it does not already exist\n",[87,520,521,523,526,528,531,533,535,537,540,543,546,549,551,553,555,557,559,561,563,566],{"class":89,"line":223},[87,522,450],{"class":93},[87,524,525],{"class":97}," ([[ ",[87,527,456],{"class":136},[87,529,530],{"class":97},"build_tar_local",[87,532,462],{"class":136},[87,534,465],{"class":93},[87,536,498],{"class":136},[87,538,539],{"class":93}," &&",[87,541,542],{"class":93}," !",[87,544,545],{"class":93}," -f",[87,547,548],{"class":97}," $TILE_TAR ]]) ",[87,550,474],{"class":93},[87,552,453],{"class":97},[87,554,456],{"class":136},[87,556,530],{"class":97},[87,558,462],{"class":136},[87,560,465],{"class":93},[87,562,468],{"class":136},[87,564,565],{"class":97}," ]]; ",[87,567,568],{"class":93},"then\n",[11,570,571,572,575],{},"Наш скрипт синхронизации заканчивался перезапуском под комментарием ",[324,573,574],{},"«Restart Valhalla to rebuild with new transit data»",". Когда транспортные тайлы уже есть, перезапуск ничего такого не пересобирает. Новый фид лежит на диске, а отвечает старый граф.",[11,577,578,579,582,583,585,586,589],{},"Очевидное решение — ловушка. Образ понимает ещё и ",[47,580,581],{},"Force",", который пересобирает всё каждый раз. Но окружение контейнера фиксируется при его создании, а наш настроен подниматься сам. Создай его один раз с ",[47,584,581],{}," — и каждая перезагрузка виртуалки стирает тайлы и минут двадцать собирает их заново, менялся фид или нет. В compose-файле, который мы закоммитили в марте, был включён родственник этого переключателя — ",[47,587,588],{},"force_rebuild=True",". Он пересобирает весь дорожный граф при каждом старте и всё равно не трогает уже существующие транспортные тайлы: двадцать минут пересборки всего, кроме единственного, что поменялось.",[11,591,592,593,596,597,599],{},"Работает наоборот: оставить переменные в ",[47,594,595],{},"True"," и перед пересозданием контейнера удалить транспортные тайлы и архив. Тогда ",[47,598,595],{}," строит ровно тогда, когда есть что строить. Пересборка занимает от десяти до двадцати пяти минут на четырёх потоках для выгрузки OpenStreetMap размером около 46 МБ.",[11,601,602,605,606,608],{},[362,603,604],{},"Календарь истекает молча."," ",[47,607,65],{}," говорит, по каким дням ходит каждый сервис, между датой начала и датой конца. Наш покрывает отправления, которые мы сгенерировали, — примерно на шесть недель вперёд. Фид, у которого дата конца прошла, собирается без единой ошибки и никого никуда не везёт. Фид, который мы опубликовали сегодня, перестанет строить маршруты 13 сентября, если до этого его не сменит новый.",[11,610,611,614,615,618],{},[362,612,613],{},"Пешая прогулка — это 200."," Когда у Valhalla нет автобусов или ни один не помогает, запрос маршрута на автобусе не падает. Она отвечает пешим маршрутом и HTTP 200 — ровно как при успехе. Функция, которую я написал в марте, проверяла ровно одно: ",[47,616,617],{},"res.ok",". Если бы её кто-то вызвал, она положила бы пешую прогулку в список автобусных маршрутов, и прогулка выглядела бы нормально.",[18,620,622],{"id":621},"подключаем","Подключаем",[11,624,625],{},"Исправление фида влили в пять. К четверти седьмого поиск пути в приложении уже шёл через Valhalla. Что Worker делает с ответом:",[627,628,629,636,650,656,665],"ul",{},[630,631,632,635],"li",{},[362,633,634],{},"Спрашивает не один раз."," Valhalla возвращает один маршрут на запрос, а приложение показывает несколько на выбор. Поэтому Worker спрашивает до трёх раз и каждый раз исключает маршруты, которые уже использовали предыдущие ответы. Если ответ всё равно приходит тот же, он останавливается.",[630,637,638,641,642,645,646,649],{},[362,639,640],{},"Узнаёт свои id."," Valhalla возвращает id из нашего фида, завёрнутые в свои: ",[47,643,644],{},"cuando_06-2"," для маршрута, ",[47,647,648],{},"cuando_c-argonauto_transit_station"," для остановки. Сними обёртку — и это те самые id, которые приложение уже знает, так что в приложении не пришлось менять ни строчки.",[630,651,652,655],{},[362,653,654],{},"Никогда не смотрит на название."," Короткое название маршрута 06-2 — «06», и у маршрута в обратную сторону оно тоже «06». Ищи маршруты по названию — и получишь правильный номер в неправильную сторону.",[630,657,658,661,662,664],{},[362,659,660],{},"Отправляет местное время."," Время отправления Valhalla понимает как местное время на часах, потому что время в ",[47,663,61],{}," местное. Отправь UTC — и в августе получишь автобусы двухчасовой давности.",[630,666,667,670],{},[362,668,669],{},"Считает прогулку отсутствием ответа."," Ответ без единого автобуса считается как «ничего не нашлось». Ещё каждый маршрут и каждая остановка в ответе сверяются с базой, так что тайлы, собранные из старого датасета, всплывают предупреждением в логах, а не маршрутом без названия в приложении.",[11,672,673],{},"Одна настройка в Worker возвращает поиск пути на граф из четвёртой части. Без релиза приложения и без ревью в сторах.",[18,675,677],{"id":676},"потом-мы-посмотрели-на-карту","Потом мы посмотрели на карту",[11,679,680],{},"Когда пошли настоящие поездки, мы посмотрели, как они нарисованы. Автобусная линия на одной поездке обрывалась за 843 метра до остановки, где тебе выходить. Другая делала крюк на 851 метр и пропускала остановку. В нашей админской консоли те же маршруты рисовались целиком, и это была подсказка: консоль рисует все рёбра, какие у неё есть, а всё, что проходит рёбра маршрута по порядку, может часть из них потерять.",[11,682,683,684,687,688,149],{},"Теряли их четыре отдельных бага. Каждый был молчаливым по построению: там, где должно было быть предупреждение, стояли ",[47,685,686],{},"continue"," или ",[47,689,690],{},"filter",[627,692,693,699,729,735],{},[630,694,695,698],{},[362,696,697],{},"Опять две кодировки",", на этот раз в коде, который рисует форму поездки. Ребро, записанное парами, давало ноль точек.",[630,700,701,704,705,708,709,712,713,716,717,720,721,724,725,728],{},[362,702,703],{},"Парсер id, который съедал id."," В старых строках лежит голый id остановки, в новых — полное имя ресурса, и запрос отрезал имя после ",[47,706,707],{},"\u002Fstations\u002F"," вот так: ",[47,710,711],{},"substr(x, instr(x, '\u002Fstations\u002F') + length('\u002Fstations\u002F'))",". На голом id ",[47,714,715],{},"instr"," возвращает 0, так что отрезать начинали с десятого символа самого id: ",[47,718,719],{},"c-argonauto"," превращался в ",[47,722,723],{},"to",", а ",[47,726,727],{},"c-perseo",", который короче, — вообще в пустую строку. Ни то ни другое ни с чем не совпадает.",[630,730,731,734],{},[362,732,733],{},"Ребро выбиралось по наименьшему id."," Между двумя одними и теми же остановками разные маршруты могут ехать разными дорогами. Запрос брал первое попавшееся ребро, чьё бы оно ни было.",[630,736,737,740],{},[362,738,739],{},"Кольцевые маршруты."," Фид искал рёбра маршрута по остановке, от которой они отходят. Кольцевой маршрут проходит одну остановку дважды, так что от неё отходят два ребра. Фид оставлял одно и использовал его оба раза: линия уходила по одной ветке, возвращалась, рисовала её ещё раз, а другую ветку — ту, на которой была остановка, — не рисовала вообще.",[11,742,743,744,747],{},"Раньше днём, переделывая джобу, мы нашли ещё один. У каждого рейса в фиде был ",[47,745,746],{},"direction_id"," 0, потому что строчка, которая должна была различать два направления, сравнивала маршрут с его собственным обратным маршрутом.",[11,749,750,751,754],{},"Вместе с исправлением появились две джобы, которые проверяют геометрию; их запускают руками после правок сети. Та, что важна здесь, читает из R2 ",[324,752,753],{},"опубликованный"," zip, так что проверяет ровно те байты, которые ест Valhalla, а не свежую сборку, которая может от них отличаться. Главная её проверка ловит любую остановку дальше 120 метров от линии, нарисованной для её рейса. Порог взят из баг-репорта: в хорошем случае остановки отстояли от линии максимум на 41 метр, а сломанные промахивались на 313, 435 и 843.",[18,756,758],{"id":757},"день-навигации","День навигации",[11,760,761],{},"Сегодня в навигацию и геометрию под ней ушло двенадцать пулл-реквестов — с без четверти три дня до двадцати минут одиннадцатого вечера. Три из них чинили поиск в памяти из четвёртой части, у которого хватало своих проблем. Один добавил норвежский в подсказки маршрута, а шведский — в подписи, которые мы пишем сами: в городе, где живёт столько скандинавов, и то и другое стоило сделать давным-давно. (Белорусского в Valhalla нет вообще, так что белорусским читателям подсказки приходят по-русски.)",[11,763,764,765,768],{},"Урок, который я из этого выношу, — про сигналы. Каждая наша проверка спрашивала, ",[324,766,767],{},"отработало"," ли что-то: джоба, загрузка, контейнер, HTTP-запрос. Ни одна не спрашивала, выходит ли на другом конце автобус. Каждый раз, когда контейнер отвечал, он был здоров. Просто автобусов в нём не было.",[18,770,772],{"id":771},"что-дальше","Что дальше",[627,774,775,781,787],{},[630,776,777,780],{},[362,778,779],{},"Проба."," Каждое утро спрашивать у Valhalla поездку, в которой обязан быть автобус, и громко падать, если его нет. Сейчас «синхронизация прошла» значит, что началась пересборка, а она после этого идёт ещё до двадцати пяти минут.",[630,782,783,786],{},[362,784,785],{},"Более поздние отправления."," Valhalla отвечает на вопрос «как лучше доехать, если выйти прямо сейчас?», а спросить про следующий автобус приложение пока не может.",[630,788,789,792],{},[362,790,791],{},"Сами данные."," У новых джоб для нас есть список, и он не короткий.",[794,795,796,802],"note",{},[11,797,798,801],{},[362,799,800],{},"Из сентября 2026."," Этот пост датирован днём, когда автобусы переехали в Valhalla, и написан так, как всё выглядело тогда. Восемь недель спустя:",[627,803,804,810,820,826],{},[630,805,806,809],{},[362,807,808],{},"Переключатель так и остался включённым."," С того вечера каждый автобусный маршрут в приложении строит Valhalla. Поиск по графу из четвёртой части всё ещё в коде, на расстоянии одной настройки.",[630,811,812,815,816,819],{},[362,813,814],{},"Более поздние отправления появились 4 сентября."," Комментарий над кодом с самого первого дня говорил, что ",[324,817,818],{},"«выбор более позднего отправления — отдельное, явное действие в приложении»",", — вот только отправить это действие было некуда. Теперь API принимает время отправления.",[630,821,822,825],{},[362,823,824],{},"Тайлы помнят маршрут до следующей пересборки."," Накануне выяснилось, что архивированный маршрут пропадал из поиска пути не сразу. Тайлы собирались, пока он ходил, а проверка, которая должна была ловить маршруты, которых у нас больше нет, пропускала архивированные. Теперь любой ответ, в котором такой маршрут есть, выбрасывается.",[630,827,828],{},[362,829,830],{},"Проба всё ещё в списке.",[832,833,834],"style",{},"html pre.shiki code .s8I7P, html code.shiki .s8I7P{--shiki-default:#F92672}html pre.shiki code .sCdxs, html code.shiki .sCdxs{--shiki-default:#F8F8F2}html pre.shiki code .sOx1s, html code.shiki .sOx1s{--shiki-default:#66D9EF;--shiki-default-font-style:italic}html pre.shiki code .sHkqI, html code.shiki .sHkqI{--shiki-default:#A6E22E}html pre.shiki code .s_Ekj, html code.shiki .s_Ekj{--shiki-default:#E6DB74}html pre.shiki code .snpHw, html code.shiki .snpHw{--shiki-default:#88846F}html pre.shiki code .s7s5_, html code.shiki .s7s5_{--shiki-default:#AE81FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sYf5A, html code.shiki .sYf5A{--shiki-default:#66D9EF}",{"title":83,"searchDepth":114,"depth":114,"links":836},[837,838,839,840,841,842,843],{"id":20,"depth":114,"text":21},{"id":41,"depth":114,"text":42},{"id":342,"depth":114,"text":5},{"id":621,"depth":114,"text":622},{"id":676,"depth":114,"text":677},{"id":757,"depth":114,"text":758},{"id":771,"depth":114,"text":772},"2026-08-04","С сегодняшнего дня автобусные маршруты в Cuando строит Valhalla по GTFS-расписанию, которое мы генерируем сами. Мы публикуем его — или пытаемся — каждый будний день с марта, и до сегодняшнего дня никто не спросил у него ни одного автобуса. Как сделать GTFS, когда его никто не публикует, и как Valhalla теряет все автобусы, пока все проверки горят зелёным.",false,"md","\u002Fimages\u002Fcuando_valhalla\u002Fcover.jpg",{},"5","\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_valhalla",{"title":5,"description":845},"cuando","ru\u002Fposts\u002Fcode_n_shit\u002Fcuando_valhalla",[47,853,856,857,858],"valhalla","gtfs","cloudflare","-xtgZ5QXyYi7Fd5bU6F67W06ck-UwxK-fzZYymsSGo8",[861],{"locale":862,"path":863,"title":864},"en","\u002Fposts\u002Fcode_n_shit\u002Fcuando_valhalla","Every light was green",{"slug":853,"title":866,"path":867,"chapters":868},"Как мы делали Cuando","\u002Fru\u002Fseries\u002Fcuando",[869,876,883,890,897],{"path":870,"title":871,"description":872,"date":873,"image":874,"draft":846,"series":853,"part":875,"n":875},"\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_bus_app","Мы сделали приложение про автобусы Торревьехи. Оно задаёт один вопрос","Cuando — приложение про автобусы Торревьехи, теперь в Google Play и App Store. Данных для старта не было, так что сеть мы нарисовали руками: 163 остановки, 16 маршрутов, приложение-секундомер и куча YAML. Рассказываю, как оно работает и почему графовая база продержалась восемь недель.","2025-01-24","\u002Fimages\u002Fcuando_bus_app\u002Fcover.jpg","1",{"path":877,"title":878,"description":879,"date":880,"image":881,"draft":846,"series":853,"part":882,"n":882},"\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_tiles","Платим за каждый тайл","Все решения про карту в Cuando до сих пор вытекали из того, как за эту карту брали деньги. Полгода Mapbox, одна неделя векторных тайлов, тепловая карта, которая решает, насколько глубоко можно зумить, и — с этой недели — карта, которую мы рисуем сами.","2025-02-22","\u002Fimages\u002Fcuando_tiles\u002Fcover.jpg","2",{"path":884,"title":885,"description":886,"date":887,"image":888,"draft":846,"series":853,"part":889,"n":889},"\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_vector","Пятьдесят восемь мегабайт","Полтора года мы рисовали карту картинками. Почему Cuando переезжает на вектор: один файл на 58,67 МБ вместо 15 ГБ на тему, упавший рендерер во Flutter и три картографических движка за одной вьюхой в новых приложениях.","2026-08-15","\u002Fimages\u002Fcuando_vector\u002Fcover.jpg","3",{"path":891,"title":892,"description":893,"date":894,"image":895,"draft":846,"series":853,"part":896,"n":896},"\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_routing_sql","Запрос, который съел базу","Целый год поиск пути в Cuando был одним рекурсивным SQL-запросом. Этой осенью его начало вызывать приложение, и тут ему перестало хватать памяти. Как устроен поиск пути на SQL, почему он взрывается и почему с сегодняшнего дня он переехал в Worker.","2025-12-04","\u002Fimages\u002Fcuando_routing_sql\u002Fcover.jpg","4",{"path":851,"title":5,"description":845,"date":844,"image":848,"draft":846,"series":853,"part":850,"n":850},1790951508407]