Come ho costruito il mio sistema prezzi con bitmap ed ETS

Come ho costruito il mio sistema prezzi con bitmap ed ETS

By Yuriy Zhar 5 min read
Volevo una ricerca prenotazioni che sembrasse istantanea, non “aspetta che faccio cinque join e piango”, quindi ho finito per codificare disponibilità e prezzi in mini bitmap e buttarle dentro ETS. Ecco come e perché funziona.

Perché Non Ho Usato i “Soliti” Schemi di Booking

Quando inizi un sistema di booking, arrivano subito le solite opzioni:

  • Una riga per notte per barca: boat_id, date, price, status.
  • Range: from, to, day_price, weekly_price, status.
  • Qualche blob JSON di “calendar” per barca che parsifichi ogni volta.

Ho provato variazioni di tutte queste nella testa e su carta. Funzionano, ma per la ricerca fanno male:

  • Le righe per giorno esplodono: anno × barche × notti. Ti ritrovi con tabelle enormi e indici sempre accesi.
  • I modelli a range sono compatti ma fastidiosi da interrogare: capodanni di mezzo, promo che si sovrappongono, regole di noleggio minimo, ecc.
  • I calendari JSON spostano solo il casino lato app: alla fine stai comunque ricostruendo una “timeline” in memoria ad ogni ricerca.

Quello che volevo davvero:

  • Check veloci “queste date sono libere?”.
  • Check veloci “quanto costa in totale questo periodo?”.
  • Poco uso di memoria anche con migliaia di barche.
  • Qualcosa che posso tenere in ETS e “slicciare” in puro Elixir, senza pingare il database.

Lì le bitmap hanno iniziato ad avere senso.

Bitmap Spiegate Terra-Terra

Dimentichiamoci le barche un attimo. Prendiamo un solo anno. Per la disponibilità mi basta sapere: per ogni giorno, è libero o occupato?

  • Bitmap di disponibilità: un carattere per giorno, "0" per libero, "1" per occupato.
  • Bitmap di prezzo: un byte per giorno, <<0>> significa “nessun prezzo / non prenotabile”, qualsiasi altro byte è un codice tier.

La parte importante: la lunghezza è sempre days_in_year(year). Niente strutture a lunghezza variabile, niente buchi. Un anno intero è una stringa o binary a dimensione fissa.

Esempio per la disponibilità (semplificato):

# Codifica 2025-01-01..2025-01-10 come occupati (1), il resto dell'anno resta libero (0)
bitmap =
  AvailabilityBitmap.encode(
    [%{from: ~D[2025-01-01], to: ~D[2025-01-10]}],
    2025
  )

# Queste date cadono dentro al blocco occupato → non disponibile
AvailabilityBitmap.available_between?(bitmap, ~D[2025-01-05], ~D[2025-01-07])
# => false

# Queste date sono lontane dal blocco occupato → completamente disponibile
AvailabilityBitmap.available_between?(bitmap, ~D[2025-02-01], ~D[2025-02-10])
# => true

Sotto il cofano sta letteralmente tagliando stringhe:

  • Converte le date in indici: idx = giorni dal primo gennaio.
  • Prende una substring con start_idx..end_idx.
  • Controlla se dentro c’è un marcatore “occupato” o no.

Stessa idea per i prezzi, solo un filo più interessante. Non salvo i numeri veri nella bitmap, solo un byte di tier che punta a una prices map:

# Tabella dei tier: ogni codice di 1 byte punta a prezzo giornaliero + settimanale
prices = %{
  <<1>> => %{day: 120_00, weekly: 700_00},  # codice 1 → bassa stagione
  <<2>> => %{day: 150_00, weekly: 900_00}   # codice 2 → alta stagione
}

# Costruisco una bitmap prezzi per l'anno:
# - inizio giugno: bassa stagione (<<1>>)
# - agosto:        alta stagione (<<2>>)
price_bitmap =
  PriceBitmap.encode(
    [
      %{from: ~D[2025-06-01], to: ~D[2025-06-14], code: <<1>>},
      %{from: ~D[2025-08-01], to: ~D[2025-08-31], code: <<2>>}
    ],
    2025
  )

Ora calcolo il prezzo per un soggiorno:

# Chiedo direttamente alla bitmap di prezzare il soggiorno, senza query al DB
PriceBitmap.price(
  price_bitmap,
  prices,
  ~D[2025-06-03],
  ~D[2025-06-10]
)

# => {:ok, total_in_cents}

