<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title>Denis Evsyukov</title><link>https://www.juev.org/</link><description>Recent content on Denis Evsyukov</description><generator>Hugo -- gohugo.io</generator><language>ru-RU</language><managingEditor>denis@evsyukov.org (Denis Evsyukov)</managingEditor><webMaster>denis@evsyukov.org (Denis Evsyukov)</webMaster><copyright>This work is licensed under a MIT License.</copyright><lastBuildDate>Sat, 31 Oct 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://www.juev.org/atom.xml" rel="self" type="application/rss+xml"/><atom:link href="https://pubsubhubbub.appspot.com/" rel="hub"/><xhtml:meta content="noindex" name="robots" xmlns:xhtml="http://www.w3.org/1999/xhtml"/><item><title>Perigee: своя читалка и разреженные строки</title><link>https://www.juev.org/2026/10/31/perigee-justification/</link><pubDate>Sat, 31 Oct 2026 18:00:00 +0300</pubDate><author>denis@evsyukov.org (Denis Evsyukov)</author><guid>https://www.juev.org/2026/10/31/perigee-justification/</guid><description>&lt;p&gt;Я пишу читалку для Android, она называется Perigee&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt;. Сегодня вышла &lt;code&gt;v0.1.0-rc5&lt;/code&gt;&lt;sup id="fnref:2"&gt;&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref"&gt;2&lt;/a&gt;&lt;/sup&gt;, и главное в ней — страницы без дыр между словами. Ниже расскажу, зачем понадобилась своя читалка, откуда берутся разреженные строки, какие способы мы перебрали и почему ни один готовый алгоритм не подошёл целиком.&lt;/p&gt;
&lt;p&gt;Исследование вели два агента, Hermes и Claude Code. Я смотрел на страницы и принимал решения.&lt;/p&gt;
&lt;h2 id="зачем-своя-читалка"&gt;&lt;a href="#%d0%b7%d0%b0%d1%87%d0%b5%d0%bc-%d1%81%d0%b2%d0%be%d1%8f-%d1%87%d0%b8%d1%82%d0%b0%d0%bb%d0%ba%d0%b0" class="post_hash"&gt;Зачем своя читалка&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Книги у меня лежат на собственном сервере bookOrbit&lt;sup id="fnref:3"&gt;&lt;a href="#fn:3" class="footnote-ref" role="doc-noteref"&gt;3&lt;/a&gt;&lt;/sup&gt;. Он хранит библиотеку и прогресс чтения, у него есть веб-читалка, а официального приложения для Android, когда я начинал, не было.&lt;/p&gt;
&lt;p&gt;Читать на телефоне мне нравится в ReadEra: она хорошо показывает текст и ею просто пользоваться. Но с bookOrbit она не синхронизируется. KOReader синхронизируется и даёт отличный движок, только настроек в нём столько, что читалка становится отдельным хобби.&lt;/p&gt;
&lt;p&gt;Отсюда и цели Perigee:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Отображение важнее всего.&lt;/strong&gt; Текст верстает crengine&lt;sup id="fnref:4"&gt;&lt;a href="#fn:4" class="footnote-ref" role="doc-noteref"&gt;4&lt;/a&gt;&lt;/sup&gt;, нативный движок KOReader: переносы по словарю языка книги, выравнивание по ширине, висячая пунктуация, сноски внизу страницы.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Просто, как в ReadEra.&lt;/strong&gt; Настройки повторяют её набор: шрифт, размер, интервалы, поля. Сразу видно, как изменение меняет страницу.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Самостоятельное приложение.&lt;/strong&gt; Локальная библиотека и каталоги OPDS работают без сервера.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Полная синхронизация с bookOrbit.&lt;/strong&gt; В обе стороны ходят место чтения, выделения с заметками, закладки, статус и оценка, на сервер уходит время чтения.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;PDF, DjVu, комиксов и аудиокниг нет: для них нужен второй движок. Первый коммит появился 12 октября, в репозитории Kotlin и Compose поверх C++ движка.&lt;/p&gt;
&lt;h2 id="проблема"&gt;&lt;a href="#%d0%bf%d1%80%d0%be%d0%b1%d0%bb%d0%b5%d0%bc%d0%b0" class="post_hash"&gt;Проблема&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Первые сборки выглядели хорошо, но кое-где между словами зияли дыры. Вот самая плохая строка из тестовой книги, слева:&lt;/p&gt;
&lt;p&gt;&lt;figure class="image-figure"&gt;&lt;img src="https://static.juev.org/2026/10/perigee-justify-ceiling-291.png"
alt="Строка «Distributions, что-то вида» растянута на всю ширину"
loading="lazy"
decoding="async"&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Причина в том, как движок выравнивает текст. Он жадный: набирает в строку столько, сколько влезает, а остаток ширины поровну делит между промежутками. У сжатия есть предел, в Perigee пробел нельзя ужать меньше чем до 75 %. У растяжения предела нет. Если следующее слово длинное и не переносится, вся пустота достаётся промежуткам этой строки.&lt;/p&gt;
&lt;p&gt;Я попросил это померить. Промежутки считали в em, это размер шрифта: у шрифтов из приложения собственный пробел занимает 0.20–0.29 em. Hermes собрал crengine на компьютере с патчами приложения и добавил запись в CSV на каждую выровненную строку. Книга для замера: 1800 абзацев моего блога, Roboto 18 dp на экране шириной 1440 пикселей, это около 42 символов в строке и примерно 13 300 строк.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Показатель&lt;/th&gt;
&lt;th&gt;Значение&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Медианный промежуток&lt;/td&gt;
&lt;td&gt;0.35 em&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;99-й перцентиль&lt;/td&gt;
&lt;td&gt;1.13 em&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Самый широкий&lt;/td&gt;
&lt;td&gt;5.48 em&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Строк шире 0.5 em&lt;/td&gt;
&lt;td&gt;20.0 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Строк шире 0.75 em&lt;/td&gt;
&lt;td&gt;4.4 %&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Медиана в норме, плохо с хвостом. Цель из задачи звучала так: ни одного промежутка шире 0.75 em и не больше одной строки из пятидесяти шире 0.5 em&lt;sup id="fnref:5"&gt;&lt;a href="#fn:5" class="footnote-ref" role="doc-noteref"&gt;5&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;h2 id="что-мы-попробовали"&gt;&lt;a href="#%d1%87%d1%82%d0%be-%d0%bc%d1%8b-%d0%bf%d0%be%d0%bf%d1%80%d0%be%d0%b1%d0%be%d0%b2%d0%b0%d0%bb%d0%b8" class="post_hash"&gt;Что мы попробовали&lt;/a&gt;&lt;/h2&gt;
&lt;h3 id="потолок"&gt;&lt;a href="#%d0%bf%d0%be%d1%82%d0%be%d0%bb%d0%be%d0%ba" class="post_hash"&gt;Потолок&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Самое простое решение: если промежуток в строке превысит потолок, строка не выравнивается и остаётся с естественными пробелами. Предел у пробела есть и в InDesign: по умолчанию он растягивается до 133 %&lt;sup id="fnref:6"&gt;&lt;a href="#fn:6" class="footnote-ref" role="doc-noteref"&gt;6&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Потолок&lt;/th&gt;
&lt;th&gt;Строк без правого края&lt;/th&gt;
&lt;th&gt;p99&lt;/th&gt;
&lt;th&gt;Самый широкий&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;нет&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1.13 em&lt;/td&gt;
&lt;td&gt;5.48 em&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.75 em&lt;/td&gt;
&lt;td&gt;4.4 %&lt;/td&gt;
&lt;td&gt;0.71 em&lt;/td&gt;
&lt;td&gt;0.71 em&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.5 em&lt;/td&gt;
&lt;td&gt;20.0 %&lt;/td&gt;
&lt;td&gt;0.50 em&lt;/td&gt;
&lt;td&gt;0.50 em&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;На картинке выше потолок 0.75 em справа убрал дыру. Вот все три варианта на той же странице, а ниже обычная страница:&lt;/p&gt;
&lt;p&gt;&lt;figure class="image-figure"&gt;&lt;img src="https://static.juev.org/2026/10/perigee-justify-three-ceilings.png"
alt="Сегодня, потолок 0.75 em и потолок 0.5 em"
loading="lazy"
decoding="async"&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;&lt;figure class="image-figure"&gt;&lt;img src="https://static.juev.org/2026/10/perigee-justify-ceiling-171.png"
alt="Обычная страница: несколько строк потеряли правый край"
loading="lazy"
decoding="async"&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Дыры пропали, зато вместо них появились короткие строки посреди абзаца. На обычной странице таких было шесть из тридцати. Глаз читает это как ошибку вёрстки.&lt;/p&gt;
&lt;h3 id="взгляд-на-строку-вперёд"&gt;&lt;a href="#%d0%b2%d0%b7%d0%b3%d0%bb%d1%8f%d0%b4-%d0%bd%d0%b0-%d1%81%d1%82%d1%80%d0%be%d0%ba%d1%83-%d0%b2%d0%bf%d0%b5%d1%80%d1%91%d0%b4" class="post_hash"&gt;Взгляд на строку вперёд&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Жадный алгоритм ошибается, потому что не видит следующую строку. Вариант: перед добавлением строки оценить её и следующую, попробовать три предыдущих места разрыва и взять то, где худший промежуток пары меньше.&lt;/p&gt;
&lt;p&gt;Первая версия не сдвинула ни одного разрыва на всей книге. Ранний разрыв только расширяет текущую строку, поэтому смотреть надо на следующую: дыра обычно стоит перед длинным словом. После исправления разрывы сместились в 1.1 % случаев, строк шире 0.75 em стало 545 вместо 587, а самый широкий промежуток остался 5.48 em.&lt;/p&gt;
&lt;p&gt;&lt;figure class="image-figure"&gt;&lt;img src="https://static.juev.org/2026/10/perigee-justify-lookahead-171.png"
alt="Сегодня, взгляд вперёд, он же с потолком: дыра переехала на строку выше"
loading="lazy"
decoding="async"&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;На этой странице хорошо видно, почему так: дыра часто просто переезжает на строку выше. Окна в одну строку мало, остаток ширины накапливается за несколько строк. Цена за это — около 150 строк C++ и +15 % ко времени вёрстки.&lt;/p&gt;
&lt;h3 id="абзац-целиком"&gt;&lt;a href="#%d0%b0%d0%b1%d0%b7%d0%b0%d1%86-%d1%86%d0%b5%d0%bb%d0%b8%d0%ba%d0%be%d0%bc" class="post_hash"&gt;Абзац целиком&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Настоящий ответ индустрии давно известен. Кнут и Пласс в 1981 году описали разбивку всего абзаца как поиск пути с наименьшей суммарной «плохостью» строк&lt;sup id="fnref:7"&gt;&lt;a href="#fn:7" class="footnote-ref" role="doc-noteref"&gt;7&lt;/a&gt;&lt;/sup&gt;, этот алгоритм работает в TeX. Paragraph Composer в InDesign тоже оценивает все строки абзаца вместе&lt;sup id="fnref:8"&gt;&lt;a href="#fn:8" class="footnote-ref" role="doc-noteref"&gt;8&lt;/a&gt;&lt;/sup&gt;. Для самого crengine такую работу уже проделали в надстройке к KOReader&lt;sup id="fnref:9"&gt;&lt;a href="#fn:9" class="footnote-ref" role="doc-noteref"&gt;9&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;p&gt;Идея такая: выписываем все места, где можно разорвать строку, включая переносы. Для каждой возможной строки считаем штраф за то, насколько её нужно растянуть или сжать. Динамическим программированием находим последовательность разрывов с минимальной суммой. Выбранные разрывы подаём в обычный цикл движка, поэтому слова, позиции и отрисовка остаются его собственными.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Вариант&lt;/th&gt;
&lt;th&gt;p50&lt;/th&gt;
&lt;th&gt;p99&lt;/th&gt;
&lt;th&gt;Самый широкий&lt;/th&gt;
&lt;th&gt;Шире 0.5 em&lt;/th&gt;
&lt;th&gt;Шире 0.75 em&lt;/th&gt;
&lt;th&gt;Вёрстка&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;сегодня&lt;/td&gt;
&lt;td&gt;0.35&lt;/td&gt;
&lt;td&gt;1.13&lt;/td&gt;
&lt;td&gt;5.48&lt;/td&gt;
&lt;td&gt;20.0 %&lt;/td&gt;
&lt;td&gt;4.4 %&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;абзац целиком&lt;/td&gt;
&lt;td&gt;0.39&lt;/td&gt;
&lt;td&gt;0.85&lt;/td&gt;
&lt;td&gt;2.75&lt;/td&gt;
&lt;td&gt;23.9 %&lt;/td&gt;
&lt;td&gt;2.2 %&lt;/td&gt;
&lt;td&gt;+60 %&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;figure class="image-figure"&gt;&lt;img src="https://static.juev.org/2026/10/perigee-justify-paragraph-291.png"
alt="Сегодня, разбивка абзаца и она же с потолком"
loading="lazy"
decoding="async"&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Хвост сократился вдвое, дыра в 5.48 em исчезла. Но вся страница стала немного рыхлее, и на моих глазах одна плохая строка превращалась в несколько средних. Вёрстка книги при этом стала дольше на 60 %. Hermes предложил другую мысль: считать абзац, но менять только те строки, где есть проблема. Её мы и довели до конца.&lt;/p&gt;
&lt;h2 id="что-нашлось-по-дороге"&gt;&lt;a href="#%d1%87%d1%82%d0%be-%d0%bd%d0%b0%d1%88%d0%bb%d0%be%d1%81%d1%8c-%d0%bf%d0%be-%d0%b4%d0%be%d1%80%d0%be%d0%b3%d0%b5" class="post_hash"&gt;Что нашлось по дороге&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Прежде чем делать новый вариант, я проверил полный алгоритм на странном случае. Если убрать ограничения, он должен быть не хуже жадного: жадный путь для него доступен. А он оказался хуже: 951 строка шире 0.75 em против 517.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Рыхлая страница оказалась следствием одной константы.&lt;/strong&gt; За два переноса подряд алгоритм добавлял 10000. Число взято из TeX, но там оно складывается с квадратами штрафов и равно одной строке с плохостью 100. У нас штраф линейный, и эта добавка перевешивала всё остальное. При значении 1000 тот же алгоритм становится лучше сегодняшней страницы целиком: на «Под Куполом» Кинга строк шире 0.5 em 10.2 % против 12.5 %. Вывод предыдущего раунда описывал константу, а не метод.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Блог сильно завышал проблему.&lt;/strong&gt; Мой блог полон названий и путей, а настоящие книги устроены иначе. В семи книгах прозы шире 0.75 em оказалось от 0.3 до 1.2 % строк, а не 5 %. Цель «одна строка из пятидесяти шире 0.5 em» недостижима ни для одного варианта: даже лучший алгоритм даёт 4–11 %.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Латиница в русских книгах не переносилась.&lt;/strong&gt; Из 295 строк, оставшихся шире 0.75 em после полного алгоритма, у 88 в следующем слове была латиница: &lt;code&gt;Wordpress&lt;/code&gt;, &lt;code&gt;Amazon&lt;/code&gt;, &lt;code&gt;pages.github.com&lt;/code&gt;. Для языка &lt;code&gt;ru&lt;/code&gt; движок берёт &lt;code&gt;Russian.pattern&lt;/code&gt;, где латинских шаблонов нет, хотя в приложении уже лежит &lt;code&gt;Russian_EnUS.pattern&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="что-мы-выбрали"&gt;&lt;a href="#%d1%87%d1%82%d0%be-%d0%bc%d1%8b-%d0%b2%d1%8b%d0%b1%d1%80%d0%b0%d0%bb%d0%b8" class="post_hash"&gt;Что мы выбрали&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Выбрали точечную правку и другой словарь для русских книг. Без настройки и без потолка.&lt;/p&gt;
&lt;p&gt;Сначала, как и раньше, считаются жадные строки. Если в абзаце нет промежутка шире 0.75 em, движок работает как обычно. Если такая строка есть, вокруг неё открывается окно: четыре строки до и две после. Только внутри окна алгоритм рассматривает другие разрывы и расставляет переносы, снаружи разрывы остаются жадными, поэтому эти строки не могут измениться.&lt;/p&gt;
&lt;p&gt;Два решения в функции штрафа делают алгоритм точечным. Промежуток до 0.5 em ничего не стоит, поэтому обычным строкам незачем меняться. А каждая строка, отличающаяся от жадной, стоит немного, поэтому без причины путь с неё не уходит.&lt;/p&gt;
&lt;p&gt;Девять книг в той же настройке. В таблице жадная вёрстка и правка, размеры в em:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Книга&lt;/th&gt;
&lt;th&gt;Шире 0.75 em: было&lt;/th&gt;
&lt;th&gt;стало&lt;/th&gt;
&lt;th&gt;Самый широкий: было&lt;/th&gt;
&lt;th&gt;стало&lt;/th&gt;
&lt;th&gt;Шире 0.5 em: было&lt;/th&gt;
&lt;th&gt;стало&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Кинг, «Под Куполом»&lt;/td&gt;
&lt;td&gt;1.0 %&lt;/td&gt;
&lt;td&gt;0.5 %&lt;/td&gt;
&lt;td&gt;2.05&lt;/td&gt;
&lt;td&gt;1.46&lt;/td&gt;
&lt;td&gt;12.5 %&lt;/td&gt;
&lt;td&gt;12.5 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Петросян, «Дом, в котором…»&lt;/td&gt;
&lt;td&gt;1.0 %&lt;/td&gt;
&lt;td&gt;0.5 %&lt;/td&gt;
&lt;td&gt;1.52&lt;/td&gt;
&lt;td&gt;1.52&lt;/td&gt;
&lt;td&gt;11.0 %&lt;/td&gt;
&lt;td&gt;10.9 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Макьюэн, «Невыносимая любовь»&lt;/td&gt;
&lt;td&gt;1.2 %&lt;/td&gt;
&lt;td&gt;0.3 %&lt;/td&gt;
&lt;td&gt;1.78&lt;/td&gt;
&lt;td&gt;1.30&lt;/td&gt;
&lt;td&gt;11.7 %&lt;/td&gt;
&lt;td&gt;11.6 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Саймак, «Город»&lt;/td&gt;
&lt;td&gt;1.2 %&lt;/td&gt;
&lt;td&gt;0.6 %&lt;/td&gt;
&lt;td&gt;1.28&lt;/td&gt;
&lt;td&gt;1.28&lt;/td&gt;
&lt;td&gt;12.6 %&lt;/td&gt;
&lt;td&gt;12.6 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Брэдбери, «Марсианские хроники»&lt;/td&gt;
&lt;td&gt;1.1 %&lt;/td&gt;
&lt;td&gt;0.5 %&lt;/td&gt;
&lt;td&gt;1.25&lt;/td&gt;
&lt;td&gt;1.01&lt;/td&gt;
&lt;td&gt;12.7 %&lt;/td&gt;
&lt;td&gt;12.7 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Аллен, GTD&lt;/td&gt;
&lt;td&gt;4.1 %&lt;/td&gt;
&lt;td&gt;1.6 %&lt;/td&gt;
&lt;td&gt;2.15&lt;/td&gt;
&lt;td&gt;2.15&lt;/td&gt;
&lt;td&gt;20.0 %&lt;/td&gt;
&lt;td&gt;20.7 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Мой блог&lt;/td&gt;
&lt;td&gt;5.1 %&lt;/td&gt;
&lt;td&gt;2.1 %&lt;/td&gt;
&lt;td&gt;4.94&lt;/td&gt;
&lt;td&gt;2.43&lt;/td&gt;
&lt;td&gt;21.2 %&lt;/td&gt;
&lt;td&gt;20.3 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;«Война и мир», английский перевод&lt;/td&gt;
&lt;td&gt;0.9 %&lt;/td&gt;
&lt;td&gt;0.3 %&lt;/td&gt;
&lt;td&gt;2.47&lt;/td&gt;
&lt;td&gt;1.54&lt;/td&gt;
&lt;td&gt;9.6 %&lt;/td&gt;
&lt;td&gt;9.6 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;«Гордость и предубеждение»&lt;/td&gt;
&lt;td&gt;0.3 %&lt;/td&gt;
&lt;td&gt;0.1 %&lt;/td&gt;
&lt;td&gt;1.11&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;7.3 %&lt;/td&gt;
&lt;td&gt;7.3 %&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Хвост в прозе падает примерно вдвое и догоняет полный алгоритм, а основная масса строк почти не меняется. На «Под Куполом» правка затронула 534 строки примерно из 47 000. Время вёрстки выросло на 13–18 % вместо 68–74 % у полного алгоритма. Текст проверен хешем на трёх книгах: все символы на месте, изменилась только разбивка на строки.&lt;/p&gt;
&lt;p&gt;Шире всего проверили по матрице из 288 прогонов: четыре книги, четыре шрифта, размеры 16, 18 и 24 dp, две ширины, всего 1.7 млн строк.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Вариант&lt;/th&gt;
&lt;th&gt;Шире 0.5 em&lt;/th&gt;
&lt;th&gt;Шире 0.75 em&lt;/th&gt;
&lt;th&gt;Шире 1 em&lt;/th&gt;
&lt;th&gt;Самый широкий&lt;/th&gt;
&lt;th&gt;С переносом&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;сегодня&lt;/td&gt;
&lt;td&gt;17.3 %&lt;/td&gt;
&lt;td&gt;3.69 %&lt;/td&gt;
&lt;td&gt;19 257&lt;/td&gt;
&lt;td&gt;8.36 em&lt;/td&gt;
&lt;td&gt;35.1 %&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;правка&lt;/td&gt;
&lt;td&gt;17.5 %&lt;/td&gt;
&lt;td&gt;2.03 %&lt;/td&gt;
&lt;td&gt;6 543&lt;/td&gt;
&lt;td&gt;3.54 em&lt;/td&gt;
&lt;td&gt;35.8 %&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Цена тоже есть: переносов на 0.7 пункта больше, а в одной из настроек прозы рост доходил до 1.8. Страниц стало почти столько же, разница не больше 0.33 %.&lt;/p&gt;
&lt;p&gt;Обычная страница «Гордости и предубеждения»: слева как было, в середине после правки. Изменились три строки, а один перенос вообще исчез. Правая панель с потолком 1 em ничем не отличается, потому что на таких страницах ему нечего делать:&lt;/p&gt;
&lt;p&gt;&lt;figure class="image-figure"&gt;&lt;img src="https://static.juev.org/2026/10/perigee-justify-repair-pride.png"
alt="Обычная страница: изменились три строки"
loading="lazy"
decoding="async"&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Самый тяжёлый случай, который нашёлся, — страница блога. Дыры ушли, но оба абзаца переработаны, переносов больше, и первый стал на строку длиннее:&lt;/p&gt;
&lt;p&gt;&lt;figure class="image-figure"&gt;&lt;img src="https://static.juev.org/2026/10/perigee-justify-repair-blog.png"
alt="Самая тяжёлая страница: абзацы заметно меняются"
loading="lazy"
decoding="async"&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Словарь работает так: &lt;code&gt;файле mcabberrc&lt;/code&gt; и &lt;code&gt;ошибку: jabstart&lt;/code&gt; теряют дыры, а имена разрываются переносом. Для технических текстов это нормальный обмен, а в художественной прозе словарь почти ничего не меняет. Панели здесь начинаются с разного места, потому что текст немного сдвинулся:&lt;/p&gt;
&lt;p&gt;&lt;figure class="image-figure"&gt;&lt;img src="https://static.juev.org/2026/10/perigee-justify-dictionary.png"
alt="Словарь с латиницей: дыры исчезли, имена перенесены"
loading="lazy"
decoding="async"&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Потолок мы оставили за бортом. В прозе он сработал бы примерно на одной строке из 550, а строка без правого края мне не понравилась сразу. Остаются редкие строки вроде первой строки абзаца из длинных слов или строки перед адресом: двигать там нечего.&lt;/p&gt;
&lt;h2 id="как-это-сделано"&gt;&lt;a href="#%d0%ba%d0%b0%d0%ba-%d1%8d%d1%82%d0%be-%d1%81%d0%b4%d0%b5%d0%bb%d0%b0%d0%bd%d0%be" class="post_hash"&gt;Как это сделано&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Движок лежит сабмодулем и не форкнут. Приложение несёт к нему патчи: при сборке CMake копирует нужные файлы движка в каталог сборки, накладывает на них патчи и компилирует копии. Вторым таким патчем стала правка, она меняет только форматирование абзаца в &lt;code&gt;lvtextfm.cpp&lt;/code&gt;. Документ при этом не трогается, поэтому закладки, выделения и места чтения, которые ходят на сервер, указывают на тот же текст.&lt;/p&gt;
&lt;p&gt;Словарь выбирает небольшая функция в JNI-слое при открытии книги. Рядом появился тест, который на настоящем экране эмулятора мерит пустые колонки внутри строки. Без патча он падает: 65 пикселей против предела в 47. Заодно пришлось заново принять контрольные страницы, с которыми сверяется вёрстка.&lt;/p&gt;
&lt;p&gt;Измерительный стенд остался в репозитории: патч с записью в CSV накладывается поверх патчей приложения и может отключить правку, так что сравнение с жадной вёрсткой всегда воспроизводится. Весь разбор с цифрами лежит в &lt;code&gt;docs/spikes/word-gaps-preview.md&lt;/code&gt;&lt;sup id="fnref:10"&gt;&lt;a href="#fn:10" class="footnote-ref" role="doc-noteref"&gt;10&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;p&gt;Релиз собирается только в GitHub Actions: тег &lt;code&gt;v0.1.0-rc5&lt;/code&gt;, подписанные APK для трёх ABI, заметки из коммитов. Перед тегом прошли сборка, линтер и unit-тесты, а на эмуляторе тесты движка, экранов и синхронизации с bookOrbit.&lt;/p&gt;
&lt;p&gt;Так выглядит контрольная страница из тестов приложения, это PT Serif на узкой странице, самом неудобном случае. Строка &lt;code&gt;путешественники переговаривались&lt;/code&gt; больше не разорвана пустотой, а цена видна сразу: переносов стало больше.&lt;/p&gt;
&lt;p&gt;&lt;figure class="image-figure"&gt;&lt;img src="https://static.juev.org/2026/10/perigee-justify-result-app.png"
alt="Контрольная страница приложения до и после"
loading="lazy"
decoding="async"&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;Сборку rc5 я проверил на своём телефоне, и она мне понравилась. Время первого открытия на телефоне мы не замеряли, цифры выше сняты на компьютере.&lt;/p&gt;
&lt;p&gt;Perigee пока называется рабочим именем и в магазинах его нет, сборки лежат в релизах на GitHub. Лицензия GPL-3.0.&lt;/p&gt;
&lt;div class="footnotes" role="doc-endnotes"&gt;
&lt;hr&gt;
&lt;ol&gt;
&lt;li id="fn:1"&gt;
&lt;p&gt;&lt;a href="https://github.com/juev/perigee"&gt;juev/perigee&lt;/a&gt;, репозиторий проекта.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:2"&gt;
&lt;p&gt;&lt;a href="https://github.com/juev/perigee/releases/tag/v0.1.0-rc5"&gt;Perigee 0.1.0-rc5&lt;/a&gt;, подписанные APK.&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:3"&gt;
&lt;p&gt;&lt;a href="https://github.com/bookorbit/bookorbit"&gt;bookOrbit&lt;/a&gt;, сервер библиотеки.&amp;#160;&lt;a href="#fnref:3" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:4"&gt;
&lt;p&gt;&lt;a href="https://github.com/koreader/crengine"&gt;koreader/crengine&lt;/a&gt;, движок вёрстки KOReader.&amp;#160;&lt;a href="#fnref:4" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:5"&gt;
&lt;p&gt;&lt;a href="https://github.com/juev/perigee/issues/99"&gt;Issue #99&lt;/a&gt;, вся история замеров в комментариях.&amp;#160;&lt;a href="#fnref:5" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:6"&gt;
&lt;p&gt;&lt;a href="https://helpx.adobe.com/indesign/desktop/format-and-style-text/tabs-indents-and-spacing/adjust-text-spacing.html"&gt;Настройки выравнивания в InDesign&lt;/a&gt;, по умолчанию межсловный пробел от 80 до 133 %.&amp;#160;&lt;a href="#fnref:6" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:7"&gt;
&lt;p&gt;Donald Knuth, Michael Plass, Breaking Paragraphs into Lines, 1981; &lt;a href="https://en.wikipedia.org/wiki/Knuth%E2%80%93Plass_line-breaking_algorithm"&gt;краткое описание алгоритма&lt;/a&gt;.&amp;#160;&lt;a href="#fnref:7" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:8"&gt;
&lt;p&gt;&lt;a href="https://helpx.adobe.com/indesign/desktop/format-and-style-text/composition-and-text-wrapping/set-text-composition.html"&gt;Set text composition&lt;/a&gt;, описание Paragraph Composer.&amp;#160;&lt;a href="#fnref:8" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:9"&gt;
&lt;p&gt;&lt;a href="https://github.com/hanspinckaers/koreader-knuth-justification"&gt;koreader-knuth-justification&lt;/a&gt;, постраничная разбивка для KOReader.&amp;#160;&lt;a href="#fnref:9" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:10"&gt;
&lt;p&gt;&lt;a href="https://github.com/juev/perigee/blob/main/docs/spikes/word-gaps-preview.md"&gt;word-gaps-preview.md&lt;/a&gt;, разбор с таблицами и командами для воспроизведения.&amp;#160;&lt;a href="#fnref:10" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
&lt;p&gt;Читайте оригинал на странице: &lt;a href="https://www.juev.org/2026/10/31/perigee-justification/" alt="Perigee: своя читалка и разреженные строки"&gt;Perigee: своя читалка и разреженные строки&lt;/a&gt; (Denis Evsyukov)&lt;/p&gt;</description></item><item><title>Ссылки #414</title><link>https://www.juev.org/2026/10/31/weblinks-414/</link><pubDate>Sat, 31 Oct 2026 07:06:23 +0300</pubDate><author>denis@evsyukov.org (Denis Evsyukov)</author><guid>https://www.juev.org/2026/10/31/weblinks-414/</guid><description>&lt;p&gt;На этой неделе в подборке: Claude, формально доказавший безопасность 123 open-source библиотек, почтовый сервер Stalwart v1.0, обогнавший Postfix и Dovecot, расследование инцидента безопасности Tailscale SSH и двенадцать лет распределённого SQLite в rqlite.&lt;/p&gt;
&lt;h2 id="-ии"&gt;&lt;a href="#-%d0%b8%d0%b8" class="post_hash"&gt;&#129302; ИИ&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.anthropic.com/research/formal-verification"&gt;LLMs are making formal software verification feasible at a much larger scale \ Anthropic&lt;/a&gt;&lt;/strong&gt;
Claude формально доказал свойства безопасности в скомпилированном коде 123 библиотек с открытым исходным кодом. В ходе проверки он обнаружил ошибки, которые годами оставались незамеченными.&lt;/p&gt;
&lt;h2 id="-инфраструктура"&gt;&lt;a href="#-%d0%b8%d0%bd%d1%84%d1%80%d0%b0%d1%81%d1%82%d1%80%d1%83%d0%ba%d1%82%d1%83%d1%80%d0%b0" class="post_hash"&gt;&#128421;️ Инфраструктура&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://stalw.art/blog/stalwart-1-0/"&gt;Stalwart v1.0: The Fastest Mail Server on the Planet | Stalwart&lt;/a&gt;&lt;/strong&gt;
Stalwart v1.0 — первый стабильный выпуск почтового сервера после пяти лет разработки; в нём зафиксирована схема базы данных и переписан поисковый движок. По результатам тестирования с профилированием и нагрузочными испытаниями Stalwart показал более высокую производительность, чем Postfix и Dovecot, в 35 из 38 тестов.&lt;/p&gt;
&lt;h2 id="-безопасность"&gt;&lt;a href="#-%d0%b1%d0%b5%d0%b7%d0%be%d0%bf%d0%b0%d1%81%d0%bd%d0%be%d1%81%d1%82%d1%8c" class="post_hash"&gt;&#128272; Безопасность&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://tailscale.com/ssh-investigation-update"&gt;Tailscale SSH: Investigation and remediation update&lt;/a&gt;&lt;/strong&gt;
Обновление посвящено расследованию безопасности Tailscale SSH и ходу работ по устранению проблем. Также в нём говорится, что клиенты могут сделать сейчас, но конкретные меры и результаты в кратком описании не указаны.&lt;/p&gt;
&lt;h2 id="-базы-данных"&gt;&lt;a href="#-%d0%b1%d0%b0%d0%b7%d1%8b-%d0%b4%d0%b0%d0%bd%d0%bd%d1%8b%d1%85" class="post_hash"&gt;&#128452;️ Базы данных&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://philipotoole.com/twelve-years-of-distributed-sqlite/"&gt;Twelve years of distributed SQLite – Philip O&amp;rsquo;Toole&lt;/a&gt;&lt;/strong&gt;
rqlite — проект, объявленный в 2014 году: он добавляет журнал Raft перед SQLite и реплицирует каждую запись между узлами кластера. Цель такого подхода — сохранить данные при потере узла, однако в исходном описании представлена идея проекта, а не результаты его дальнейшей работы.&lt;/p&gt;
&lt;h2 id="а-ещё"&gt;&lt;a href="#%d0%b0-%d0%b5%d1%89%d1%91" class="post_hash"&gt;А ещё&lt;/a&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/theconsensuslabs/noalloc"&gt;GitHub - theconsensuslabs/noalloc: Tooling and libraries to enforce zero heap allocations in Golang · GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.brangus.io/survival-llm"&gt;Survival LLM&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://xn--gckvb8fzb.com/inbox-zero-for-privacy-not-productivity/"&gt;マリウス . Inbox Zero for Privacy, not Productivity&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://magazine.sebastianraschka.com/p/building-reasoning-models"&gt;Build Your Own Reasoning Model: A Full Video Course&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://simonwillison.net/2026/Oct/26/gpt-61-terra/"&gt;GPT-6.1 Terra&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://taonaw.com/2026/10/26/how-do-you-read-it.html"&gt;How do you Read it Later? | The Art Of Not Asking Why&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.cerebras.ai/blog/how-we-deploy-trillion-parameter-frontier-models-on-cerebras"&gt;How Cerebras Deploys Trillion-Parameter AI Models&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.dbreunig.com/2026/10/24/the-state-of-context-engineering.html"&gt;The State of Context Engineering&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://bolknote.ru/all/kakoj-azyk-lucse-podhodit-dla-promptov"&gt;Какой язык лучше подходит для промптов? — Евгений Степанищев&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://venturebeat.com/orchestration/a-coding-agents-harness-can-triple-its-cost-without-making-it-more-accurate-study-finds"&gt;A coding agent’s harness can triple its cost without making it more accurate, study finds | VentureBeat&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://keepandroidopen.org/?"&gt;Keep Android Open&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://grapheneos.social/@GrapheneOS/117492375462459244"&gt;GrapheneOS: &amp;ldquo;@maya@gs.lexinova.eu @OmegaPolice@hachyderm.io @b…&amp;rdquo; - GrapheneOS Mastodon&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://alexsci.com/blog/uuidv7-is-weird/"&gt;UUIDv7 doesn&amp;rsquo;t have the properties you think it does&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://zachlandes.com/trolley-problem/"&gt;Jev Meets the Trolley Problem · Zach Landes&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;За прошедшую неделю собрал 39 ссылок.&lt;/p&gt;
&lt;p&gt;Хорошей вам недели!&lt;/p&gt;
&lt;p&gt;Читайте оригинал на странице: &lt;a href="https://www.juev.org/2026/10/31/weblinks-414/" alt="Ссылки #414"&gt;Ссылки #414&lt;/a&gt; (Denis Evsyukov)&lt;/p&gt;</description></item><item><title>go-cache-lru: три версии за день и сутки под лимитом</title><link>https://www.juev.org/2026/10/29/go-cache-lru-update/</link><pubDate>Thu, 29 Oct 2026 17:00:00 +0300</pubDate><author>denis@evsyukov.org (Denis Evsyukov)</author><guid>https://www.juev.org/2026/10/29/go-cache-lru-update/</guid><description>&lt;p&gt;Позавчера я писал про &lt;a href="https://www.juev.org/2026/10/27/go-cache-lru/"&gt;go-cache-lru&lt;/a&gt;, реализацию GOCACHEPROG, которая держит кеш сборки Go в заданном размере. Тот пост заканчивался списком того, что не проверено: вытеснение в реальной работе ни разу не срабатывало, а Windows не поддерживалась. 28 октября вышли версии 0.2.0, 0.3.0 и 0.4.0&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt;. Ниже — что в них изменилось и как кеш прожил следующие сутки.&lt;/p&gt;
&lt;h2 id="020-вытеснение-без-пауз-и-тихий-демон"&gt;&lt;a href="#020-%d0%b2%d1%8b%d1%82%d0%b5%d1%81%d0%bd%d0%b5%d0%bd%d0%b8%d0%b5-%d0%b1%d0%b5%d0%b7-%d0%bf%d0%b0%d1%83%d0%b7-%d0%b8-%d1%82%d0%b8%d1%85%d0%b8%d0%b9-%d0%b4%d0%b5%d0%bc%d0%be%d0%bd" class="post_hash"&gt;0.2.0: вытеснение без пауз и тихий демон&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Утром 28 октября кеш дошёл до 25 ГиБ из 30, не вытеснив ни одной записи&lt;sup id="fnref:2"&gt;&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref"&gt;2&lt;/a&gt;&lt;/sup&gt;. Первый прогон вытеснения должен был удалить около 5 ГиБ, и тут всплыла проблема: файлы объектов удалялись под мьютексом кеша. Пока шла пачка из 256 удалений, ждали все &lt;code&gt;get&lt;/code&gt; и &lt;code&gt;put&lt;/code&gt; всех команд &lt;code&gt;go&lt;/code&gt;. Теперь под блокировкой объект только помечается, а файл удаляется после её снятия. Бенчмарк на 2000 объектах по 256 КиБ показал, как изменилась задержка &lt;code&gt;get&lt;/code&gt; во время вытеснения:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;До&lt;/th&gt;
&lt;th&gt;После&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Максимальная задержка &lt;code&gt;get&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;30–38 мс&lt;/td&gt;
&lt;td&gt;0.12–0.16 мс&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get&lt;/code&gt; дольше 1 мс&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get&lt;/code&gt;, обслуженных за время вытеснения&lt;/td&gt;
&lt;td&gt;около 650&lt;/td&gt;
&lt;td&gt;около 30 000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Само вытеснение быстрее не стало, но сборки его больше не ждут.&lt;/p&gt;
&lt;p&gt;Запись объектов стала дешевле. Тело &lt;code&gt;put&lt;/code&gt; приходит в base64 и декодируется кусками не больше 768 байт, а &lt;code&gt;io.Copy&lt;/code&gt; превращал каждый кусок в отдельный системный вызов &lt;code&gt;write&lt;/code&gt;. На холодной сборке, которая положила в кеш 1.8 ГиБ, демон потратил на эти вызовы 12.7 с из 16 с процессорного времени&lt;sup id="fnref:3"&gt;&lt;a href="#fn:3" class="footnote-ref" role="doc-noteref"&gt;3&lt;/a&gt;&lt;/sup&gt;. Буфер перед временным файлом поднял скорость записи в бенчмарке со 156–175 до 486–500 МБ/с. Второе узкое место — сокет между командой &lt;code&gt;go&lt;/code&gt; и демоном: macOS даёт unix-сокетам буферы по 8 КиБ, и данные шли мелкими порциями. Обе стороны теперь просят 1 МиБ. На холодном &lt;code&gt;go test -race&lt;/code&gt; большого сервиса процессорное время демона упало с 8.5 до 7.8 с, а промежуточного процесса — с 3.4 до 2.1 с.&lt;/p&gt;
&lt;p&gt;Демон перестал просыпаться без дела. Без единой команды &lt;code&gt;go&lt;/code&gt; он всё равно просыпался примерно раз в секунду: индекс сбрасывался по таймеру каждые 100 мс, метка &lt;code&gt;go clean -cache&lt;/code&gt; проверялась каждые 2 с, а &lt;code&gt;stats.json&lt;/code&gt; перезаписывался каждые 30 с. Теперь каждый таймер работает только при наличии работы. По &lt;code&gt;top -stats idlew&lt;/code&gt; за три минуты простоя после сборки было 129 пробуждений, стало 0.&lt;/p&gt;
&lt;p&gt;Ещё два изменения про удобство. Демон замечает, что его бинарник заменили, и завершается, так что после обновления больше не нужен &lt;code&gt;go-cache-lru stop&lt;/code&gt;. &lt;code&gt;stats -v&lt;/code&gt; показывает, чем занят кеш: объекты по размеру и по времени последнего обращения. Раньше для этого приходилось лезть в &lt;code&gt;index.db&lt;/code&gt; через &lt;code&gt;sqlite3&lt;/code&gt;, и так выяснилось, что 72 % кеша в 25 ГиБ занимали 407 архивов пакетов примерно по 90 МиБ&lt;sup id="fnref:4"&gt;&lt;a href="#fn:4" class="footnote-ref" role="doc-noteref"&gt;4&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;h2 id="030-история-по-часам"&gt;&lt;a href="#030-%d0%b8%d1%81%d1%82%d0%be%d1%80%d0%b8%d1%8f-%d0%bf%d0%be-%d1%87%d0%b0%d1%81%d0%b0%d0%bc" class="post_hash"&gt;0.3.0: история по часам&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Накопительные счётчики плохо показывают, как кеш работает сейчас: в процент попаданий с первого запуска входит прогрев. На одной машине &lt;code&gt;stats&lt;/code&gt; давал 96.7 % за 22 часа, хотя за последние три с половиной часа было 98.7 %&lt;sup id="fnref:5"&gt;&lt;a href="#fn:5" class="footnote-ref" role="doc-noteref"&gt;5&lt;/a&gt;&lt;/sup&gt;. Увидеть второе число можно было, только вычитая два снимка руками.&lt;/p&gt;
&lt;p&gt;Теперь демон хранит изменения счётчиков по часам за 7 дней в той же базе SQLite, вместе с пиковым размером кеша за час. &lt;code&gt;stats&lt;/code&gt; показывает текущий час, сутки и неделю, &lt;code&gt;stats -v&lt;/code&gt; добавляет почасовую таблицу. По этой истории &lt;code&gt;stats&lt;/code&gt; даёт совет про &lt;code&gt;--max-size&lt;/code&gt;. Пока часов с командами &lt;code&gt;go&lt;/code&gt; меньше 24, он ждёт данных. Если вытеснений не было, а пик не дошёл до 60 % лимита, совет — пик плюс 25 %. Если вытеснения были, он сравнивает долю промахов в час после вытеснения с обычной. Когда после трёх и более вытеснений промахов в полтора раза больше, он предлагает лимит на 50 % выше. Пороги — первая прикидка, их надо проверить на настоящей истории.&lt;/p&gt;
&lt;p&gt;Обновление стало мягче. В 0.2.0 демон, заметив новый бинарник, сразу переставал принимать подключения, и новые команды &lt;code&gt;go&lt;/code&gt; ждали, пока закончатся старые. Теперь он продолжает обслуживать и выходит в первый момент, когда к нему не подключена ни одна команда &lt;code&gt;go&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Управляющим командам больше не нужен &lt;code&gt;--dir&lt;/code&gt;. &lt;code&gt;stats&lt;/code&gt;, &lt;code&gt;trim&lt;/code&gt;, &lt;code&gt;clean&lt;/code&gt; и &lt;code&gt;stop&lt;/code&gt; берут флаги из &lt;code&gt;GOCACHEPROG&lt;/code&gt; в окружении или в &lt;code&gt;go env&lt;/code&gt;, поэтому статистика теперь — просто &lt;code&gt;go-cache-lru stats&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="040-windows"&gt;&lt;a href="#040-windows" class="post_hash"&gt;0.4.0: Windows&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Блокировка демона и запуск в фоне работали через &lt;code&gt;flock&lt;/code&gt; и &lt;code&gt;setsid&lt;/code&gt;, которых в Windows нет. Теперь там используются &lt;code&gt;LockFileEx&lt;/code&gt; и отдельная группа процессов, а различия собраны в файлах &lt;code&gt;*_windows.go&lt;/code&gt;. По дороге нашлись две ошибки: путь с буквой диска ломал URI базы SQLite, а два одновременных &lt;code&gt;put&lt;/code&gt; одного объекта переименовывали второй файл поверх первого, что Windows запрещает, пока файл кто-то читает. Содержимое у них одинаковое, поэтому теперь остаётся файл, который уже лежит на диске. Тесты гоняются в CI и на Windows, бинарники выходят zip-архивами.&lt;/p&gt;
&lt;h2 id="сутки-на-040"&gt;&lt;a href="#%d1%81%d1%83%d1%82%d0%ba%d0%b8-%d0%bd%d0%b0-040" class="post_hash"&gt;Сутки на 0.4.0&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Демон v0.4.0 запустился 28 октября в 17:22, через несколько секунд после тега, и проработал без перезапуска до вечера 29-го. Лимит прежний: &lt;code&gt;--max-size=30GiB&lt;/code&gt;, &lt;code&gt;--target-size=25GiB&lt;/code&gt;. Вот &lt;code&gt;stats -v&lt;/code&gt; на 29 октября, 17:10, путь к домашнему каталогу заменён на &lt;code&gt;~&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ go-cache-lru stats -v
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;daemon pid 48388, version v0.4.0, up 23h47m56s, 0 active sessions
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;directory ~/.cache/go-lru
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;size 25.8 GiB of 30.0 GiB (target 25.0 GiB), 86.0%
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;entries 76155 actions, 14056 objects, 0 pinned
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;counters since 2026-10-27 16:38:51
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;hits 3542620 (97.4%), 775.3 GiB served
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;misses 95312
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;puts 174754, 80.3 GiB written
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;evictions 66727 entries, 50.6 GiB
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cleans 0 explicit, 1 by go clean -cache
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sessions 2337
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;this hour no go commands
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;last 24 hours hit rate 98.0%, 1639228 gets, 28.7 GiB written, 6 evictions
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;last 7 days hit rate 97.9%, 2169649 gets, 38.5 GiB written, 7 evictions
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;advice not enough history yet: 12 of 24 active hours
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;objects by size
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;lt; 64 KiB 5675 43.2 MiB 0.2%
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 64 KiB - 1 MiB 5823 2.4 GiB 9.3%
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 1 MiB - 10 MiB 2096 4.8 GiB 18.7%
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 10 MiB - 100 MiB 460 18.3 GiB 70.9%
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;gt;= 100 MiB 2 253.8 MiB 1.0%
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;objects by last access
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;lt; 1h 1127 375.2 MiB 1.4%
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 1h - 6h 12929 25.4 GiB 98.6%
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 6h - 24h 0 0 B 0.0%
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;gt;= 24h 0 0 B 0.0%
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;hourly, last 24 hours
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; hour gets hit rate written evictions peak size
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 18:00 710 99.7% 2.0 MiB 0 29.4 GiB
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 09:00 9140 98.3% 94.2 MiB 0 29.5 GiB
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 10:00 43177 99.6% 60.8 MiB 0 29.5 GiB
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 11:00 303009 97.7% 5.0 GiB 1 30.0 GiB
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 12:00 437705 97.8% 8.2 GiB 2 30.0 GiB
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 13:00 152856 97.1% 3.5 GiB 1 30.0 GiB
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 14:00 332627 98.3% 6.9 GiB 1 30.0 GiB
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 15:00 352247 98.3% 5.0 GiB 1 30.0 GiB
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 16:00 7757 99.6% 5.3 MiB 0 25.8 GiB
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Главное здесь — столбец &lt;code&gt;peak size&lt;/code&gt;. С 11 до 16 часов в кеш записали 28.6 ГиБ, а пик за каждый час остался на 30.0 ГиБ. Шесть раз кеш упирался в лимит, и шесть раз демон сжимал его до цели, убирая по 5–5.5 ГиБ за прогон. За всё время счётчиков вытеснено 66 727 записей, 50.6 ГиБ. До go-cache-lru кеш на этой машине дорастал до 66–85 ГБ и забивал диск.&lt;/p&gt;
&lt;p&gt;Точнее, в байтах пик выходит за лимит на 0.4–31 МиБ. Вытеснение стартует в фоне после записи, которая перешагнула лимит, и пока оно идёт, успевают лечь ещё несколько объектов. В пересчёте на 30 ГиБ это меньше 0.1 %.&lt;/p&gt;
&lt;p&gt;Доля попаданий от вытеснения не просела: в часы с вытеснением она 97.1–98.3 %, за сутки — 98 %. Кеш отдал 775.3 ГиБ при 80.3 ГиБ записанного.&lt;/p&gt;
&lt;p&gt;Две строки стоит прочитать отдельно. В &lt;code&gt;objects by last access&lt;/code&gt; всё, что осталось, использовалось за последние шесть часов, более старое вытеснено. Сборка ветки, к которой я не прикасался со вчера, начнёт с промахов. Стоит ли это лишних гигабайт на диске, должен ответить совет: на момент снимка у него 12 часов с командами &lt;code&gt;go&lt;/code&gt; из 24 нужных. А &lt;code&gt;objects by size&lt;/code&gt; подтверждает картину из 0.2.0: 460 объектов от 10 до 100 МиБ занимают 71 % места, и сколько всего поместится под лимит, решают они.&lt;/p&gt;
&lt;h2 id="что-не-проверено"&gt;&lt;a href="#%d1%87%d1%82%d0%be-%d0%bd%d0%b5-%d0%bf%d1%80%d0%be%d0%b2%d0%b5%d1%80%d0%b5%d0%bd%d0%be" class="post_hash"&gt;Что не проверено&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Совет по размеру ещё ни разу не выдал рекомендацию, и пороги в нём пока не сверены с настоящей историей. Запуск с холодным page cache по-прежнему не замерен.&lt;/p&gt;
&lt;div class="footnotes" role="doc-endnotes"&gt;
&lt;hr&gt;
&lt;ol&gt;
&lt;li id="fn:1"&gt;
&lt;p&gt;&lt;a href="https://github.com/juev/go-cache-lru/releases"&gt;Релизы go-cache-lru&lt;/a&gt;, бинарники для macOS, Linux и Windows.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:2"&gt;
&lt;p&gt;&lt;a href="https://github.com/juev/go-cache-lru/issues/3"&gt;Issue #3&lt;/a&gt;, удаление файлов вне блокировки кеша.&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:3"&gt;
&lt;p&gt;&lt;a href="https://github.com/juev/go-cache-lru/issues/12"&gt;Issue #12&lt;/a&gt;, профиль и замеры записи объектов.&amp;#160;&lt;a href="#fnref:3" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:4"&gt;
&lt;p&gt;&lt;a href="https://github.com/juev/go-cache-lru/issues/5"&gt;Issue #5&lt;/a&gt;, &lt;code&gt;stats -v&lt;/code&gt; и флаг &lt;code&gt;--version&lt;/code&gt;.&amp;#160;&lt;a href="#fnref:4" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:5"&gt;
&lt;p&gt;&lt;a href="https://github.com/juev/go-cache-lru/issues/14"&gt;Issue #14&lt;/a&gt;, почасовая статистика и правила совета.&amp;#160;&lt;a href="#fnref:5" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
&lt;p&gt;Читайте оригинал на странице: &lt;a href="https://www.juev.org/2026/10/29/go-cache-lru-update/" alt="go-cache-lru: три версии за день и сутки под лимитом"&gt;go-cache-lru: три версии за день и сутки под лимитом&lt;/a&gt; (Denis Evsyukov)&lt;/p&gt;</description></item><item><title>Hermes в телефоне: свой клиент вместо мессенджеров</title><link>https://www.juev.org/2026/10/28/hermes-android/</link><pubDate>Wed, 28 Oct 2026 17:30:00 +0300</pubDate><author>denis@evsyukov.org (Denis Evsyukov)</author><guid>https://www.juev.org/2026/10/28/hermes-android/</guid><description>&lt;p&gt;Работать с Hermes через мессенджеры неудобно, поэтому я написал для него свой клиент под Android. Сегодня вышла первая сборка, &lt;code&gt;v0.1.0-rc1&lt;/code&gt;&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;h2 id="что-такое-hermes"&gt;&lt;a href="#%d1%87%d1%82%d0%be-%d1%82%d0%b0%d0%ba%d0%be%d0%b5-hermes" class="post_hash"&gt;Что такое Hermes&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Hermes — открытый агент от Nous Research под лицензией MIT&lt;sup id="fnref:2"&gt;&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref"&gt;2&lt;/a&gt;&lt;/sup&gt;. Он работает на сервере, а не на ноутбуке: модель выбирается любая (OpenRouter, OpenAI, свой endpoint и другие), инструменты выполняются в выбранном окружении — локально, в Docker, по SSH. У агента есть память, навыки, которые он сам создаёт и правит, поиск по прошлым сессиям, планировщик cron и подключение MCP-серверов.&lt;/p&gt;
&lt;p&gt;С агентом можно говорить из терминала (&lt;code&gt;hermes&lt;/code&gt;), а можно запустить шлюз (&lt;code&gt;hermes gateway&lt;/code&gt;) и писать ему из мессенджеров. Список каналов большой: Telegram, Discord, Slack, WhatsApp, Signal, Matrix, Mattermost, Email, SMS, Microsoft Teams и ещё десяток платформ&lt;sup id="fnref:3"&gt;&lt;a href="#fn:3" class="footnote-ref" role="doc-noteref"&gt;3&lt;/a&gt;&lt;/sup&gt;. Шлюз — один процесс: он держит подключения ко всем каналам, ведёт сессии по каждому чату, запускает задачи cron и доставляет их результаты в нужный канал.&lt;/p&gt;
&lt;p&gt;Третий способ — веб-панель &lt;code&gt;hermes dashboard&lt;/code&gt;. В ней есть чат, а ещё настройки: модель, &lt;code&gt;config.yaml&lt;/code&gt;, секреты, задачи cron, навыки, каналы, журналы.&lt;/p&gt;
&lt;p&gt;У меня Hermes крутится в контейнере на домашнем сервере, каналов два: бот в Telegram и бот на моём Matrix-сервере. Память агенту даёт &lt;a href="https://www.juev.org/2026/10/27/memcue/"&gt;memcue&lt;/a&gt;, о ней я писал вчера.&lt;/p&gt;
&lt;h2 id="чем-неудобны-мессенджеры"&gt;&lt;a href="#%d1%87%d0%b5%d0%bc-%d0%bd%d0%b5%d1%83%d0%b4%d0%be%d0%b1%d0%bd%d1%8b-%d0%bc%d0%b5%d1%81%d1%81%d0%b5%d0%bd%d0%b4%d0%b6%d0%b5%d1%80%d1%8b" class="post_hash"&gt;Чем неудобны мессенджеры&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Неудобства сводятся к двум вещам: отдельный разговор трудно начать и потом трудно найти.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Треды.&lt;/strong&gt; Каждой задаче нужна своя сессия, иначе контексты разных разговоров смешиваются. В личке с ботом мессенджер даёт для этого треды, но их надо создавать руками. В Telegram это темы в личке: режим включается командой &lt;code&gt;/topic&lt;/code&gt;, а новая тема создаётся кнопкой «+»&lt;sup id="fnref:4"&gt;&lt;a href="#fn:4" class="footnote-ref" role="doc-noteref"&gt;4&lt;/a&gt;&lt;/sup&gt;. Эта схема у меня ещё и ломалась: мобильный клиент иногда присылает первое сообщение новой темы без номера темы, и ответ уходит в общий чат. Разбор этой истории — в &lt;a href="https://www.juev.org/2026/10/17/telegram-dm-topic-thread-less-opener/"&gt;отдельном посте&lt;/a&gt;. В Matrix Hermes по умолчанию не заводит треды в личке, их включает отдельная настройка, либо тред приходится начинать самому&lt;sup id="fnref:5"&gt;&lt;a href="#fn:5" class="footnote-ref" role="doc-noteref"&gt;5&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Поиск.&lt;/strong&gt; Через неделю нужно вернуться к разговору, а где он был? В Telegram или в Matrix, в какой теме или треде? Поиск мессенджера находит сообщение, но про сессии Hermes ничего не знает и ищет только в своём канале. Команда &lt;code&gt;/sessions search&lt;/code&gt; фильтрует сессии лишь по названию или идентификатору&lt;sup id="fnref1:3"&gt;&lt;a href="#fn:3" class="footnote-ref" role="doc-noteref"&gt;3&lt;/a&gt;&lt;/sup&gt;, а содержимое разговоров ей не видно.&lt;/p&gt;
&lt;h2 id="hermes-for-android"&gt;&lt;a href="#hermes-for-android" class="post_hash"&gt;Hermes for Android&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Приложение — клиент для &lt;code&gt;hermes dashboard&lt;/code&gt;, проверенное на Hermes v0.22.5&lt;sup id="fnref:6"&gt;&lt;a href="#fn:6" class="footnote-ref" role="doc-noteref"&gt;6&lt;/a&gt;&lt;/sup&gt;. Сервер ставить отдельно не нужно: приложение пользуется тем же API, что и веб-панель. Код на Kotlin и Jetpack Compose, лицензия MIT.&lt;/p&gt;
&lt;h3 id="чат"&gt;&lt;a href="#%d1%87%d0%b0%d1%82" class="post_hash"&gt;Чат&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Чат устроен как в приложении ChatGPT. Список чатов — в боковой панели. Каждый чат — отдельная сессия, и новый открывается кнопкой с карандашом или если потянуть ленту вверх за последнее сообщение. Создавать тред руками больше не нужно. Ответ приходит потоком через WebSocket панели: Markdown, рассуждения модели над ответом, а вызовы инструментов свёрнуты в одну строку, которую можно раскрыть. Код в ответе копируется одним нажатием. На вопросы агента и запросы на подтверждение отвечают кнопками на панели, которая выезжает снизу чата.&lt;/p&gt;
&lt;p&gt;В боковой панели чаты сгруппированы по дате и по источнику: свои, из Telegram, из Matrix. Закреплённые чаты сверху, архив и запуски cron — в отдельных разделах. Поиск идёт по полнотекстовому индексу на сервере, то есть по тексту разговоров, и находит в том числе то, что писалось из Telegram и Matrix.&lt;/p&gt;
&lt;p&gt;Вложения: фото, камера, любой файл. Картинки, PDF, Markdown, текст и код, аудио и видео открываются внутри приложения, в том числе файлы, которые прислал сам агент. Диктовка отправляется в тот провайдер распознавания речи, который настроен в Hermes.&lt;/p&gt;
&lt;h3 id="работа-в-фоне-и-уведомления"&gt;&lt;a href="#%d1%80%d0%b0%d0%b1%d0%be%d1%82%d0%b0-%d0%b2-%d1%84%d0%be%d0%bd%d0%b5-%d0%b8-%d1%83%d0%b2%d0%b5%d0%b4%d0%be%d0%bc%d0%bb%d0%b5%d0%bd%d0%b8%d1%8f" class="post_hash"&gt;Работа в фоне и уведомления&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Ход агента не обрывается, если свернуть приложение: сокет держит foreground-сервис, а ответ приходит уведомлением. На запрос подтверждения можно ответить прямо из уведомления.&lt;/p&gt;
&lt;p&gt;Чтобы узнавать о готовых ответах без постоянного соединения, в репозитории лежит плагин &lt;code&gt;hermes-push&lt;/code&gt; для сервера. Он отправляет Web Push после каждого хода в приложении, в Telegram, в Matrix или в cron. Доставка идёт через UnifiedPush, например через ntfy. Сообщение шифруется ключами телефона (RFC 8291), так что сервер push видит только шифротекст. Без плагина приложение проверяет новости периодически, не чаще раза в 15 минут, или держит постоянное соединение, если его включить: своего push у панели нет.&lt;/p&gt;
&lt;h3 id="управление-сервером"&gt;&lt;a href="#%d1%83%d0%bf%d1%80%d0%b0%d0%b2%d0%bb%d0%b5%d0%bd%d0%b8%d0%b5-%d1%81%d0%b5%d1%80%d0%b2%d0%b5%d1%80%d0%be%d0%bc" class="post_hash"&gt;Управление сервером&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Раздел сервера показывает состояние и позволяет менять то, ради чего раньше открывался SSH: шлюз, модель, &lt;code&gt;config.yaml&lt;/code&gt; (по схеме или сырым YAML), секреты &lt;code&gt;.env&lt;/code&gt;, задачи cron, навыки, наборы инструментов, MCP-серверы и каналы. Здесь же журналы и расход токенов. Всё, что может сломать или удалить что-то на сервере, сначала спрашивает подтверждение.&lt;/p&gt;
&lt;h3 id="соединение"&gt;&lt;a href="#%d1%81%d0%be%d0%b5%d0%b4%d0%b8%d0%bd%d0%b5%d0%bd%d0%b8%d0%b5" class="post_hash"&gt;Соединение&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;У каждого сервера свой маршрут:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Маршрут&lt;/th&gt;
&lt;th&gt;Когда&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Напрямую&lt;/td&gt;
&lt;td&gt;Телефон видит панель сам: публичный адрес, приложение Tailscale, домашний Wi-Fi&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Встроенный Tailscale&lt;/td&gt;
&lt;td&gt;Приложение само входит в tailnet через &lt;code&gt;tsnet&lt;/code&gt;, слот VPN не нужен&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SOCKS5, HTTP&lt;/td&gt;
&lt;td&gt;Есть прокси, который видит сервер; логин по желанию&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Встроенный Tailscale — это узел Tailscale, собранный из Go через gomobile и запущенный внутри процесса приложения. &lt;code&gt;VpnService&lt;/code&gt; он не использует, поэтому работает рядом с любым другим VPN. Узлу нужен адрес control-сервера Tailscale или Headscale и, по желанию, pre-auth ключ; без ключа приложение покажет ссылку для входа. Журналы узла остаются на телефоне.&lt;/p&gt;
&lt;p&gt;Сервер можно добавить по ссылке, чтобы не набирать ключ на телефоне:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;org.evsyukov.hermes://add-server?url=hermes.example.org&amp;amp;route=tailscale&amp;amp;control=https://hs.example.org&amp;amp;key=&amp;lt;pre-auth key&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Вход идёт через поток &lt;code&gt;native_pkce&lt;/code&gt; панели. Если сервер доступен напрямую, вход открывается в Custom Tab, и там работают passkey. Если сервер за прокси или во встроенном tailnet, страница входа открывается во WebView, чей трафик идёт тем же маршрутом.&lt;/p&gt;
&lt;h3 id="интерфейс-и-язык"&gt;&lt;a href="#%d0%b8%d0%bd%d1%82%d0%b5%d1%80%d1%84%d0%b5%d0%b9%d1%81-%d0%b8-%d1%8f%d0%b7%d1%8b%d0%ba" class="post_hash"&gt;Интерфейс и язык&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;Интерфейс на английском с русским переводом. На Android 13 и новее язык приложения можно выбрать отдельно от системного.&lt;/p&gt;
&lt;h2 id="ограничения-и-статус"&gt;&lt;a href="#%d0%be%d0%b3%d1%80%d0%b0%d0%bd%d0%b8%d1%87%d0%b5%d0%bd%d0%b8%d1%8f-%d0%b8-%d1%81%d1%82%d0%b0%d1%82%d1%83%d1%81" class="post_hash"&gt;Ограничения и статус&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Это первая сборка, и пока выходят только release candidate. Приложение совсем новое, и проверено оно пока на одном сервере — моём. Известные ограничения перечислены в README:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;без плагина &lt;code&gt;hermes-push&lt;/code&gt; новости приходят с задержкой или требуют постоянного соединения;&lt;/li&gt;
&lt;li&gt;на обычном &lt;code&gt;hermes dashboard&lt;/code&gt; REST-методы сессий не трогают сессии, созданные в процессе панели. Поэтому переименование, архив и удаление идут через сокет, а закрепить такой чат может не получиться: метода для этого в сокете нет;&lt;/li&gt;
&lt;li&gt;режим панели со шлюзом (&lt;code&gt;session_authority&lt;/code&gt;) поддержан только для основных вызовов.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;APK подписан, собран для arm64 и требует Android 12 или новее. Скачать можно на странице релизов&lt;sup id="fnref1:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;h2 id="попробуйте"&gt;&lt;a href="#%d0%bf%d0%be%d0%bf%d1%80%d0%be%d0%b1%d1%83%d0%b9%d1%82%d0%b5" class="post_hash"&gt;Попробуйте&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Если вы держите Hermes у себя и пишете ему с телефона, поставьте приложение и скажите, что не так. Что неудобно, чего не хватает, на каком сервере и маршруте что-то сломалось — всё это лучше всего оставлять в issue в &lt;a href="https://github.com/juev/hermes/issues"&gt;juev/hermes&lt;/a&gt;. Пожелания по функциям туда же: приложение только начинается, и сейчас самое время решить, каким оно будет.&lt;/p&gt;
&lt;div class="footnotes" role="doc-endnotes"&gt;
&lt;hr&gt;
&lt;ol&gt;
&lt;li id="fn:1"&gt;
&lt;p&gt;&lt;a href="https://github.com/juev/hermes/releases/tag/v0.1.0-rc1"&gt;Hermes 0.1.0-rc1&lt;/a&gt;, pre-release, файл &lt;code&gt;hermes-0.1.0-rc1.apk&lt;/code&gt;.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&amp;#160;&lt;a href="#fnref1:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:2"&gt;
&lt;p&gt;&lt;a href="https://github.com/NousResearch/hermes-agent"&gt;NousResearch/hermes-agent&lt;/a&gt;, README и &lt;a href="https://hermes-agent.nousresearch.com/docs/"&gt;документация&lt;/a&gt;.&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:3"&gt;
&lt;p&gt;&lt;a href="https://hermes-agent.nousresearch.com/docs/user-guide/messaging"&gt;Messaging Gateway&lt;/a&gt;, список платформ и команд внутри чата, в том числе &lt;code&gt;/sessions&lt;/code&gt;, &lt;code&gt;/resume&lt;/code&gt;, &lt;code&gt;/approve&lt;/code&gt;, &lt;code&gt;/deny&lt;/code&gt;.&amp;#160;&lt;a href="#fnref:3" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&amp;#160;&lt;a href="#fnref1:3" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:4"&gt;
&lt;p&gt;&lt;a href="https://hermes-agent.nousresearch.com/docs/user-guide/messaging/telegram"&gt;Telegram&lt;/a&gt;, раздел про темы в личке и режим &lt;code&gt;/topic&lt;/code&gt;.&amp;#160;&lt;a href="#fnref:4" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:5"&gt;
&lt;p&gt;&lt;a href="https://hermes-agent.nousresearch.com/docs/user-guide/messaging/matrix"&gt;Matrix&lt;/a&gt;: треды в личке включают &lt;code&gt;MATRIX_DM_AUTO_THREAD&lt;/code&gt; или &lt;code&gt;MATRIX_DM_MENTION_THREADS&lt;/code&gt;, по умолчанию обе выключены.&amp;#160;&lt;a href="#fnref:5" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:6"&gt;
&lt;p&gt;&lt;a href="https://github.com/juev/hermes"&gt;juev/hermes&lt;/a&gt;, README.&amp;#160;&lt;a href="#fnref:6" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
&lt;p&gt;Читайте оригинал на странице: &lt;a href="https://www.juev.org/2026/10/28/hermes-android/" alt="Hermes в телефоне: свой клиент вместо мессенджеров"&gt;Hermes в телефоне: свой клиент вместо мессенджеров&lt;/a&gt; (Denis Evsyukov)&lt;/p&gt;</description></item><item><title>go-cache-lru: кеш сборки Go с ограничением по размеру</title><link>https://www.juev.org/2026/10/27/go-cache-lru/</link><pubDate>Tue, 27 Oct 2026 17:45:00 +0300</pubDate><author>denis@evsyukov.org (Denis Evsyukov)</author><guid>https://www.juev.org/2026/10/27/go-cache-lru/</guid><description>&lt;p&gt;go-cache-lru — реализация GOCACHEPROG, которая держит кеш сборки Go в заданном размере&lt;sup id="fnref:1"&gt;&lt;a href="#fn:1" class="footnote-ref" role="doc-noteref"&gt;1&lt;/a&gt;&lt;/sup&gt;. Один демон на каталог кеша обслуживает все команды &lt;code&gt;go&lt;/code&gt;, а когда кеш перерастает лимит, удаляет записи, к которым дольше всего не обращались. Есть второй уровень хранения: каталог на сетевом диске или S3-совместимое хранилище. Сегодня вышла версия 0.1.0&lt;sup id="fnref:2"&gt;&lt;a href="#fn:2" class="footnote-ref" role="doc-noteref"&gt;2&lt;/a&gt;&lt;/sup&gt;. Ниже — откуда взялась задача, почему первая версия индекса жила в файлах и почему вторая живёт в SQLite.&lt;/p&gt;
&lt;h2 id="проблема"&gt;&lt;a href="#%d0%bf%d1%80%d0%be%d0%b1%d0%bb%d0%b5%d0%bc%d0%b0" class="post_hash"&gt;Проблема&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Я работаю с git worktree: каждая задача живёт в своей копии репозитория, параллельно их бывает несколько, и в каждой постоянно идут сборки и тесты. На ноутбуке это быстро съедает диск. 14 октября кеш сборки дорос до 85 ГБ за пять дней. 20 октября на диске в 460 ГБ кеш занимал 66 ГБ, свободного места оставалось 4.7 ГБ, и &lt;code&gt;make test&lt;/code&gt; падал на линковке:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ld: write() failed, errno=28 (No space left on device)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;go clean -cache&lt;/code&gt; вернул 78 ГБ свободного места, но через несколько дней картина повторяется.&lt;/p&gt;
&lt;p&gt;Команда &lt;code&gt;go&lt;/code&gt; чистит свой кеш только по возрасту: удаляет записи, к которым не обращались пять дней. Ограничения по размеру нет. При активной работе почти всё в кеше моложе пяти дней, поэтому чистка не успевает за ростом.&lt;/p&gt;
&lt;h2 id="попытка-с--trimpath"&gt;&lt;a href="#%d0%bf%d0%be%d0%bf%d1%8b%d1%82%d0%ba%d0%b0-%d1%81--trimpath" class="post_hash"&gt;Попытка с &lt;code&gt;-trimpath&lt;/code&gt;&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Первым подозреваемым был путь к исходникам. Без &lt;code&gt;-trimpath&lt;/code&gt; абсолютный путь к пакету входит в ключ кеша, поэтому каждый worktree собирает свою копию всего. Я замерил это на рабочем репозитории: вторая копия добавляла в кеш 721 МБ, с флагом — 3 МБ. 14 октября &lt;code&gt;GOFLAGS=-trimpath&lt;/code&gt; попал в мой конфиг окружения.&lt;/p&gt;
&lt;p&gt;Копии между worktree флаг действительно убрал. Но через шесть дней кеш снова занимал 66 ГБ: рост остался, просто медленнее. В ключ входят не только пути. Разные флаги сборки дают разные записи: на том же репозитории холодный кеш после сборки и тестов занимал 1 383 МБ без &lt;code&gt;-race&lt;/code&gt; и 2 263 МБ, когда &lt;code&gt;-race&lt;/code&gt; включён только в тестах. Каждая новая ветка, каждое изменение зависимостей и каждый прогон с другими флагами добавляют новые записи, а старые живут свои пять дней. &lt;code&gt;-trimpath&lt;/code&gt; уменьшает объём, но не ограничивает его.&lt;/p&gt;
&lt;h2 id="gocacheprog"&gt;&lt;a href="#gocacheprog" class="post_hash"&gt;GOCACHEPROG&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;С Go 1.24 команде &lt;code&gt;go&lt;/code&gt; можно подставить внешнюю программу вместо встроенного кеша: переменная &lt;code&gt;GOCACHEPROG&lt;/code&gt; указывает на бинарник, &lt;code&gt;go&lt;/code&gt; запускает его и общается с ним по stdin/stdout простым JSON-протоколом&lt;sup id="fnref:3"&gt;&lt;a href="#fn:3" class="footnote-ref" role="doc-noteref"&gt;3&lt;/a&gt;&lt;/sup&gt;. Программа отвечает на &lt;code&gt;get&lt;/code&gt; и &lt;code&gt;put&lt;/code&gt; и возвращает путь к файлу на диске, откуда &lt;code&gt;go&lt;/code&gt; прочтёт объект.&lt;/p&gt;
&lt;p&gt;Готовые реализации уже есть, я посмотрел четыре: tailscale/go-cache-plugin, platacard/cacheprog, richardartoul/gobuildcache и bradfitz/go-tool-cache&lt;sup id="fnref:4"&gt;&lt;a href="#fn:4" class="footnote-ref" role="doc-noteref"&gt;4&lt;/a&gt;&lt;/sup&gt;. Все они про общий удалённый кеш для CI, где каталог живёт одну задачу. Ни одна не ограничивает локальный диск. В README platacard/cacheprog прямо сказано, что сборки мусора на диске нет, в README gobuildcache — что нет никакой автоматической чистки ни локального, ни удалённого кеша. Мне нужно обратное: долгоживущий локальный кеш с жёстким лимитом. Так появился go-cache-lru.&lt;/p&gt;
&lt;h2 id="как-устроено"&gt;&lt;a href="#%d0%ba%d0%b0%d0%ba-%d1%83%d1%81%d1%82%d1%80%d0%be%d0%b5%d0%bd%d0%be" class="post_hash"&gt;Как устроено&lt;/a&gt;&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;go build ──stdin/stdout──▶ go-cache-lru ──unix socket──┐
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;go test ──stdin/stdout──▶ go-cache-lru ──unix socket──┼──▶ daemon ──▶ local dir
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;go vet ──stdin/stdout──▶ go-cache-lru ──unix socket──┘ │ (LRU, pins)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └────▶ remote (optional)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Каждая команда &lt;code&gt;go&lt;/code&gt; запускает свой экземпляр &lt;code&gt;go-cache-lru&lt;/code&gt;, но этот процесс только пересылает байты демону через unix-сокет. Первая команда запускает демон, остальные подключаются к нему, в том числе параллельные сборки из разных worktree. Один демон на каталог гарантирует &lt;code&gt;flock&lt;/code&gt; на файле блокировки. Блокировка умирает вместе с процессом, поэтому после падения следующий запуск не застревает.&lt;/p&gt;
&lt;p&gt;Что делает демон:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Держит лимит.&lt;/strong&gt; Выше &lt;code&gt;--max-size&lt;/code&gt; (по умолчанию 30 ГиБ) он удаляет записи, к которым дольше всего не обращались, пока кеш не сожмётся до &lt;code&gt;--target-size&lt;/code&gt; (25 ГиБ). Удаление идёт в фоне, пачками по 256 записей.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Не удаляет файлы в работе.&lt;/strong&gt; Протокол требует, чтобы возвращённый файл существовал, пока команда &lt;code&gt;go&lt;/code&gt; не закроет сессию. Поэтому каждый выданный объект закреплён до конца сессии, и вытеснение его пропускает.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Хранит объект один раз.&lt;/strong&gt; Файл называется по хешу содержимого, и одинаковые результаты разных действий делят один файл. Перед записью демон проверяет, что тело совпадает с SHA-256 из имени.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Понимает &lt;code&gt;go clean -cache&lt;/code&gt;.&lt;/strong&gt; Эта команда GOCACHEPROG не запускает, а просто удаляет подкаталоги &lt;code&gt;GOCACHE&lt;/code&gt;. Демон держит в одном из них файл-метку и проверяет его раз в две секунды. Пропала метка — значит, была чистка, и демон приводит индекс в порядок.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Завершается аккуратно.&lt;/strong&gt; По &lt;code&gt;SIGTERM&lt;/code&gt; или &lt;code&gt;go-cache-lru stop&lt;/code&gt; он перестаёт принимать подключения, ждёт запущенные команды &lt;code&gt;go&lt;/code&gt; (по умолчанию минуту), дописывает очередь загрузок и сохраняет статистику.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Удалённое хранилище — второй уровень за локальным диском. Команда &lt;code&gt;go&lt;/code&gt; читает объекты только из локальных файлов, поэтому диск всегда основной. При промахе демон спрашивает удалённое хранилище (таймаут 10 секунд) и копирует объект на диск, а после локальной записи загружает объект в фоне. Поддерживаются &lt;code&gt;file://&lt;/code&gt; для каталога, например сетевой шары, и &lt;code&gt;s3://&lt;/code&gt; для AWS S3, Cloudflare R2, MinIO и других S3-совместимых сервисов. Новый драйвер реализует интерфейс из трёх методов (&lt;code&gt;Get&lt;/code&gt;, &lt;code&gt;Put&lt;/code&gt;, &lt;code&gt;Close&lt;/code&gt;) и регистрирует свою схему URL.&lt;/p&gt;
&lt;h2 id="первый-индекс-файлы"&gt;&lt;a href="#%d0%bf%d0%b5%d1%80%d0%b2%d1%8b%d0%b9-%d0%b8%d0%bd%d0%b4%d0%b5%d0%ba%d1%81-%d1%84%d0%b0%d0%b9%d0%bb%d1%8b" class="post_hash"&gt;Первый индекс: файлы&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Объекты в любом случае остаются файлами: команда &lt;code&gt;go&lt;/code&gt; читает их по путям, которые вернул демон. Вопрос был в том, где держать индекс — какому действию соответствует какой объект и когда к нему обращались последний раз.&lt;/p&gt;
&lt;p&gt;Сначала я выбрал файлы: на каждую запись индекса отдельный маленький файл, а время последнего обращения — его mtime. Доводы были такие. Каждая запись появляется одним атомарным rename, и каталог всегда согласован сам с собой. База данных дала бы два источника правды: &lt;code&gt;go clean -cache&lt;/code&gt; удаляет файлы, но не строки, а падение между rename и коммитом оставляет файл без строки или строку без файла. Пишет только демон, так что многопроцессные блокировки SQLite не нужны. И SQLite в Go — это либо cgo, либо заметно более тяжёлый бинарник.&lt;/p&gt;
&lt;p&gt;Цена — время запуска: при старте демон читает все записи индекса. Чтобы понять масштаб, я написал тест &lt;code&gt;TestOpenScale&lt;/code&gt;, который генерирует синтетический кеш и замеряет открытие. Размер взял по первой реальной статистике: 177.9 МиБ в 875 объектах, около 200 КиБ на объект и две записи индекса на объект. Полный кеш в 30 ГиБ — это примерно 150 000 объектов и 300 000 записей индекса, всего 450 000 файлов.&lt;/p&gt;
&lt;p&gt;На Mac с Apple Silicon (12 ядер, APFS) открытие такого кеша с прогретым page cache заняло от 6.6 до 15.4 секунды за шесть прогонов. По профилю CPU 97 % времени уходило в системные вызовы, 84 % — в чтение записей индекса по одной: open, read, close и stat на каждый файл. Разброс шёл за нагрузкой на машину. Демон открывает кеш до того, как начинает слушать сокет, поэтому после перезагрузки или &lt;code&gt;stop&lt;/code&gt; первая команда &lt;code&gt;go&lt;/code&gt; ждала всё это время. Холодный page cache сразу после загрузки я не замерял: для &lt;code&gt;purge&lt;/code&gt; нужен root, и там будет медленнее.&lt;/p&gt;
&lt;h2 id="второй-индекс-sqlite"&gt;&lt;a href="#%d0%b2%d1%82%d0%be%d1%80%d0%be%d0%b9-%d0%b8%d0%bd%d0%b4%d0%b5%d0%ba%d1%81-sqlite" class="post_hash"&gt;Второй индекс: SQLite&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;После этого замера индекс переехал в SQLite. Драйвер — &lt;code&gt;modernc.org/sqlite&lt;/code&gt; на чистом Go, без cgo. В базе одна строка на действие: идентификатор объекта, размер, время записи и время последнего обращения.&lt;/p&gt;
&lt;p&gt;Чтобы кеш не ждал базу, горячий путь не изменился. &lt;code&gt;get&lt;/code&gt; и &lt;code&gt;put&lt;/code&gt; работают с индексом в памяти под одним мьютексом. Изменения встают в очередь, а фоновая горутина раз в 100 мс пишет их одной транзакцией. При завершении демон дописывает остаток очереди.&lt;/p&gt;
&lt;p&gt;Расхождение базы и файлов никуда не делось, его пришлось обрабатывать явно. &lt;code&gt;go clean -cache&lt;/code&gt; удаляет файлы, а падение теряет до 100 мс изменений из очереди. Поэтому при каждом старте и после каждой обнаруженной &lt;code&gt;go clean -cache&lt;/code&gt; демон сверяет одно с другим. Он перечисляет файлы объектов по именам через &lt;code&gt;ReadDir&lt;/code&gt;, не открывая их и не вызывая stat. Строки без объекта удаляются, объекты без строки тоже. Падение стоит не больше последних 100 мс записей в кеш, но согласованность не теряется.&lt;/p&gt;
&lt;p&gt;Замер на той же машине, тот же синтетический кеш на 300 000 записей и параллельный бенчмарк пакета кеша (12 горутин, объекты по 4 КиБ, три прогона по 3 секунды):&lt;/p&gt;
&lt;p&gt;&lt;figure class="image-figure"&gt;&lt;img src="https://static.juev.org/2026/10/go-cache-lru-index.svg"
alt="Индекс в файлах и в SQLite: запуск демона, Put и Get"
loading="lazy"
decoding="async"&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Индекс в файлах&lt;/th&gt;
&lt;th&gt;Индекс в SQLite&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Открытие, прогретый page cache&lt;/td&gt;
&lt;td&gt;6.6–15.4 с за 6 прогонов&lt;/td&gt;
&lt;td&gt;0.55–0.57 с за 9 прогонов&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Put&lt;/td&gt;
&lt;td&gt;251–265 мкс&lt;/td&gt;
&lt;td&gt;110–117 мкс&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Get, попадание&lt;/td&gt;
&lt;td&gt;4.5–4.9 мкс&lt;/td&gt;
&lt;td&gt;1.43–1.47 мкс&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Память под индекс&lt;/td&gt;
&lt;td&gt;около 150 МиБ&lt;/td&gt;
&lt;td&gt;около 100 МиБ&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Индекс на диске&lt;/td&gt;
&lt;td&gt;300 000 файлов&lt;/td&gt;
&lt;td&gt;&lt;code&gt;index.db&lt;/code&gt; на 54 МБ&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Размер бинарника&lt;/td&gt;
&lt;td&gt;11.6 МиБ&lt;/td&gt;
&lt;td&gt;17.4 МиБ&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Запуск стал быстрее в 12–28 раз и перестал зависеть от нагрузки на машину. Горячий путь тоже ускорился: &lt;code&gt;put&lt;/code&gt; больше не пишет и не переименовывает файл индекса под блокировкой, а &lt;code&gt;get&lt;/code&gt; не обновляет mtime файла. Платой стали 5.8 МиБ бинарника и требование Go 1.26 для сборки из исходников — его просит драйвер SQLite. Самой команде &lt;code&gt;go&lt;/code&gt;, которая пользуется кешем, достаточно 1.24.&lt;/p&gt;
&lt;p&gt;Старый индекс в файлах импортируется в базу при первом запуске. На кеше в 300 000 записей импорт один раз занял 42 секунды. Если перед переходом выполнить &lt;code&gt;go clean -cache&lt;/code&gt;, импортировать нечего.&lt;/p&gt;
&lt;p&gt;Вся история от кодека протокола до релиза уложилась в один день: 15 коммитов 27 октября, с 16:00 до 17:40. Замер с индексом в файлах я записал в README в 17:01 вместе с объяснением, почему не SQLite. Через 13 минут индекс уже был в SQLite.&lt;/p&gt;
&lt;h2 id="как-подключить"&gt;&lt;a href="#%d0%ba%d0%b0%d0%ba-%d0%bf%d0%be%d0%b4%d0%ba%d0%bb%d1%8e%d1%87%d0%b8%d1%82%d1%8c" class="post_hash"&gt;Как подключить&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Готовые бинарники для macOS и Linux (amd64, arm64) лежат на странице релизов. Можно собрать из исходников:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;go install github.com/juev/go-cache-lru/cmd/go-cache-lru@latest
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Дальше указать бинарник в &lt;code&gt;GOCACHEPROG&lt;/code&gt;. Флаги идут в ту же переменную:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;export&lt;/span&gt; &lt;span class="nv"&gt;GOCACHEPROG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/go/bin/go-cache-lru \
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; --dir=&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.cache/go-lru \
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; --max-size=30GiB \
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="s2"&gt; --target-size=25GiB&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;То же для fish:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fish" data-lang="fish"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="na"&gt;-gx&lt;/span&gt; &lt;span class="nv"&gt;GOCACHEPROG&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/go/bin/go-cache-lru --dir=&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.cache/go-lru --max-size=30GiB --target-size=25GiB&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;GOFLAGS=-trimpath&lt;/code&gt; стоит оставить: без него каждый worktree по-прежнему заполняет кеш своими копиями, и до лимита кеш дойдёт быстрее.&lt;/p&gt;
&lt;p&gt;Отдельный каталог через &lt;code&gt;--dir&lt;/code&gt; лучше, чем &lt;code&gt;GOCACHE&lt;/code&gt;. Команда &lt;code&gt;go&lt;/code&gt; всё равно открывает &lt;code&gt;GOCACHE&lt;/code&gt; для корпуса фаззинга и отметки устаревания тестового кеша, и остатки её собственного кеша там не смешаются с файлами go-cache-lru. Перед переходом эти остатки стоит один раз удалить через &lt;code&gt;go clean -cache&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Каждый флаг можно задать и переменной окружения: &lt;code&gt;GOCACHELRU_DIR&lt;/code&gt;, &lt;code&gt;GOCACHELRU_MAX_SIZE&lt;/code&gt;, &lt;code&gt;GOCACHELRU_REMOTE&lt;/code&gt; и так далее. Флаг важнее переменной. Удалённое хранилище подключается одним флагом:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;--remote&lt;span class="o"&gt;=&lt;/span&gt;file:///Volumes/share/go-cache
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;--remote&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;s3://my-bucket/go-cache?region=eu-central-1&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;--remote&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;s3://my-bucket/go-cache?endpoint=&amp;lt;account-id&amp;gt;.r2.cloudflarestorage.com&amp;amp;region=auto&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Ключи S3 берутся из &lt;code&gt;AWS_ACCESS_KEY_ID&lt;/code&gt; и &lt;code&gt;AWS_SECRET_ACCESS_KEY&lt;/code&gt;, из файла учётных данных или из метаданных инстанса. Удалённые объекты go-cache-lru никогда не удаляет, для этого нужно правило жизненного цикла бакета.&lt;/p&gt;
&lt;p&gt;Две оговорки. Демон запоминает лимиты, с которыми запущен, поэтому после их изменения нужен &lt;code&gt;go-cache-lru stop&lt;/code&gt;: следующая команда &lt;code&gt;go&lt;/code&gt; поднимет демон с новыми значениями. То же после обновления бинарника. И программы, запущенные не из шелла, например IDE из Dock, переменную не видят и пользуются встроенным кешем.&lt;/p&gt;
&lt;h2 id="как-следить"&gt;&lt;a href="#%d0%ba%d0%b0%d0%ba-%d1%81%d0%bb%d0%b5%d0%b4%d0%b8%d1%82%d1%8c" class="post_hash"&gt;Как следить&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Управляющим командам нужен тот же &lt;code&gt;--dir&lt;/code&gt;, что и в &lt;code&gt;GOCACHEPROG&lt;/code&gt;. Главная из них — &lt;code&gt;stats&lt;/code&gt;. Так выглядит мой кеш через час после перехода:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;$ go-cache-lru --dir=$HOME/.cache/go-lru stats
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;daemon pid 12299, version v0.0.0-20261027142116-62b694c9a6b6 62b694c9a6b6, up 25m49s, 0 active sessions
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;directory ~/.cache/go-lru
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;size 4.4 GiB of 30.0 GiB (target 25.0 GiB), 14.7%
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;entries 17740 actions, 6020 objects, 0 pinned
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;counters since 2026-10-27 16:38:51
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;hits 25955 (73.8%), 5.4 GiB served
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;misses 9199
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;puts 22017, 5.1 GiB written
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;evictions 0 entries, 0 B
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;cleans 0 explicit, 1 by go clean -cache
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;sessions 21
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Счётчики переживают перезапуск демона. &lt;code&gt;stats -json&lt;/code&gt; отдаёт то же для скриптов, &lt;code&gt;stats -reset&lt;/code&gt; обнуляет счётчики. Остальные команды:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sh" data-lang="sh"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;go-cache-lru --dir&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;/.cache/go-lru trim &lt;span class="c1"&gt;# сжать до --target-size сейчас&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;go-cache-lru --dir&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;/.cache/go-lru clean &lt;span class="c1"&gt;# удалить всё, что не используется&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;go-cache-lru --dir&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;/.cache/go-lru stop &lt;span class="c1"&gt;# аккуратно остановить демон&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;go-cache-lru --dir&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;/.cache/go-lru serve &lt;span class="c1"&gt;# запустить демон на переднем плане&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Журнал демона пишется в &lt;code&gt;&amp;lt;dir&amp;gt;/gocacheprog/daemon.log&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="что-не-проверено"&gt;&lt;a href="#%d1%87%d1%82%d0%be-%d0%bd%d0%b5-%d0%bf%d1%80%d0%be%d0%b2%d0%b5%d1%80%d0%b5%d0%bd%d0%be" class="post_hash"&gt;Что не проверено&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Вытеснение в реальной работе ещё не срабатывало: в статистике выше 4.4 ГиБ из 30 и ноль вытеснений. Его покрывают тесты, в том числе сквозные с настоящей командой &lt;code&gt;go&lt;/code&gt;, но на живом кеше лимит я ещё не набрал. Запуск с холодным page cache не замерен. Windows пока не поддерживается.&lt;/p&gt;
&lt;div class="footnotes" role="doc-endnotes"&gt;
&lt;hr&gt;
&lt;ol&gt;
&lt;li id="fn:1"&gt;
&lt;p&gt;&lt;a href="https://github.com/juev/go-cache-lru"&gt;juev/go-cache-lru&lt;/a&gt;, репозиторий с README, где описаны устройство и замеры.&amp;#160;&lt;a href="#fnref:1" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:2"&gt;
&lt;p&gt;&lt;a href="https://github.com/juev/go-cache-lru/releases/tag/v0.1.0"&gt;go-cache-lru v0.1.0&lt;/a&gt;, бинарники для macOS и Linux.&amp;#160;&lt;a href="#fnref:2" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:3"&gt;
&lt;p&gt;&lt;a href="https://pkg.go.dev/cmd/go/internal/cacheprog"&gt;cmd/go/internal/cacheprog&lt;/a&gt;, описание протокола GOCACHEPROG.&amp;#160;&lt;a href="#fnref:3" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id="fn:4"&gt;
&lt;p&gt;&lt;a href="https://github.com/tailscale/go-cache-plugin"&gt;tailscale/go-cache-plugin&lt;/a&gt;, &lt;a href="https://github.com/platacard/cacheprog"&gt;platacard/cacheprog&lt;/a&gt;, &lt;a href="https://github.com/richardartoul/gobuildcache"&gt;richardartoul/gobuildcache&lt;/a&gt;, &lt;a href="https://github.com/bradfitz/go-tool-cache"&gt;bradfitz/go-tool-cache&lt;/a&gt;; README смотрел в октябре 2026.&amp;#160;&lt;a href="#fnref:4" class="footnote-backref" role="doc-backlink"&gt;&amp;#x21a9;&amp;#xfe0e;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
&lt;p&gt;Читайте оригинал на странице: &lt;a href="https://www.juev.org/2026/10/27/go-cache-lru/" alt="go-cache-lru: кеш сборки Go с ограничением по размеру"&gt;go-cache-lru: кеш сборки Go с ограничением по размеру&lt;/a&gt; (Denis Evsyukov)&lt;/p&gt;</description></item></channel></rss>