diff --git a/AGENTS.md b/AGENTS.md
index dd8ce6d..46b1f4e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -25,10 +25,12 @@ joins CI's `rails` matrix.
storage goes through Active Storage only: no SDK of our own.
- Mapitaine never requests a page of the site, and never hooks into models: a build reads the database and the
manifest of the last build, nothing else.
-- A file name is a promise: the same ids land in the same file at every build. Anything that moves them (the shard
- size, the slug, the file name) belongs in `Configuration#digest` or rewrites everything by itself.
-- A build publishes the sitemap files, then the index, then deletes, then writes the manifest. Keep that order: it
- is what lets the next build repair one that died midway.
+- A file name is a promise: the same ids land in the same file at every build. Anything that moves them belongs in
+ `Configuration#digest` (the file name), in each shard's fingerprint (the shard size), or rewrites everything by
+ itself (the slug).
+- A build renders every shard, records the names it is about to add as `pending` in the manifest, publishes the
+ sitemap files, then the index, then deletes, then writes the manifest. Keep that order: it is what lets the next
+ build repair one that died midway.
- `priority`, `changefreq` and the ping endpoints are left out on purpose.
- The README is the user guide and `docs/` the GitHub Pages site: change them with the code. The README's table and
`docs/benchmarks.html` come from the same `bench/build.rb` run.
diff --git a/README.md b/README.md
index d531d4f..56cca6a 100644
--- a/README.md
+++ b/README.md
@@ -26,8 +26,9 @@ on each run is time spent on pages that did not change. It works differently fro
- A page is one record in several languages, not a list of unrelated URLs. The `hreflang` links come from that:
every language lists all the others and itself, which search engines require before they trust any of them.
- Each file holds a fixed range of ids, so a record stays in the same file from one build to the next. A build
- asks the database one question per source, the count and the latest change of each range, and rewrites only
- the files whose answer moved. No callback in your models: `update_all`, imports and deletions are seen too.
+ asks the database one question per source, the count, the sum of the ids and the latest change of each range,
+ and rewrites only the files whose answer moved. No callback in your models: `update_all`, imports and deletions
+ are seen too, and a full build once a week catches what leaves those numbers alone.
- `lastmod` is the only hint search engines still read, so it is the date your content changed, in the files
and in the index. `priority` and `changefreq`, which Google ignores, are not written.
- Search engines are told through [IndexNow](https://www.indexnow.org) which URLs changed. The ping endpoints
@@ -148,8 +149,8 @@ bin/rails mapitaine:build # rewrite the files whose records changed
bin/rails mapitaine:rebuild # rewrite everything
```
-`Mapitaine.build` does the same from Ruby and returns what it did (`written`, `kept`, `deleted`, `url_count`,
-`submitted`, `warnings`). Run it on a schedule, every night or every hour. With Solid Queue:
+`Mapitaine.build` does the same from Ruby and returns what it did (`full`, `written`, `kept`, `deleted`,
+`url_count`, `submitted`, `warnings`). Run it on a schedule, every night or every hour. With Solid Queue:
```yaml
# config/recurring.yml
@@ -159,9 +160,27 @@ production:
schedule: every hour
```
-A build writes the sitemap files first, then the index, then removes the files no range fills any more, then
-writes `sitemap-manifest.json`, its memory of what each file held. A build that fails midway is repaired by the
-next one. Changing the host, the languages or the version of Mapitaine rewrites everything once.
+### How a build decides
+
+For each source, one query groups the records by range of ids and returns the count, the sum of the ids and the
+latest change of each. A range whose answer is the one the last build stored keeps its file. The others are
+rendered again, and a file that comes out the same is not uploaded (a full build uploads every file). In the index, a file that changed takes the
+latest `lastmod` it holds, or the time of the build when a record left it, so that crawlers read it again.
+
+Some changes leave those numbers alone: `update_columns`, a change in another table that the `url`, `locales` or
+`images` blocks read, a new `url` block, a file someone removed from the storage. Name the other table in
+`fingerprint`, or `touch` the record from it. For the rest, a full build comes once a week by itself:
+
+```ruby
+config.rebuild_every = 1.day # default: a week; nil leaves it to mapitaine:rebuild
+```
+
+After deploying a change to a `url` block, run `mapitaine:rebuild` rather than wait.
+
+A build notes the files it is about to add in `sitemap-manifest.json`, its memory of what each file held, then
+writes the sitemap files, then the index, then removes the files no range fills any more, then writes the
+manifest again. A build that fails midway is repaired by the next one. Changing the host, the languages, the
+size of a range or the version of Mapitaine rewrites everything it moves once.
## Storage
diff --git a/docs/guide.html b/docs/guide.html
index b471b88..34770ff 100644
--- a/docs/guide.html
+++ b/docs/guide.html
@@ -135,15 +135,19 @@
Building
bin/rails mapitaine:build # rewrite the files whose records changed
bin/rails mapitaine:rebuild # rewrite everything
-
Mapitaine.build does the same from Ruby and returns what it did: written, kept, deleted, url_count, submitted and warnings. Run it on a schedule, every night or every hour. With Solid Queue:
+
Mapitaine.build does the same from Ruby and returns what it did: full, written, kept, deleted, url_count, submitted and warnings. Run it on a schedule, every night or every hour. With Solid Queue:
# config/recurring.yml
production:
sitemap:
command: "Mapitaine.build"
schedule: every hour
How a build decides
-
For each source, one query groups the records by range of ids and returns the count and the latest change of each. A range whose answer is the one the last build stored keeps its file. The others are rendered again. Nothing hooks into your models, so update_all, imports and deletions are seen too.
-
A build writes the sitemap files first, then the index, then removes the files no range fills any more, then writes sitemap-manifest.json, its memory of what each file held. A build that fails midway is repaired by the next one. Changing the host, the languages or the version of Mapitaine rewrites everything once.
+
For each source, one query groups the records by range of ids and returns the count, the sum of the ids and the latest change of each. A range whose answer is the one the last build stored keeps its file. The others are rendered again, and a file that comes out the same is not uploaded (a full build uploads every file). Nothing hooks into your models, so update_all, imports and deletions are seen too.
+
In the index, a file that changed takes the latest lastmod it holds, or the time of the build when a record left it, so that crawlers read it again.
+
Some changes leave those numbers alone: update_columns, a change in another table that the url, locales or images blocks read, a new url block, a file someone removed from the storage. Name the other table in fingerprint, or touch the record from it. For the rest, a full build comes once a week by itself:
+
config.rebuild_every = 1.day # default: a week; nil leaves it to mapitaine:rebuild
+
After deploying a change to a url block, run mapitaine:rebuild rather than wait.
+
A build notes the files it is about to add in sitemap-manifest.json, its memory of what each file held, then writes the sitemap files, then the index, then removes the files no range fills any more, then writes the manifest again. A build that fails midway is repaired by the next one. Changing the host, the languages, the size of a range or the version of Mapitaine rewrites everything it moves once.
diff --git a/lib/mapitaine/builder.rb b/lib/mapitaine/builder.rb
index 8437549..55c3743 100644
--- a/lib/mapitaine/builder.rb
+++ b/lib/mapitaine/builder.rb
@@ -2,7 +2,8 @@
module Mapitaine
class Builder
- Result = Data.define(:written, :kept, :deleted, :url_count, :submitted, :warnings) do
+ # full: whether this build rewrote everything, asked to or because rebuild_every had passed.
+ Result = Data.define(:full, :written, :kept, :deleted, :url_count, :submitted, :warnings) do
def changed?
written.any? || deleted.any?
end
@@ -14,6 +15,8 @@ def changed?
# A language, then maybe a script and a region: en, pt-BR, zh-Hant-TW, es-419.
HREFLANG = /\A[a-z]{2,3}(-[a-z]{4})?(-([a-z]{2}|\d{3}))?\z/i
+ Rendered = Data.define(:source_slug, :shard, :fingerprint, :files, :changed_urls)
+
Warning = Data.define(:source, :url, :message) do
def to_s
"#{source}: #{url} #{message}"
@@ -29,6 +32,8 @@ def initialize(configuration, storage: configuration.storage, submit: true)
def build(full: false)
ActiveSupport::Notifications.instrument("build.mapitaine", full:) do |payload|
payload[:result] = Dir.mktmpdir("mapitaine") { |directory| build_in(directory, full:) }
+ payload[:full] = payload[:result].full
+ payload[:result]
end
end
@@ -37,8 +42,11 @@ def build(full: false)
def build_in(directory, full:)
@directory = directory
@previous = Manifest.read(@storage, manifest_name)
- @reusable = !full && @previous.digest == @configuration.digest
- @manifest = Manifest.new(digest: @configuration.digest, built_at: Time.now)
+ @previous_file_by_name = @previous.files.to_h { |file| [file["name"], file] }
+ built_at = Time.now
+ @full = full || rebuild_due?(built_at)
+ @reusable = !@full && @previous.digest == @configuration.digest
+ @manifest = Manifest.new(digest: @configuration.digest, built_at:, rebuilt_at: @full ? built_at : @previous.rebuilt_at)
@written = []
@kept = []
@warnings = []
@@ -47,13 +55,16 @@ def build_in(directory, full:)
@reported = Set.new
@x_default = Xml.hreflang(@configuration.x_default)
+ @rendered = []
+
@configuration.sources.each { |source_class| build_source(source_class.new(@configuration)) }
+ publish_rendered
publish_index
- deleted = @previous.file_names - @manifest.file_names
+ deleted = (@previous.file_names | @previous.pending) - @manifest.file_names
deleted.each { |name| @storage.delete(name) }
upload(manifest_name, @manifest.to_json)
- Result.new(written: @written, kept: @kept, deleted:, url_count: @manifest.files.sum { |file| file["urls"] },
+ Result.new(full: @full, written: @written, kept: @kept, deleted:, url_count: @manifest.files.sum { |file| file["urls"] },
submitted: submit_changed_urls, warnings: @warnings)
end
@@ -64,7 +75,7 @@ def build_source(source)
if fingerprint && stored && stored["fingerprint"] == fingerprint
keep(source, shard, stored)
else
- render(source, shard, fingerprint, stored, seen_urls)
+ render(source, shard, fingerprint, seen_urls)
end
end
end
@@ -74,7 +85,7 @@ def keep(source, shard, stored)
@kept.concat(stored["files"].map { |file| file["name"] })
end
- def render(source, shard, fingerprint, stored, seen_urls)
+ def render(source, shard, fingerprint, seen_urls)
writer = ShardWriter.new(directory: @directory, basename: source.basename(shard))
changed_urls = []
ActiveSupport::Notifications.instrument("shard.mapitaine", source: source.slug, shard:) do
@@ -87,21 +98,74 @@ def render(source, shard, fingerprint, stored, seen_urls)
end
fingerprint ||= writer.content_digest
- return keep(source, shard, stored) if stored && stored["fingerprint"] == fingerprint
+ # Recorded now, published once every shard is rendered: the index keeps the order of the sources.
+ @manifest.record(source.slug, shard, fingerprint:, files: [])
+ @rendered << Rendered.new(source_slug: source.slug, shard:, fingerprint:, files: writer.files, changed_urls:)
+ end
+
+ def publish_rendered
+ changed_names = @rendered.flat_map(&:files).reject { |file| same_as_before?(file) }.to_set(&:name)
+ announce_new_files(changed_names)
+ @rendered.each do |rendered|
+ entries = rendered.files.map do |file|
+ changed = changed_names.include?(file.name)
+ publish(file, changed)
+ manifest_entry(file, changed)
+ end
+ @manifest.record(rendered.source_slug, rendered.shard, fingerprint: rendered.fingerprint, files: entries)
+ @changed_urls.concat(rendered.changed_urls) if rendered.files.any? { |file| changed_names.include?(file.name) }
+ end
+ end
+
+ def same_as_before?(file)
+ @previous_file_by_name.dig(file.name, "digest") == file.digest
+ end
- publish(source, shard, fingerprint, writer.files)
- @changed_urls.concat(changed_urls)
+ # Recorded before the first upload, so that a build dying midway leaves no file the next one cannot remove.
+ def announce_new_files(names)
+ new_names = names.to_a - @previous_file_by_name.keys - @previous.pending
+ return if new_names.empty?
+
+ @previous.pending.concat(new_names)
+ upload(manifest_name, @previous.to_json)
end
- def publish(source, shard, fingerprint, files)
- files.each do |file|
+ # A full build uploads every file, so that it also repairs a storage that lost one.
+ def publish(file, changed)
+ if changed || @full
@storage.write(file.name, file.path)
- File.delete(file.path)
+ @written << file.name
+ else
+ @kept << file.name
+ end
+ File.delete(file.path)
+ end
+
+ # The index tells crawlers which files to read again: a file that changed gets a later date even when the
+ # change is a removal, and one that did not keeps its date.
+ def manifest_entry(file, changed)
+ previous_lastmod = @previous_file_by_name.dig(file.name, "lastmod")
+ lastmod = if !changed
+ previous_lastmod
+ elsif @previous_file_by_name.key?(file.name) && !later?(file.lastmod, previous_lastmod)
+ @manifest.built_at.getutc.iso8601
+ else
+ file.lastmod
end
- @written.concat(files.map(&:name))
- @manifest.record(source.slug, shard, fingerprint:, files: files.map do |file|
- {"name" => file.name, "urls" => file.url_count, "lastmod" => file.lastmod}
- end)
+ {"name" => file.name, "urls" => file.url_count, "lastmod" => lastmod, "digest" => file.digest}
+ end
+
+ # A manifest written by an older version may hold a lastmod in another format: compare times, not text.
+ def later?(stamp, previous_stamp)
+ return false if stamp.nil?
+
+ previous_stamp = Xml.timestamp(previous_stamp)
+ previous_stamp.nil? || Xml.time(stamp) > Xml.time(previous_stamp)
+ end
+
+ def rebuild_due?(now)
+ every = @configuration.rebuild_every
+ every.present? && (@previous.rebuilt_at.nil? || @previous.rebuilt_at + every <= now)
end
def publish_index
diff --git a/lib/mapitaine/configuration.rb b/lib/mapitaine/configuration.rb
index 129656f..4703089 100644
--- a/lib/mapitaine/configuration.rb
+++ b/lib/mapitaine/configuration.rb
@@ -2,13 +2,18 @@
module Mapitaine
class Configuration
- attr_accessor :filename, :index_now_key, :index_now_key_location, :load_sources, :storage, :x_default
+ # A full build now and then catches what no fingerprint sees: update_columns, a change in another table,
+ # a new url block, a file removed from the storage.
+ DEFAULT_REBUILD_EVERY_SECONDS = 7 * 24 * 60 * 60
+
+ attr_accessor :filename, :index_now_key, :index_now_key_location, :load_sources, :rebuild_every, :storage, :x_default
attr_reader :host
attr_writer :locales, :sitemaps_url, :sources
def initialize
@filename = "sitemap"
@load_sources = -> {}
+ @rebuild_every = DEFAULT_REBUILD_EVERY_SECONDS
@storage = Storage::Local.new("public")
end
@@ -38,7 +43,7 @@ def sources
# Whatever changes every file: a build under another digest starts over.
def digest
- Digest::SHA256.hexdigest([VERSION, host!, sitemaps_url, locales, x_default, filename].to_json)
+ Digest::SHA256.hexdigest(JSON.generate([VERSION, host!, sitemaps_url, locales, x_default, filename]))
end
private
diff --git a/lib/mapitaine/index_now.rb b/lib/mapitaine/index_now.rb
index 55390d8..faba80e 100644
--- a/lib/mapitaine/index_now.rb
+++ b/lib/mapitaine/index_now.rb
@@ -19,7 +19,7 @@ def initialize(host:, key:, key_location: nil, transport: Net::HTTP.method(:post
def submit(urls)
urls.each_slice(MAX_URLS_PER_REQUEST).sum do |batch|
body = {host: @host, key: @key, keyLocation: @key_location, urlList: batch}.compact
- response = @transport.call(ENDPOINT, body.to_json, HEADERS)
+ response = @transport.call(ENDPOINT, JSON.generate(body), HEADERS)
response.code.to_i.between?(200, 299) ? batch.size : 0
end
end
diff --git a/lib/mapitaine/manifest.rb b/lib/mapitaine/manifest.rb
index d85cf4d..da45644 100644
--- a/lib/mapitaine/manifest.rb
+++ b/lib/mapitaine/manifest.rb
@@ -1,25 +1,33 @@
# frozen_string_literal: true
module Mapitaine
- # What the last build wrote: for each shard of each source, its fingerprint and its files.
+ # What the last build wrote: for each shard of each source, its fingerprint and its files. pending lists the
+ # files a build started to publish before it could record them, so that the next one can remove them.
class Manifest
- attr_reader :built_at, :digest, :shards_by_source
+ attr_reader :built_at, :rebuilt_at, :digest, :shards_by_source, :pending
def self.read(storage, name)
json = storage.read(name)
return new unless json
stored = JSON.parse(json)
- new(digest: stored["digest"], built_at: Time.iso8601(stored["built_at"]), shards_by_source: stored["sources"])
+ new(digest: stored["digest"], built_at: time(stored["built_at"]), rebuilt_at: time(stored["rebuilt_at"]),
+ shards_by_source: stored["sources"] || {}, pending: stored["pending"] || [])
rescue JSON::ParserError, ArgumentError, TypeError
# A manifest we cannot read is a manifest we do not have: the build starts over and replaces it.
new
end
- def initialize(digest: nil, built_at: nil, shards_by_source: {})
+ def self.time(text)
+ Time.iso8601(text) if text
+ end
+
+ def initialize(digest: nil, built_at: nil, rebuilt_at: nil, shards_by_source: {}, pending: [])
@digest = digest
@built_at = built_at
+ @rebuilt_at = rebuilt_at
@shards_by_source = shards_by_source
+ @pending = pending
end
def shard(source, key)
@@ -39,7 +47,10 @@ def file_names
end
def to_json(*)
- JSON.pretty_generate("digest" => digest, "built_at" => built_at.utc.iso8601, "sources" => shards_by_source)
+ # Whole seconds, like the lastmods compared to built_at: a change in the second of a build is sent again
+ # rather than never.
+ JSON.pretty_generate("digest" => digest, "built_at" => built_at&.utc&.iso8601,
+ "rebuilt_at" => rebuilt_at&.utc&.iso8601, "pending" => pending, "sources" => shards_by_source)
end
end
end
diff --git a/lib/mapitaine/shard_writer.rb b/lib/mapitaine/shard_writer.rb
index da387f4..74558fd 100644
--- a/lib/mapitaine/shard_writer.rb
+++ b/lib/mapitaine/shard_writer.rb
@@ -7,7 +7,7 @@ class ShardWriter
MAX_URLS_PER_FILE = 50_000
MAX_BYTES_PER_FILE = 52_428_800
- Written = Data.define(:name, :path, :url_count, :lastmod)
+ Written = Data.define(:name, :path, :url_count, :lastmod, :digest)
attr_reader :files
@@ -17,7 +17,6 @@ def initialize(directory:, basename:, max_urls: MAX_URLS_PER_FILE, max_bytes: MA
@max_urls = max_urls
@max_bytes = max_bytes
@files = []
- @digest = Digest::SHA256.new
end
# The entries of one page stay in the same file.
@@ -36,7 +35,7 @@ def close
end
def content_digest
- @digest.hexdigest
+ Digest::SHA256.hexdigest(@files.map(&:digest).join)
end
private
@@ -46,6 +45,7 @@ def open_file
@name = "#{@basename}#{suffix}.xml.gz"
@path = File.join(@directory, @name)
@gzip = Zlib::GzipWriter.open(@path)
+ @file_digest = Digest::SHA256.new
@url_count = 0
@bytes = Xml::URLSET_CLOSE.bytesize
@lastmod = nil
@@ -53,16 +53,15 @@ def open_file
end
def roll
- @gzip.write(Xml::URLSET_CLOSE)
- @digest << Xml::URLSET_CLOSE
+ write(Xml::URLSET_CLOSE)
@gzip.close
@gzip = nil
- @files << Written.new(name: @name, path: @path, url_count: @url_count, lastmod: @lastmod)
+ @files << Written.new(name: @name, path: @path, url_count: @url_count, lastmod: @lastmod, digest: @file_digest.hexdigest)
end
def write(text)
@gzip.write(text)
- @digest << text
+ @file_digest << text
@bytes += text.bytesize
end
end
diff --git a/lib/mapitaine/source.rb b/lib/mapitaine/source.rb
index 87c3365..1ba4cd6 100644
--- a/lib/mapitaine/source.rb
+++ b/lib/mapitaine/source.rb
@@ -102,8 +102,11 @@ def default_url_options
def fingerprint_by_shard
return {WHOLE => nil} unless relation?
- relation.reorder(nil).group(shard_sql).pluck(shard_sql, Arel.sql("COUNT(*)"), fingerprint_sql)
- .to_h { |shard, count, latest| [shard.to_i.to_s, "#{count}/#{precise(latest)}"] }
+ # The sum of the ids moves when a record leaves a range and another enters it; the shard size, when the
+ # ranges themselves move. Summed as offsets within the range: SQLite's SUM overflows on large ids.
+ offset_sum = Arel.sql("SUM(#{model.quoted_table_name}.#{model.quoted_primary_key} % #{shard_size})")
+ relation.reorder(nil).group(shard_sql).pluck(shard_sql, Arel.sql("COUNT(*)"), offset_sum, fingerprint_sql)
+ .to_h { |shard, count, sum, latest| [shard.to_i.to_s, "#{shard_size}/#{count}/#{sum.to_i}/#{precise(latest)}"] }
end
def each_page(shard)
diff --git a/lib/mapitaine/tasks.rake b/lib/mapitaine/tasks.rake
index 1fe25cf..8b680b3 100644
--- a/lib/mapitaine/tasks.rake
+++ b/lib/mapitaine/tasks.rake
@@ -2,7 +2,7 @@
namespace :mapitaine do
report = lambda do |result|
- puts "#{result.url_count} URLs: #{result.written.size} files written, #{result.kept.size} kept, " \
+ puts "#{result.url_count} URLs#{" (full build)" if result.full}: #{result.written.size} files written, #{result.kept.size} kept, " \
"#{result.deleted.size} deleted, #{result.submitted} URLs sent to IndexNow"
result.warnings.each { |warning| warn " #{warning}" }
end
diff --git a/test/build_test.rb b/test/build_test.rb
index ea4f82f..73ccc9d 100644
--- a/test/build_test.rb
+++ b/test/build_test.rb
@@ -3,13 +3,13 @@
require "test_helper"
class BuildTest < MapitaineTest
- def test_first_build_writes_a_file_per_shard_then_the_index_then_the_manifest
+ def test_first_build_announces_its_files_writes_one_per_shard_then_the_index_then_the_manifest
create_post(1)
create_post(12)
built = build
- assert_equal %w[sitemap-post-00000.xml.gz sitemap-post-00001.xml.gz sitemap-page.xml.gz sitemap.xml sitemap-manifest.json],
- storage.writes
+ assert_equal %w[sitemap-manifest.json sitemap-post-00000.xml.gz sitemap-post-00001.xml.gz sitemap-page.xml.gz sitemap.xml
+ sitemap-manifest.json], storage.writes
assert_equal %w[sitemap-post-00000.xml.gz sitemap-post-00001.xml.gz sitemap-page.xml.gz], built.written
assert_equal 6, built.url_count
assert_empty built.warnings
@@ -108,6 +108,135 @@ def test_a_shard_left_without_records_loses_its_file
assert_predicate built, :changed?
end
+ def test_a_record_replaced_by_an_older_one_rewrites_its_shard
+ create_post(1, updated_at: LONG_AGO + 60)
+ create_post(2)
+ build
+ Post.where(id: 2).delete_all
+ create_post(3)
+ built = build
+
+ assert_equal %w[sitemap-post-00000.xml.gz], built.written
+ assert_includes storage.xml("sitemap-post-00000.xml.gz"), "posts/3"
+ end
+
+ def test_ids_too_large_for_a_sum_are_fingerprinted
+ create_post(9_000_000_000_000_000_001)
+ create_post(9_000_000_000_000_000_002)
+ build
+ Post.where(id: 9_000_000_000_000_000_002).delete_all
+ create_post(9_000_000_000_000_000_003)
+
+ assert_equal %w[sitemap-post-900000000000000000.xml.gz], build.written
+ end
+
+ def test_a_new_shard_size_rewrites_the_source
+ create_post(5)
+ create_post(15)
+ build
+ Post.where(id: 15).delete_all
+ create_post(25)
+ Mapitaine.configuration.sources = [Class.new(PostSitemap) { shard_size 20 }, PageSitemap]
+ built = build
+
+ assert_equal %w[sitemap-post-00001.xml.gz], built.written
+ assert_includes built.kept, "sitemap-post-00000.xml.gz"
+ refute_includes storage.xml("sitemap-post-00001.xml.gz"), "posts/15"
+ assert_includes storage.xml("sitemap-post-00001.xml.gz"), "posts/25"
+ end
+
+ def test_a_file_that_changed_moves_forward_in_the_index_even_when_a_record_left_it
+ create_post(1)
+ create_post(2)
+ build
+ Post.where(id: 2).delete_all
+ built = build
+
+ assert_equal %w[sitemap-post-00000.xml.gz], built.written
+ stamp = storage.xml("sitemap.xml")[%r{sitemap-post-00000.xml.gz([^<]+)}, 1]
+
+ assert_operator Time.iso8601(stamp), :>, LONG_AGO
+ end
+
+ def test_a_file_dated_by_an_older_manifest_is_compared_by_time
+ create_post(1, updated_at: Time.utc(2026, 1, 15, 5))
+ build
+ manifest = JSON.parse(storage.content_by_name["sitemap-manifest.json"])
+ manifest["sources"]["post"]["0"]["files"][0]["lastmod"] = "2026-01-15T10:00:00+09:00"
+ storage.content_by_name["sitemap-manifest.json"] = manifest.to_json
+ Post.where(id: 1).update_all(title: "Edited", updated_at: Time.utc(2026, 1, 15, 6))
+ build
+
+ assert_includes storage.xml("sitemap.xml"), "sitemap-post-00000.xml.gz2026-01-15T06:00:00Z"
+ end
+
+ def test_a_shard_rendered_again_without_a_visible_change_is_not_uploaded
+ create_post(1)
+ fixed = Class.new(PostSitemap) { lastmod { Date.new(2026, 1, 1) } }
+ Mapitaine.configuration.sources = [fixed]
+ build
+ Post.where(id: 1).update_all(updated_at: LONG_AGO + 60)
+ built = build
+
+ assert_empty built.written
+ assert_equal %w[sitemap-post-00000.xml.gz], built.kept
+ assert_includes storage.xml("sitemap.xml"), "sitemap-post-00000.xml.gz2026-01-01"
+ end
+
+ def test_a_full_build_uploads_everything_and_keeps_the_dates_of_what_did_not_change
+ create_post(1)
+ build
+ index = storage.xml("sitemap.xml")
+ storage.delete("sitemap-post-00000.xml.gz")
+ built = build(full: true)
+
+ assert_equal %w[sitemap-post-00000.xml.gz sitemap-page.xml.gz], built.written
+ assert_equal index, storage.xml("sitemap.xml")
+ end
+
+ def test_a_full_build_comes_every_week_by_itself
+ create_post(1)
+ build
+ manifest = JSON.parse(storage.content_by_name["sitemap-manifest.json"])
+ manifest["rebuilt_at"] = (Time.now - 8 * 86_400).utc.iso8601
+ storage.content_by_name["sitemap-manifest.json"] = manifest.to_json
+
+ built = build
+
+ assert_predicate built, :full
+ assert_equal %w[sitemap-post-00000.xml.gz sitemap-page.xml.gz], built.written
+ refute_predicate build, :full
+ Mapitaine.configuration.rebuild_every = nil
+ storage.content_by_name["sitemap-manifest.json"] = manifest.to_json
+
+ assert_empty build.written
+ end
+
+ def test_files_of_a_build_that_died_midway_are_removed_by_the_next
+ create_post(1)
+ build
+ create_post(12)
+ failing = Class.new(MemoryStorage) do
+ def write(name, source_path)
+ raise IOError, "disk full" if name == "sitemap.xml"
+
+ super
+ end
+ end.new
+ failing.content_by_name.merge!(storage.content_by_name)
+ Mapitaine.configuration.storage = failing
+
+ assert_raises(IOError) { Mapitaine.build }
+ assert failing.content_by_name.key?("sitemap-post-00001.xml.gz")
+ Post.where(id: 12).delete_all
+ @storage = MemoryStorage.new
+ storage.content_by_name.merge!(failing.content_by_name)
+ Mapitaine.configuration.storage = storage
+ built = build
+
+ assert_equal %w[sitemap-post-00001.xml.gz], built.deleted
+ end
+
def test_a_full_build_rewrites_everything
create_post(1)
build
@@ -250,6 +379,10 @@ def test_a_build_is_instrumented
built = build
assert_equal ["shard.mapitaine", {source: "post", shard: "0"}], events.first
+ assert_equal ["build.mapitaine", {full: true, result: built}], events.last
+ events.clear
+ built = build
+
assert_equal ["build.mapitaine", {full: false, result: built}], events.last
ensure
ActiveSupport::Notifications.unsubscribe(subscriber)