Questa funzione prende una slice della bitmap prezzi e ci fa girare sopra un mini algoritmo a run-length:

  • Legge il primo byte (tier code), conta quanti giorni consecutivi hanno quel codice.
  • Usa la prices map per convertire la run in soldi: settimane × weekly_price + giorni_rimasti × day_price.
  • Ripete per i byte restanti nella slice.

Niente database, niente join, niente SQL dinamico. Solo scan lineari su un binary piccolissimo in memoria.

Dove Entra In Gioco ETS

Le bitmap sono carine, ma diventano davvero interessanti quando le incolli con ETS.

ETS mi dà:

  • Tabelle in memoria di proprietà della VM.
  • Lookup O(1) per chiave quando ho la chiave completa.
  • :select con match spec quando voglio “dammi tutte le barche sotto questo prefisso”.
  • Concorrenza: più processi che leggono/scrivono senza che io gestisca lock a mano.

Il pattern che uso è:

  • Chiave: una 5-tuple in stile {zone, country, city, harbour, boat_id}.
  • Valore: una map con disponibilità e prezzi precomputati e tutta la metadata che mi serve per filtrare.

Indicizzare una barca più o meno così:

defmodule Booking.Index do
  @moduledoc """
  Simple ETS-backed index for boats:
  - precomputes availability + price calendars
  - stores them under a 5-tuple key
  """

  alias Booking.{AvailabilityBitmap, PriceBitmap}

  @table :boat_index

  # API pubblica: prende una struct/map di barca e la butta in ETS
  def index_boat(boat) do
    availability = AvailabilityBitmap.build(boat.blackout_ranges)
    prices       = PriceBitmap.build(boat.price_ranges)

    key = build_key(boat)

    # Il valore è una map compatta che il codice di search può usare
    :ets.insert(@table, {key, %{id: boat.id, availability: availability, prices: prices}})
  end

  # Costruisce la chiave canonica a 5 segmenti usata in ETS
  defp build_key(boat) do
    {boat.zone, boat.country, boat.city, boat.harbour, boat.id}
  end
end

E poi una ricerca fa qualcosa del genere:

defmodule Booking.Search do
  @moduledoc """
  Read-only search over the ETS index:
  - pulls all boats (or a prefix)
  - filters by availability
  - filters by price range
  """

  @table :boat_index

  # API di alto livello, quella che chiama il controller
  def search(opts) do
    from      = opts.start_date
    to        = opts.end_date
    min_price = opts.min_price
    max_price = opts.max_price

    @table
    |> select_all_boats()
    |> normalize_rows()
    |> filter_available(from, to)
    |> filter_by_price_range(from, to, min_price, max_price)
  end

  # Match-spec che dice “dammi le righe complete: chiave + valore”
  defp select_all_boats(table) do
    match_spec = [
      {
        # {{zone, country, city, harbour, boat_id}, value}
        {{:"$1", :"$2", :"$3", :"$4", :"$5"}, :"$6"},
        # nessuna guardia, vogliamo tutto il contenuto della tabella
        [],
        # ritorna tutte le variabili catturate
        [:"$$"]
      }
    ]

    :ets.select(table, match_spec)
  end

  # Trasforma le righe grezze di `:"$$"` in un formato più carino `{key, value}`
  defp normalize_rows(rows) do
    Enum.map(rows, fn [z, c, cc, h, b, v] ->
      {{z, c, cc, h, b}, v}
    end)
  end

  # Tiene solo le barche libere nel periodo richiesto
  defp filter_available(rows, from, to) do
    Enum.filter(rows, fn {_key, v} ->
      Booking.Availability.available?(v.availability, from, to)
    end)
  end

  # Tiene solo le barche il cui totale è dentro [min_price, max_price]
  defp filter_by_price_range(rows, from, to, min_price, max_price) do
    # La bitmap prezzi usa range inclusivi, quindi la data di fine diventa (to - 1)
    inclusive_end = Date.add(to, -1)

    Enum.filter(rows, fn {_key, v} ->
      case Booking.Prices.calculate(v.prices, from, inclusive_end) do
        {:ok, total} -> total >= min_price and total <= max_price
        _ -> false
      end
    end)
  end
end

Nota cosa manca:

  • Nessuna chiamata al database nel path critico.
  • Nessuna magia dell’ORM.
  • Nessun loop “giorno per giorno per barca” in Elixir puro; il lavoro pesante è stato fatto una volta sola quando ho costruito le bitmap.

I filtri di disponibilità e prezzo chiamano solo i moduli bitmap, che fanno slice-and-scan su binary minuscoli. La tabella ETS è di fatto un grande indice in memoria di “snapshot di calendario barca”.

Come Si Confronta Con Altri Approcci

Mettiamolo fianco a fianco con qualche alternativa.

1. Righe per giorno nel database

Design classico: per ogni barca e giorno salvi una riga con stato e prezzo.

  • Pro: facile da capire; SQL può fare tutto il filtraggio; funziona con qualsiasi ORM.
  • Contro: tabelle grosse, indici grossi, un sacco di I/O random sotto carico; finisci a fare cache pesante comunque.

Il calcolo del prezzo per un soggiorno singolo in SQL si può fare, ma diventa brutto quando mischi prezzi giornalieri, settimanali, promo, regole che si sovrappongono. A un certo punto stai scrivendo SQL di basso livello che assomiglia parecchio alla logica delle bitmap, solo infilata dentro il database.

2. Solo range di date

Salvi solo range tipo “questa promo è da X a Y” o “queste date sono bloccate”.

  • Pro: molto compatto; concettualmente pulito.
  • Contro: i check di disponibilità e prezzo diventano “prendi il periodo richiesto, sottrai tutti i blocchi, intersechi tutti i prezzi, speri che niente si sovrapponga in modo strano”.

Si può far funzionare, ma ogni nuova regola rende la logica a range un po’ più tossica. E non è a tempo costante rispondere a “tutti i giorni da A a B sono liberi?” devi sempre ragionare sull’insieme completo dei range.

3. Calendari JSON per barca

Salvi un mega JSON con tutti i giorni e la metadata per ogni barca.

  • Pro: facile da mandare ai client; tutto in un unico posto; niente join.
  • Contro: devi comunque deserializzare tutto ad ogni ricerca o mantenere una seconda rappresentazione per l’accesso veloce.

È praticamente una bitmap con più overhead e passaggi in più.

4. Trade-off di Bitmap + ETS

Bitmap ed ETS non sono magia:

  • Devi spezzare per anno, perché ogni bitmap è per-anno. Gli stay che passano da un anno all’altro vanno split in pezzi.
  • Serve codice curato per gestire input strani e tenere le bitmap della lunghezza giusta.
  • È specifico Elixir/BEAM: se cambi stack/lingua, lo reimplementi.

Ma una volta fatto, ti porti a casa cose molto interessanti:

  • Memoria prevedibile: un anno di disponibilità sono 365 o 366 byte per barca, più bitmap prezzi e map.
  • Check semplici e veloci: slice di string/binary, scan per marcatori “cattivi”, un po’ di operazioni intere.
  • Puoi tenere l’intero universo dei calendari in ETS su ogni nodo e rispondere alla maggior parte delle richieste senza toccare il database principale.

In un sistema dove la ricerca pesa tanto, è un bel vantaggio: il DB fa scritture lente e non frequentissime (aggiorna calendari, importa barche), mentre ogni richiesta API pesca quasi solo strutture in memoria.

Perché Bitmap + ETS Aveva Senso Qui

Non ho scelto le bitmap perché sono fighe o di moda. Semplicemente matchano bene la forma del problema:

  • Il calendario è naturalmente una sequenza di giorni con stati semplici.
  • I prezzi si ripetono; hai pochi tier riusati su tanti giorni.
  • La ricerca fa un sacco di operazioni “quasi solo read” con requisiti di latenza stretti.

Le bitmap mi danno una rappresentazione ultra-compatta di quel calendario. ETS mi dà uno store veloce e condiviso per quelle bitmap e la loro metadata. Insieme posso:

  • Controllare la disponibilità per un intervallo di date con una sola funzione su qualche decina di byte.
  • Calcolare il totale, inclusi sconti settimanali, senza una riga di SQL.
  • Filtrare migliaia di barche in Elixir in modo leggibile e debuggabile.

Potrei spedire un motore di booking senza bitmap ed ETS? Certo. Ma se sei su Elixir, questi strumenti li hai già in casa, e per questo tipo di ricerca pesante su calendari sono difficili da battere.

Windsurf
Strumento Consigliato

Windsurf

All my projects and even this website is build using Windsurf Editor. Windsurf is the most intuitive AI coding experience, built to keep you and your team in flow.

Share this article:
Yuriy Zhar

Yuriy Zhar

github.com

Passionate web developer. Love Elixir/Erlang, Go, Deno, Svelte. Interested in ML, LLM, astronomy, philosophy. Enjoy traveling and napping.

Contattaci

Se ti serve uno sviluppatore che consegna soluzioni veloci, affidabili e pratiche, contattami. Trasformiamo insieme la tua idea o il tuo progetto in qualcosa che funziona davvero.

Rimani aggiornato

Iscriviti alla nostra newsletter e ricevi gli ultimi articoli direttamente nella tua casella di posta.