Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

RetractorDB

RetractorDB to brzegowy silnik przetwarzania sygnałów (ang. Edge Signal Processing Engine, ESPE), przeznaczony do ciągłego przetwarzania regularnych serii czasowych blisko źródła danych. Za pomocą deklaratywnego języka RQL opisuje przekształcenia, agregacje i reguły, a ich wyniki udostępnia na żywo lub materializuje w postaci artefaktów, które można później przeglądać i korygować. System wspiera centralne bazy szeregów czasowych i systemy strumieniowe, ograniczając ilość przesyłanych do nich danych, lecz ich nie zastępuje.

Nazwa odzwierciedla połączenie dwóch idei. Retractor oznacza narzędzie, które wydobywa, rozdziela, łączy i przetwarza dane zawarte w seriach czasowych, natomiast człon DB wskazuje na rozwiązania znane z baz danych: deklaratywny język zapytań, opis schematu, mechanizmy dostępu oraz trwałe przechowywanie wyników. Więcej o pochodzeniu nazwy można przeczytać w rozdziale Dlaczego wybrano taką nazwę dla systemu?.

Dokumentacja prowadzi od podstaw matematycznych i konstrukcji języka RQL, przez architekturę systemu, kompilację i realizację zapytań, aż po przykłady zastosowań oraz załączniki z opisem narzędzi. Przy pierwszym kontakcie najlepiej czytać rozdziały w kolejności podanej w spisie treści, ponieważ kolejne części korzystają z pojęć wprowadzonych wcześniej. Czytelnik szukający konkretnego rozwiązania może przejść bezpośrednio do odpowiedniego rozdziału, a następnie skorzystać z przykładów i załączników jako materiału praktycznego i referencyjnego.

RetractorDB na tle sąsiednich dziedzin

Ten rozdział jest mapą, nie katalogiem. Zamiast wyliczać wszystko, co kiedykolwiek napisano o strumieniach i sygnałach, pokazuję osiem nurtów literatury badawczej, na styku których leży RetractorDB, i dla każdego z nich odpowiadam na trzy pytania: co ten nurt już rozwiązał, w czym RetractorDB się od niego różni i czego ten nurt nie dotyka. Dopiero ich zestawienie pokazuje lukę, którą ten projekt wypełnia.

📥 Pobierz dokumentację

Ta dokumentacja w całości jest kompilowana z plików w formacie markdown. Kopilowane są 3 cele. Pierwszy to strona html, którą teraz widzisz. Drugi to plik pdf, trzeci to dokument epub na czytnik. Za każdym razem po zmianie zawartości repozytorium na github gdzie przechowywane są pliki markdown uruchamiany jest proces tworzący te 3 cele.

✅ Uwaga

Ten system to: Edge Signal Processing Engine (Brzegowy System Przetwarzania Sygnałów). RetractorDB wspiera – a nie zastępuje – bazy szeregów czasowych (TSDB) i strumieniowe systemy zarządzania danymi (DSMS): pracuje blisko źródła sygnału, wstępnie przetwarza i filtruje wysokoczęstotliwościowe pomiary za pomocą deklaratywnego języka zapytań, utrzymuje częściowy, korygowalny zapis zdarzeń przeszłych i zaplanowanych przyszłych w inspekcjonowalnych artefaktach, a w górę architektury przekazuje dokładne, deterministyczne wyniki – tak, aby do centralnej architektury docierały wyłącznie zredukowane, już przetworzone strumienie.

ℹ️ Info

Dlaczego umieściłem ten rozdział tak wcześnie? Bo uczciwa odpowiedź na pytanie „czy to jest potrzebne?“ wymaga najpierw pokazania, co już istnieje. Większość pomysłów w informatyce została już raz pomyślana – wymyślanie koła na nowo to marnowanie cudzego wysiłku. Ten rozdział jest moją próbą udowodnienia, że akurat tego koła jeszcze nie wynaleziono.

Osiem sąsiednich dziedzin

Problem, który rozwiązuje RetractorDB, nie należy w całości do żadnej pojedynczej dyscypliny. Leży na styku ośmiu nurtów:

  • 1. Teoria liczb

    Sekwencje Beatty’ego, twierdzenie Fraenkela, układy pokrywające. To dostarcza fundamentu formalnego.

  • 2. Szeregowanie zadań przez sekwencje Beatty’ego

    Ta sama matematyka, inne zastosowanie. Najbliższy sąsiad aplikacyjny.

  • 3. Synchroniczny i cykliczno-statyczny przepływ danych (SDF/CSDF)

    Wielotempowe grafy aktorów, statyczne harmonogramy i rozmiary buforów.

  • 4. Języki synchroniczne i rachunki zegarów

    Deklaratywne zależności między okresowymi strumieniami oraz kompilacyjne wyznaczanie opóźnień.

  • 5. Cyfrowe przetwarzanie sygnałów (DSP)

    Próbkowanie niejednorodne i banki filtrów o wymiernych współczynnikach. To DSP-owy odpowiednik operacji przeplotu.

  • 6. Strumieniowe systemy zarządzania danymi (DSMS)

    Algebry strumieni i semantyka zapytań ciągłych. To bazodanowy punkt odniesienia.

  • 7. Współdzielenie wielu zapytań i stanu

    Ponowne wykorzystanie obliczeń, indeksów i materializacji przez kilka planów.

  • 8. Systemy szeregów czasowych (TSMS) i DSP wewnątrz bazy

    Najwęższa nisza, najbliższa właściwemu celowi systemu.

Omawiam je kolejno, od fundamentu ku zastosowaniu.

Teoria liczb: sekwencje Beatty’ego i układy pokrywające (1)

Cała algebra RetractorDB stoi na sekwencji Beatty’ego i jej uogólnieniu przez Fraenkela na liczby wymierne. Te wyniki przytaczam w Formalnych podstawach i dowodach. Tutaj interesuje mnie szersze tło: jak ta matematyka funkcjonuje we współczesnej literaturze i czy ktoś zastosował ją już tam, gdzie ja.

Sekwencje Beatty’ego mają bogatą literaturę kombinatoryczną oraz udokumentowane zastosowania w nieperiodycznych parkietażach (kwazikryształy), szeregowaniu okresowym, widzeniu komputerowym (linie cyfrowe) i teorii języków formalnych [11]. Nurt jest żywy: Schaeffer, Shallit i Zorcic (2024) wykazali, że niejednorodna sekwencja Beatty’ego jest synchronizowalna automatem skończonym, co prowadzi do rozstrzygalności teorii pierwszego rzędu tych sekwencji [12]. Dla mnie najistotniejsza jest jednak praca Bergera, Felzenbauma i Fraenkela (1986) o rozłącznych układach pokrywających opartych na wymiernych sekwencjach Beatty’ego [13] – to dokładnie ten wariant, na którym opieram rozplątanie, a którego w pierwotnej pracy nie przywołałem.

Czego ten nurt nie dotyka: teoria liczb bada te sekwencje jako obiekty matematyczne. Nie łączy ich z bazą danych, z modelem przetwarzania strumieni ani z przetwarzaniem sygnałów. Dostarcza cegieł, nie budowli.

Szeregowanie zadań przez sekwencje Beatty’ego (2)

To jest nurt, który muszę omówić najuczciwiej, bo używa tej samej maszynerii dowodowej co moje twierdzenia – tyle że w innym celu. W problemie szeregowania okresowego (ang. pinwheel scheduling) zadania o różnych okresach powtarzania rozdziela się tak, że zadania o jednym czasie powtórzeń trafiają w sloty czasowe należące do pierwszej komplementarnej sekwencji Beatty’ego, a o drugim – do drugiej [14]. Świeże prace (2025) prowadzą dowody na podziale Rayleigha/Beatty’ego z tożsamościami na funkcjach podłogi i sufitu typu ⌈(m+l)a⌉ − ⌈ma⌉ [15] – niemal kropka w kropkę aparat z mojego dowodu, że rozplątanie spełnia postulaty Fraenkela.

Wniosek jest dla mnie podwójny. Z jednej strony – to niezależne potwierdzenie, że podejście jest poprawne i naturalne; skoro ktoś dochodzi tą samą drogą do działającego szeregowania, fundament jest solidny. Z drugiej – to zawęża to, co mogę nazwać nowością. „Sekwencje Beatty’ego do szeregowania“ już istnieją i są aktywnie publikowane. Co ciekawe, mój system używa tej matematyki wewnętrznie właśnie do szeregowania zadań (patrz Realizacja zapytań) – ale to nie tu leży wkład oryginalny.

Czego ten nurt nie dotyka: szeregowanie traktuje sekwencje jako narzędzie przydziału slotów czasowych procesorom. Nie buduje na nich algebry danych, nie wyraża nimi operacji na sygnałach, nie tworzy języka zapytań.

Synchroniczny i cykliczno-statyczny przepływ danych (SDF/CSDF) (3)

W SDF aktorzy zużywają i wytwarzają z góry znane liczby znaczników, dzięki czemu można przed uruchomieniem wyznaczyć harmonogram grafu [26]. CSDF rozszerza ten model o cyklicznie zmieniające się proporcje produkcji i konsumpcji oraz pozwala wyznaczać statyczne harmonogramy i ograniczenia buforów [27]. Jest to dojrzały model deklaratywnego, wielotempowego przepływu danych, więc sama analiza częstotliwości ani statyczne szeregowanie nie są cechami swoistymi RetractorDB.

SDF/CSDF potrafi opisać pokrewne zachowanie wielotempowe, co uzasadnia ocenę „częściowo” przy bezstratnym podziale próbek. Podział Beatty’ego nie jest tam jednak osobnym operatorem semantycznym. RetractorDB osadza taki operator w języku zapytań i łączy go z trwałymi wynikami.

Czego ten nurt nie dotyka: SDF/CSDF nie definiuje tej konkretnej, bezstratnej partycji pozycji próbek jako semantyki systemu zapytań ani modelu inspekcji i odtwarzania artefaktów.

Języki synchroniczne i rachunki zegarów (4)

Języki synchroniczne opisują okresowe strumienie za pomocą zegarów i potrafią podczas kompilacji sprawdzać ich zgodność oraz wyznaczać potrzebne bufory i opóźnienia. W modelach n-synchronicznych relacje między zegarami mogą mieć wymierne proporcje, a część obowiązków związanych z synchronizacją przejmuje kompilator [28]. To jeden z najbliższych punktów odniesienia dla deklaratywnej granicy RetractorDB.

Różni się jednak przedmiot opisu: zegar zwykle określa obecność wartości w logicznych taktach programu, natomiast RetractorDB przypisuje regularnemu strumieniowi wymierny interwał i na tej podstawie tworzy jeden uporządkowany strumień z dwóch wejść.

Czego ten nurt nie dotyka: rachunki zegarów służą przede wszystkim do kompilacji programów reaktywnych. Nie przenoszą tej semantyki do silnika zapytań z publicznymi deskryptorami oraz trwałymi, odtwarzalnymi artefaktami.

Cyfrowe przetwarzanie sygnałów: próbkowanie niejednorodne i banki filtrów (5)

Przeplot i rozplątanie spotykają się z DSP w zagadnieniu pracy na strumieniach o różnych częstotliwościach próbkowania, ale nie są po prostu kolejną metodą rekonstrukcji sygnału. Najbliższym pomostem jest praca Samadiego, Ahmada i Swamy’ego (2004), która formułuje warunek perfekcyjnej rekonstrukcji niejednorodnych banków filtrów na podstawie odpowiedzi układu na opóźnione sygnały skoku jednostkowego [16]. Szerszy nurt obejmuje próbkowanie okresowo-niejednorodne sygnałów pasmowo ograniczonych [17] oraz banki filtrów o wymiernych współczynnikach decymacji (Kovačević i Vetterli) [18].

Pojawiają się tam nawet konstrukcje teorioliczbowe: banki filtrów Ramanujana wydobywają składowe okresowe sygnału [19]. Ale akurat sekwencji Beatty’ego ani twierdzenia Fraenkela w tej literaturze nie znalazłem – i to jest część luki.

Czego ten nurt nie dotyka: przywołane metody DSP rekonstruują albo przekształcają wartości sygnału. Nie definiują konkretnego przeplotu pozycji próbek opartego na sekwencjach Beatty’ego ani nie osadzają go w silniku zapytań wytwarzającym artefakty.

Strumieniowe systemy zarządzania danymi (DSMS) (6)

Po stronie bazodanowej kanonem jest CQL ze stanfordzkiego projektu STREAM (Arasu, Babu, Widom). W tym modelu strumień to potencjalnie nieskończony wielozbiór elementów ⟨s, τ⟩, gdzie s jest krotką, a τ stemplem czasowym [20]; semantykę zapytań buduje się na oknach i odwzorowaniach strumień↔relacja. Drugim bliskim sąsiadem jest temporalna algebra Krämera i Seegera (system PIPES), zapewniająca deterministyczne wyniki zapytań ciągłych oraz bogaty zbiór reguł transformacji stanowiących podstawę optymalizacji [21].

To jest właściwy punkt odniesienia dla mojej algebry i moich reguł przepisywania wyrażeń. Różnica jest jednak fundamentalna i dotyczy samego modelu danych. CQL i PIPES budują semantykę na modelu (s, τ) – każda krotka nosi własny stempel czasowy, a operatory działają przez okna. Ja przyjmuję model różnicowy (sₙ, Δ) z wymierną, stałą wartością Δ na strumień, a operatory wyrównujące strumienie o różnych Δ wyprowadzam z teorii liczb. To nie jest kosmetyczna różnica w składni – to inny model danych, prowadzący do innej klasy operatorów (przeplot, rozplątanie) i innej metody optymalizacji.

W kategoriach wdrożeniowych relacja jest przy tym komplementarna, nie konkurencyjna: RetractorDB działa jako brzegowy stopień wstępnego przetwarzania i buforowania, którego dokładne, deterministyczne wyniki mogą zasilać okienkowy DSMS.

Czego ten nurt nie dotyka: DSMS obejmują zarówno semantyki deterministyczne, jak i mechanizmy skalowania, okien, tolerancji na nieuporządkowanie oraz obsługi stanu. Przywołane systemy nie definiują jednak konkretnego, bezstratnego podziału pozycji regularnych próbek opartego na sekwencjach Beatty’ego ani nie używają teorii liczb jako semantyki resamplingu.

Współdzielenie wielu zapytań i stanu (7)

Optymalizacja wielu zapytań od dawna wykorzystuje wspólne fragmenty planów. Nowsze systemy strumieniowe współdzielą także utrzymywane indeksy i stan między równoległymi przepływami [29], a normalizacja semantyczna pozwala łączyć zapytania różniące się składnią lub strukturą planu [30]. Automatyczne współdzielenie nie jest zatem samo w sobie nowym wkładem RetractorDB.

W RetractorDB to zagadnienie dotyczy materializowanych strumieni pośrednich. Kompilator może je współdzielić dopiero po normalizacji planu i sprawdzeniu zgodności semantyki regularnych serii. Jest to bliskie istniejącym mechanizmom współdzielonego stanu, choć kryterium zgodności wynika tu z modelu częstotliwości strumieni.

Czego ten nurt nie dotyka: przywołane metody nie wykorzystują wyrównania częstotliwości i podziału Beatty’ego do normalizacji planu przed decyzją o współdzieleniu. Szczegółowe granice tego porównania wykraczają poza zakres dokumentacji systemu.

Systemy szeregów czasowych (TSMS) i DSP wewnątrz bazy (8)

To najwęższa nisza – i najbliższa właściwemu celowi RetractorDB. Kanoniczny przegląd to praca Jensena, Pedersena i Thomsena „Time Series Management Systems: A Survey“ (IEEE TKDE, 2017) [22]. Opisany tam system Plato jest najbliższym prawdziwym „DSP wewnątrz bazy“: łączy RDBMS z metodami przetwarzania sygnałów, eliminując potrzebę eksportu danych do narzędzi zewnętrznych typu R czy SPSS [22]. Pozostałe podejścia do „sygnałów w bazie“ sprowadzają się do aproksymacji i kompresji – reprezentacje falkowe, słownikowe, kształtowe.

Podejścia te koncentrują się na aproksymacji, kompresji albo analizie po fakcie. Nie czynią z przeplotu pozycji próbek opartego na sekwencjach Beatty’ego operatora pierwszej klasy wewnątrz algebry zapytań. RetractorDB nie konkuruje z nimi skalą ingestii czy retencją: działa przed systemem centralnym i dostarcza mu deterministyczne wyniki oraz korygowalne artefakty.

Czego ten nurt nie dotyka: TSMS optymalizują skalę ingestii, kompresję i retencję. DSP jest w nich obywatelem drugiej kategorii – dodatkiem analitycznym, nie rdzeniem semantyki.

Biała plama: gdzie leży wkład

Poniższa tabela jest jakościową mapą możliwości, a nie dowodem pierwszeństwa ani kompletności przeglądu. Ostatnia kolumna dotyczy oceny opartej na inspekcjonowalnych artefaktach lub odtwarzaniu, nie samego zapisu danych. „Częściowo” oznacza zdolność pokrewną, nie równoważność semantyczną.

DziedzinaBeatty/FraenkelBezstratny podział próbekDeklaratywny przepływ danychOcena przez artefakty / odtwarzanie
Teoria liczb
Szeregowanie (pinwheel)częściowo
SDF / CSDFczęściowo
Języki synchroniczne / rachunki zegarów
DSP wielotempowyczęściowoczęściowo
DSMS (CQL, PIPES)częściowo
Współdzielenie wielu zapytań / stanuczęściowo
TSMS / DSP-w-bazieczęściowoczęściowoczęściowo
RetractorDB

Najsilniejszymi sąsiadami w zakresie modelu wykonania są SDF/CSDF oraz języki synchroniczne i rachunki zegarów: zapewniają już wielotempowy deklaratywny przepływ danych, deterministyczną semantykę, statyczne harmonogramy albo wyznaczanie buforów. Nurt wielu zapytań jest równie bliski na niewidocznej w kolumnach osi: potrafi automatycznie współdzielić utrzymywany stan i formułować warunki zachowania dla poszczególnych zapytań. Wpis „częściowo” nie oddaje całej bliskości tego porównania, ponieważ tabela nie opisuje sposobu ustalania tożsamości współdzielonego obiektu.

Zakres integracji RetractorDB jest węższy: system łączy zdefiniowany przez sekwencje Beatty’ego, dokładnie odwracalny podział pozycji próbek z kompilatorem zapytań, sekwencyjnym środowiskiem slotowym oraz trwałymi artefaktami dostępnymi do inspekcji i odtwarzania. Jest to opis architektury i semantyki systemu, nie twierdzenie, że poszczególne składniki są nowe. RetractorDB nie deklaruje gwarancji twardego czasu rzeczywistego.

⚠️ Ostrzeżenie

Stąd realne ryzyko, które wprost wskazuję: społeczność szeregowania publikuje tę samą maszynerię Beatty’ego/Fraenkela w latach 2023–2025. Sam problem – wraz z potrzebą deklaratywnej algebry strumieni i ciągłego języka zapytań – został sformułowany już w latach 2003–2005 w kontekście komputerowo wspomaganego monitorowania płodu [25]; pomost „układy pokrywające ↔ wyrównanie strumieni i DSP“ postawiłem publikacją w 2006 roku [3], lecz w miejscu o niskiej odnajdywalności. Jeśli ten wynik nie trafi do dobrze cytowanego obiegu, ten sam pomost może zostać niezależnie postawiony i przypisany komu innemu.

Zastrzeżenie metodologiczne

To przegląd ukierunkowany, nie systematyczny – oparty na wyszukiwaniu w ośmiu nurtach, nie na pełnej analizie cytowań. Przegląd cytowań „w przód“ pracy Samadiego [16] potwierdza tezę: według Semantic Scholar (stan na lipiec 2026) jej jedyne odnotowane cytowania to praca o projektowaniu okien Gabora, dwie prace systemowo-teoretyczne o układach wielotempowych oraz sam pomost z 2006 roku [3] – żadna z nich nie używa sekwencji Beatty’ego ani twierdzenia Fraenkela. Najbliższym znanym mi użyciem tej maszynerii poza teorią liczb jest konstrukcja wykładniczych baz Riesza z sekwencji Beatty’ego–Fraenkela (Pfander, Revay i Walnut) [24] – należy ona jednak do czystej analizy harmonicznej i nie dotyka banków filtrów ani konwersji częstotliwości próbkowania. Do pełnego domknięcia pozostaje systematyczny przegląd nurtu szeregowania [14] oraz literatury banków filtrów w całości; wśród prac o współdzieleniu zapytań wskazano jedynie reprezentatywne mechanizmy. Jeśli istnieje użycie twierdzenia Fraenkela w wielotempowym DSP, zawęża to zakres roszczenia o nowość i należy je tu uwzględnić.

Podstawy matematyczne

Podstawy matematyczne

ℹ Info

Czy wiesz co to jest medal Fieldsa? Jest to nagroda przyznawana wyłącznie wybitnym matematykom w wieku poniżej 40 lat. Nazywana jest matematycznym Noblem. Co ciekawe żaden matematyk nie otrzyma nagrody Nobla – zgodnie z życzeniem fundatora. Sam John Charles Fields (1863-1932) był Kanadyjskim matematykiem. John Charles Fields miał jednego doktoranta – Samuela Beatty (1881-1970).

Samuel Beatty w 1926 roku opublikował następujące twierdzenie [1]:

Jeśli p, q są dodatnimi liczbami niewymiernymi i zachodzi pomiędzy nimi zależność

\[ \frac{1}{p}+\frac{1}{q}=1 \]

to sekwencje

\[ \left\{ \left\lfloor np\right\rfloor \right\} _{n=1}^{\infty }=\left\lfloor p\right\rfloor ,\left\lfloor 2p\right\rfloor ,\left\lfloor 3p\right\rfloor ,\ldots \]

oraz

\[ \left\{ \left\lfloor nq\right\rfloor \right\} _{n=1}^{\infty }=\left\lfloor q\right\rfloor ,\left\lfloor 2q\right\rfloor ,\left\lfloor 3q\right\rfloor ,\ldots \]

oraz dokonują podziału zbioru dodatnich liczb całkowitych.

Rys. 1. Repreznatacja graficzna pojęcia zbiorów rozłącznych

Te dwie sekwencje dokonują podziału zbioru liczb naturalnych. Oznacza to że dysponując dwoma liczbami niewymiernymi, pomiędzy którymi wskazana w twierdzeniu zależność – będziemy mogli podzielić zbiór wszystkich liczb naturalnych na dwa rozłączne zbiory (Rys. 1).

Twierdzenie Beaty samo w sobie jest bardzo ciekawą obserwacją – jednak w przypadku systemów komputerowych mamy pewien problem z liczbami niewymiernymi. Liczby rzeczywiste – pomimo faktu że w niektórych językach programowania pojawia się czasem słowo Real lub Float jako reprezentanta typu liczby rzeczywistej, z liczbami rzeczywistymi nie mają wiele wspólnego. Fundamentalny problem polega na tym że ich nie mamy i zapewne nigdy mieć nie będziemy.

I tu nasza podróż gwałtownie by się skończyła gdyby nie powstało kolejne twierdzenie. Sytuacja diametralnie uległa zmianie za sprawą matematyka – Aviezri Siegmund Fraenkel (1926) specjalizującego się w kombinatorycznych aspektach teorii gier.

Przedstawił on w 1969 roku następujące twierdzenie [2]. Punktem wyjścia jest sparametryzowana sekwencja Beatty:

\[ \mathcal{B}(\alpha ,\alpha ^{\prime }):= \left( \left\lfloor \frac{n-\alpha^{\prime }}{\alpha }\right\rfloor \right) _{n=1}^{\infty } \]

Ta jedna definicja generuje całą rodzinę sekwencji. Twierdzenie dotyczy zawsze pary jej egzemplarzy o różnych parametrach: sekwencji

\[ \mathcal{B}(\alpha ,\alpha ^{\prime }) \quad\text{oraz}\quad \mathcal{B}(\beta ,\beta ^{\prime }):= \left( \left\lfloor \frac{n-\beta^{\prime }}{\beta }\right\rfloor \right) _{n=1}^{\infty } \]

Sekwencje te dokonują podziału zbioru ℕ wtedy i tylko wtedy gdy następujące pięć warunków zostanie spełnionych:

1.

\[ 0<\alpha<1 \]

2.

\[ \alpha+\beta=1 \]

3.

\[ 0\leq \alpha +\alpha ^{\prime }\leq 1 \]

  1. Jeśli α jest liczbą niewymierną, wtedy:

\[ \alpha ^{\prime }+\beta ^{\prime }=0 \]

i

\[ k\alpha +\alpha ^{\prime }\not\in \mathbb{Z} \]

dla

\[ 2\leq k\in \mathbb{N} \]

  1. Jeśli α jest liczbą wymierną, (niech q∈N będzie najmniejszą liczbą taką że qα∈N), wtedy

\[ \frac{1}{q}\leq \alpha +\alpha ^{\prime } \]

i

\[ \left\lceil q\alpha ^{\prime }\right\rceil +\left\lceil q\beta ^{\prime}\right\rceil =1 \]

No i to jest to czego potrzebujemy! Liczb niewymiernych co prawda nie mamy, ale liczby wymierne rozumiane jako stosunek dwóch liczb naturalnych to jest temat do ogarnięcia za pomocą komputera.

W naszym przypadku najpierw stworzyłem prototypy równań w języku Python a następnie zacząłem poszukiwać podstaw matematycznych, które wyglądały podobnie i można było się oprzeć na nich jako dobrze udokumentowanych równaniach popartych formalnymi dowodami. Dowodami oczywiście przeprowadzonymi przez bardziej doświadczonych matematyków. Skromne umiejętności pozwoliły jednak na identyfikację tych dwóch publikacji w aspekcie moich pomysłów.

W tym dokumencie nie umieściłem formalnych dowodów. Dlatego przestawiłem tutaj jedynie stosowane w systemie równania i twierdzenia. Po formalne dowody odsyłam do moich publikacji naukowych [3].

Algebra regularnych serii czasowych

Algebra – rozumiana jako konstrukcja w postaci zdefiniowanego zbioru i zdefiniowanych operacji na nim, stanowi podstawę dla opracowanego deklaratywnego język zapytań. W dalszej części pracy odnosząc się do Algebry (bez dodatkowego przymiotnika) będę ją rozumiał jako Algebrę regularnych serii czasowych. Jeśli będę chciał odwołać się do Algebry Relacji – jasno wyspecyfikuję przymiotnik.

Zaproponowałem [3] następującą definicje regularnej serii czasowej (tzw. Modelu danych) oraz następujące operacje i definicje.

✅ Uwaga

Przez strumień danych rozumiemy uporządkowaną parę S := (sn,∆) – gdzie pierwszy element to uporządkowania seria danych a drugi, oznaczony symbolem delty to regularny odstęp czasu pomiędzy kolejnymi elementami serii danych.

Przyjmujemy przy tym stałą konwencję indeksowania: indeksy strumienia biegną od zera, a element sn niesie domyślny (niejawny) znacznik czasu (n+1)·∆. Innymi słowy – pierwszy element strumienia pojawia się po upływie pełnego odstępu ∆ od chwili powstania strumienia. Znacznik czasu nie jest przenoszony w krotce; wynika z pozycji n i tempa ∆. To właśnie ten różnicowy model danych odróżnia system od klasycznych DSMS, w których strumień jest wielozbiorem par ⟨s,τ⟩ ze stemplem czasowym w każdej krotce.

Tak zdefiniowaną serię danych w systemie określam jako strumień danych. Taki regularnie przepływający przez system zestaw danych, zazwyczaj opisany schematem danych zawiera pola różnych typów. Każdy odczyt występuje w równym odstępie czasu pomiędzy kolejnymi pomiarami. Taka konstrukcja bardziej przypomina sygnał cyfrowy niż nieregularny strumień danych – jednak oznaczenie jej jako strumień w dalszej części prac badawczych okaże się uzasadnione.

ℹ Info

Uwaga:
Pojęcie strumień i Seria czasowa w tej pracy używane są zamiennie i oznaczają to samo.
Formalnie w literaturze naukowej strumień oznaczany jest jako zbiór par (a,t) – gdzie a oznacza krotkę, a czast oznacza jej moment zarejestrowania lub wystąpienia.
W strumieniu dopuszczalne są krotki, których czas t pokrywa się dla różnych krotek. W przypadku serii czasowej rozróżniamy dwa typy serii – regularne i nieregularne.
- W przypadku serii nieregularnych – seria to sekwencja uporządkowanych krotek w czasie – {at,tn}, gdzie czas tn jest unikalny w zbiorze dla każdej krotki.
- Natomiast seria regularnej serii czasowej może zostać opisana sekwencją krotek i regularnym odstępem czasu pomiędzy ich występowaniem – ({at},D) – i to ta ostatnia definicja jest bazą dalszych operacji w opracowanym systemie.

Operacje jakie możemy na takim zbiorze danych wykonać zdefiniowałem następująco:

  • przeplot i rozplątanie
  • suma i różnica
  • przesunięcie sekwencji
  • agregacja i serializacja

W operacji przeplotu biorą udział dwa różne strumienie danych.

Definiujemy ją następująco:

\[ c_{n}=\left\{ \begin{array}{cc} b_{n-\left\lfloor n z \right\rfloor } & \left\lfloor n z \right\rfloor =\left\lfloor \left( n+1\right) z \right\rfloor \\ a_{\left\lfloor n z \right\rfloor } & \left\lfloor n z \right\rfloor \neq \left\lfloor \left( n+1\right) z \right\rfloor% \end{array}% \right. , z =\frac{\Delta _{b}}{\Delta _{a}+\Delta _{b}},\Delta _{c}=% \frac{\Delta _{a}\Delta _{b}}{\Delta _{a}+\Delta _{b}} \]

Argumentem operacji splątania (przeplotu) są dwa strumienie danych A i B, każdy z własną szybkością napływu danych. Wynikiem jest strumień wynikowy C – z nową różną od dwóch poprzednich szybkością napływu wyznaczoną wzorem powyżej.

Operację będziemy oznaczać symbolem #.

Operację rozplątania definiujemy poprzez dwie operacje.

1. Rozplątanie lewostronne jako strumień A w postaci:

\[ a_{n} = c_{n+ \left\lceil \frac{(n+1)\Delta _{a}}{\Delta _{b}} \right\rceil },\ \Delta _{a}=\frac{\Delta _{c}\Delta _{b}}{\left\vert \Delta _{c}-\Delta _{b}\right\vert } \]

  1. Rozplątanie prawostronne jako strumień B w postaci:

\[ b_{n} = c_{n+\left\lfloor \frac{n\Delta_{b}}{\Delta_{a}}\right\rfloor},\ \Delta_{b}=\frac{\Delta_{c}\Delta_{a}}{\left\vert \Delta_{c}-\Delta_{a}\right\vert } \]

Operacje rozplątania 1 i 2 będziemy oznaczać symbolami & i %.

Argumentem operacji rozplątania jest splątany strumień danych oraz wymierna liczba określająca szybkość napływu odplątywanego strumienia danych. W wyniku operacji otrzymujemy strumień danych z wyznaczoną szybkością wzorem powyżej.

Operacje splątania i rozplątania są komplementarne. Oznacza to że przypominają operacje mnożenia i dzielenia w zbiorze liczb naturalnych. W wyniku mnożenia otrzymujemy pewien wynik natomiast w wyniku dzielenia – czasem dochodzi reszta, istotne jest również to co przez co dzielimy i w jakiej kolejności.

Operacje sumy zdefiniowałem następująco:

\[ c_{n}=\left\{ \begin{array}{cc} a_{n}|b_{ \left\lfloor \frac{n\Delta_{a}}{\Delta_{b}} \right\rfloor } & \Delta_{a}\leq \Delta_{b} \\ a_{ \left\lfloor \frac {n\Delta_{b}}{\Delta_{a}} \right\rfloor }|b_{n} & \Delta_{a}>\Delta_{b} \end{array} \right. ,\Delta_{c}=\min \left( \Delta_{a},\Delta_{b}\right) \]

Szybszy strumień narzuca tempo wyniku: każdy jego element zostaje sklejony (symbol | oznacza konkatenację krotek) z elementem zajmującym współindeksowany slot strumienia wolniejszego.

Natomiast różnicę opisuje wzór:

\[ a_{n}=\left\{ \begin{array}{cc} c_{n} & \Delta_{b}\geqslant \Delta_{a} \\ c_{\left\lceil \frac{n\Delta_{a}}{\Delta_{b}}\right\rceil } & \Delta_{b}<\Delta_{a} \end{array} \right. \]

Te operacje oznaczać będziemy znakami + oraz -.

Wykonanie przyczynowe rozszerza matematyczny strumień S = (sn, ∆) o początek logiczny OS ∈ ℕ oraz ogon startowy WS ∈ ℕ. Początek logiczny wskazuje indeks pierwszego rekordu, który w ogóle istnieje, a ogon określa, przez ile kolejnych slotów istniejący rekord nie jest jeszcze gotowy. Żaden z tych slotów nie jest rekordem: silnik nie wstawia zer ani zastępczych rekordów all-null.

\[ \widehat{S} := \left((s_n,\Delta),O_S,W_S\right) \]

Operację przesunięcia definiujemy jako odczyt starszego indeksu: rekord wynikowy \(n\) niesie treść rekordu \(n-m\) producenta. W realizacji przyczynowej:

\[ O_{\tau_m(S)}=O_S+m, \qquad W_{\tau_m(S)}=\max(0,W_S-m), \qquad m\in\mathbb{N} \]

Przesunięcie nie odrzuca elementów źródła i nie wytwarza prefiksu. Przenosi opóźnienie do początku logicznego, a odczyt starszego rekordu może pochłonąć część ogona producenta. Kompilator raportuje obie wielkości jako origin= i tail=. Runtime nie emituje rekordów w żadnym ze slotów milczenia, których jest origin + tail. Szczegóły: Ogony, początki logiczne i obserwowalność operatorów.

Operację przesunięcia oznaczać będę za pomocą >.

Ostatnią operacją w ramach zdefiniowanej algebry jest operacja agregacji i serializacji – w skrócie Agse. O ile wydaje się że to dwie oddzielne operacje, zdefiniowałem dwuargumentowy operator implementujący logikę ruchomego okna danych. Pierwszym argumentem jest skok okna, drugim jest jego szerokość. Skok jest liczbą naturalną o ile ruchome okno danych należy przesunąć nad strumieniem. Zakładamy że źródłowy strumień danych rozbity zostaje względem schematu danych, modyfikując jego szybkość napływu. Szerokość okna jest liczbą całkowitą, różną od zera. Wartości ujemne szerokości przenoszą kolejność tworzonych elementów w odbiciu lustrzanym. Wartości dodatnie – zachowują sekwencyjny charakter tworzonych ruchomych okien danych.

Operację Agse oznaczać będę znakiem @.

Podsumowując, algebra będąca podstawą dla deklaratywnego języka zapytań prezentuje się następująco:

\[ A_{rql}::=((s_n,\Delta_s), (\#,\&,\%,+,-,>,@)) \]

Gdzie pierwszy element pary definiującej algebrę to model danych (s_n — seria danych, ∆_s — jej regularny odstęp czasu) a drugi to zdefiniowane formalnie na tym modelu danych operacje.

Formalne podstawy i dowody

W rozdziale o algebrze regularnych serii czasowych przedstawiłem zbiór operatorów i opisujące je równania. Świadomie pominąłem tam formalne dowody – chciałem najpierw pokazać co system robi, zanim wyjaśnię dlaczego wolno mu to robić. Ta strona uzupełnia tę lukę. Zebrałem tu formalny szkielet algebry: powiązanie operatorów strumieniowych z teorią układów pokrywających oraz dowody twierdzeń, na których opiera się poprawność i optymalizacja planów zapytań.

ℹ Info

Cała poniższa konstrukcja trzyma się w jednej dziedzinie – liczb wymiernych. To nie jest ozdobnik. To jest cały sens. Twierdzenie Beatty potrzebuje liczb niewymiernych, których w komputerze nie ma. Twierdzenie Fraenkela pozwala zejść do liczb wymiernych. Dowody na tej stronie pokazują, że operacje przeplotu i rozplątania są szczególnym przypadkiem sekwencji Beatty spełniającym postulaty Fraenkela – a więc są realizowalne wyłącznie na liczbach wymiernych.

Układy pokrywające jako fundament

Literatura dotycząca układów pokrywających (ang. Covering Systems) [4] związana jest z kombinatoryką i kryptoanalizą w obszarze teorii liczb. Rozważanym problemem jest sposób wyznaczania podziału zbioru dodatnich liczb naturalnych. Mówimy, że dwie sekwencje dokonują podziału zbioru dodatnich liczb naturalnych, jeśli zbiory powstałe z elementów tych sekwencji po operacji przecięcia tworzą zbiór pusty, a ich suma tworzy zbiór dodatnich liczb naturalnych.

Podstawą rozważań jest sparametryzowana sekwencja Beatty. W postaci ogólnej zapisujemy ją z funkcją podłogi:

\[ \mathcal{B}(\alpha ,\alpha ^{\prime }) := \left( \left\lfloor \frac{n-\alpha ^{\prime }}{\alpha }\right\rfloor \right) _{n=1}^{\infty } \]

Ta jedna definicja generuje całą rodzinę sekwencji. Wyniki o podziale zbioru dotyczą zawsze pary jej egzemplarzy o różnych parametrach: parę zapisujemy jako B(α, α′) i B(β, β′), przy czym drugi zapis oznacza człon dopełniający.

Parametry tej sekwencji mają czytelną interpretację geometryczną:

  • α oznacza gęstość sekwencji,
  • 1/α oznacza nachylenie,
  • α′ oznacza przesunięcie,
  • −α′/α oznacza y-przechwycenie (punkt przecięcia z osią rzędnych).

Twierdzenie Beatty gwarantuje podział zbioru dla liczb niewymiernych. Twierdzenie Fraenkela jest uogólnieniem, które – co dla nas kluczowe – dopuszcza również liczby wymierne, pod warunkiem spełnienia pięciu postulatów (przytoczonych w rozdziale wstępnym). Przystępny dowód twierdzenia Fraenkela można odnaleźć w pracy K. O’Bryanta „Fraenkel’s partition and Brown’s decomposition“ [23].

Cała dalsza część tej strony sprowadza się do jednej myśli: pokazania, że operatory strumieniowe są w istocie maszynami generującymi sekwencje Beatty, które dokonują podziału (pokrycia) zbioru liczb naturalnych.

Narzędzia: własności podłogi i sufitu

Dowody operują niemal wyłącznie na funkcjach podłogi (⌊x⌋ – część całkowita) i sufitu (⌈x⌉ – najmniejsza liczba całkowita nie mniejsza od x). Przytaczam więc najpierw zestaw tożsamości, które będą wielokrotnie wykorzystywane. Niech x ∈ ℝ, a C oznacza liczbę całkowitą:

\[ \left\lfloor x\right\rfloor = \left\lceil x\right\rceil \iff x \in \mathbb{Z} \]

\[ \left\lfloor x\right\rfloor + 1 = \left\lceil x\right\rceil \iff x \in \mathbb{R} \setminus \mathbb{Z} \]

Druga z tych tożsamości ma bezpośrednie przełożenie na same sekwencje Beatty. Wariant sufitowy sekwencji, B′α(n) = ⌈nα⌉, jest bowiem – dla niewymiernego α – wyłącznie przesuniętą o jeden wersją wariantu podłogowego:

\[ B_{\alpha}^{\prime}(n) = \left\lceil n\alpha \right\rceil = \left\lfloor n\alpha \right\rfloor + 1 \]

W prawdziwej sekwencji Beatty α musi być niewymierne, więc nα nie jest liczbą całkowitą dla żadnego n > 0 – przesłanka drugiej tożsamości jest spełniona dla każdego wyrazu, a wariant sufitowy podnosi po prostu każdy wyraz wariantu podłogowego dokładnie o 1. Dla nas jest to jednak przypadek, którego w komputerze nie ma. W dziedzinie wymiernej, dopuszczonej dopiero przez twierdzenie Fraenkela, nα bywa liczbą całkowitą i wtedy ⌈nα⌉ = ⌊nα⌋, czyli przesunięcie o 1 znika. Stałe przesunięcie między wariantem sufitowym a podłogowym przestaje więc obowiązywać globalnie i musi być rozstrzygane wyraz po wyrazie – dokładnie to robi analiza przypadków w części trzeciej dowodu twierdzenia 2 (rozplątanie spełnia postulaty Fraenkela), gdzie o tym, który z dwóch przypadków zachodzi, decyduje nwd(a, b).

\[ \left\lfloor x + C\right\rfloor = \left\lfloor x\right\rfloor + C \]

(ostatnia tożsamość zachodzi dla każdego C ∈ ℤ). Dodatkowo, w analizie residuum sekwencji rozplątania wykorzystamy zależności wiążące największy wspólny dzielnik (nwd) z dziedziną ilorazu a/b. Dla a, b ∈ ℕ>0:

\[ \operatorname{nwd}(a,b) = b \iff \frac{a}{b} \in \mathbb{N} \]

a w przeciwnym przypadku:

\[ 1 \leq \operatorname{nwd}(a,b) \leq \min(a,b) \]

Te dwa przypadki rozłącznie pokrywają całą interesującą nas dziedzinę – co pozwoli przeprowadzić dowód „przez przypadki“.

Operatory w zapisie formalnym

Operatory wprowadzone w języku zapytań mają swoje formalne odpowiedniki. Poniższa tabela wiąże zapis formalny (stosowany w dowodach) z symbolami spotykanymi w języku zapytań:

OperacjaSymbol formalnySymbol w języku zapytań
Rzutowanieπlista pól po SELECT
Selekcjaσwarunek logiczny
SumaΣ+
Różnicaδ-
Przeplot (splątanie)φ#
Rozplątanie i jego dopełnienieΘ, ∼Θ& , %
Agregacja i serializacja (AGSE)Ψ@
Przesunięcieτ>

Dla samodzielności dowodów przytaczam dwie definicje, do których będę się bezpośrednio odwoływał.

Przeplot φ(A, B) tworzy strumień wynikowy, którego kolejne krotki wyznacza reguła:

\[ c_{n}= \left\{ \begin{array}{cc} b_{n-\left\lfloor n z \right\rfloor } & \left\lfloor n z \right\rfloor = \left\lfloor \left( n+1\right) z \right\rfloor \\ a_{\left\lfloor n z \right\rfloor } & \left\lfloor n z \right\rfloor \neq \left\lfloor \left( n+1\right) z \right\rfloor \end{array} \right. , \ z = \frac{\Delta _{b}}{\Delta _{a}+\Delta _{b}}, \ \Delta _{c}=\frac{\Delta _{a}\Delta _{b}}{\Delta _{a}+\Delta _{b}} \]

Rozplątanie definiują dwa komplementarne wzory – operator Θ odtwarzający pierwotny strumień oraz operator ∼Θ wyznaczający „resztę“ rozplątania:

\[ a_{n} = c_{n+ \left\lceil \frac{(n+1)\Delta _{a}}{\Delta _{b}} \right\rceil },\ \Delta _{a}=\frac{\Delta _{c}\Delta _{b}}{\left\vert \Delta _{c}-\Delta _{b}\right\vert } \]

\[ b_{n} = c_{n+\left\lfloor \frac{n\Delta_{b}}{\Delta_{a}}\right\rfloor},\ \Delta_{b}=\frac{\Delta_{c}\Delta_{a}}{\left\vert \Delta_{c}-\Delta_{a}\right\vert } \]

Twierdzenie 1: przeplot zapewnia pokrycie zbiorów

✅ Uwaga

Twierdzenie. Operacja splątania (przeplotu) zapewnia sekwencyjne pokrycie obu zbiorów indeksów strumieni danych będących jej argumentami: każdy element strumienia A i każdy element strumienia B zostaje wybrany dokładnie raz, po kolei, bez przerw i bez powtórzeń.

Dowód. Ponieważ 0 < z < 1, przyrost

\[ d_{n} := \left\lfloor \left( n+1\right) z \right\rfloor - \left\lfloor n z \right\rfloor \]

dla każdego n ≥ 0 równy jest 0 albo 1. Równanie przeplotu wybiera element strumienia B dokładnie w tych krokach, w których dn = 0 (gałąź równości), a element strumienia A dokładnie w krokach z dn = 1.

Rozważmy indeks wyboru z ciągu B: xn = n − ⌊nz⌋. W jednym kroku xn+1 − xn = 1 − dn: indeks rośnie o dokładnie 1 w każdym kroku wybierającym z B, a poza tym pozostaje bez zmian. Jeśli więc n < n′ są dwoma kolejnymi krokami wybierającymi z B, to xn′ = xn + 1. Pierwszym krokiem wybierającym z B jest n = 0, gdyż z 0 < z < 1 wynika ⌊0⌋ = ⌊z⌋ = 0, czyli d0 = 0, a przy tym x0 = 0. Wybory z ciągu B używają zatem indeksów 0, 1, 2, … po kolei, bez przerw i powtórzeń.

Symetrycznie: indeks wyboru z ciągu A, czyli ⌊nz⌋, rośnie o dokładnie 1 w każdym kroku wybierającym z A (dn = 1), a poza tym pozostaje bez zmian; w pierwszym takim kroku jego wartość wynosi 0 (wszystkie wcześniejsze kroki mają d = 0). Elementy ciągu A również są więc wybierane dokładnie raz każdy, po kolei. ∎

Twierdzenie 2: rozplątanie spełnia postulaty Fraenkela

To jest centralne twierdzenie tej strony. Dowodzi, że obie sekwencje opisujące operację rozplątania są szczególnym przypadkiem sekwencji Beatty spełniającym postulaty twierdzenia Fraenkela dla liczb wymiernych. Bez tego twierdzenia cały system pozostaje jedynie obietnicą.

✅ Uwaga

Twierdzenie. Niech a, b ∈ ℕ>0 reprezentują wymierny stosunek temp strumieni składowych, ∆a/∆b = a/b. Obie sekwencje wyboru krotek opisujące operację rozplątania są – z dokładnością do wyrównania indeksów wskazanego w dowodzie – szczególnym przypadkiem sekwencji Beatty spełniającym postulaty twierdzenia Fraenkela dla parametrów wymiernych. W konsekwencji dokonują one podziału zbioru ℕ₀ := ℕ ∪ {0}, czyli zbioru indeksów strumienia splątanego, a rozplątanie dokładnie odwraca splątanie przy użyciu wyłącznie arytmetyki liczb wymiernych.

Dowód – część pierwsza (sprowadzenie do postaci Beatty). Sekwencja wyboru krotek residuum rozplątania (operator ∼Θ) ma postać:

\[ \left( n + \left\lfloor \frac{nb}{a} \right\rfloor \right) _{n=0}^{\infty } \]

Jej wyraz początkowy (n = 0) wynosi 0; wyrazy dla n ≥ 1 tworzą część Beatty. Dla n ∈ ℕ, na mocy własności ⌊x + C⌋ = ⌊x⌋ + C, zachodzi n + ⌊nb/a⌋ = ⌊n + nb/a⌋, poszukujemy więc α, α′ takich, że:

\[ \left( \left\lfloor \frac{n-\alpha ^{\prime }}{\alpha }\right\rfloor \right) _{n=1}^{\infty } = \left( \left\lfloor n\frac{a + b}{a} \right\rfloor \right) _{n=1}^{\infty } \]

Odczytując nachylenie i wyraz wolny: przy przesunięciu α′ = 0 otrzymujemy α = a/(a+b), a sekwencja wyboru ograniczona do n ≥ 1 to dokładnie:

\[ \mathcal{B}\!\left( \frac{a}{a + b}, 0 \right) = \left( \left\lfloor n\frac{a + b}{a} \right\rfloor \right) _{n=1}^{\infty } \]

Dowód – część druga (weryfikacja pięciu postulatów i wyznaczenie residuum). Sprawdzamy kolejno postulaty twierdzenia Fraenkela dla α = a/(a+b), α′ = 0:

  1. Wartość α = a/(a+b) dla a, b > 0 jest większa od zera i mniejsza od jedności.
  2. Warunek α + β = 1 jest spełniony dla β = b/(a+b).
  3. Dla α′ = 0 postulat jest równoważny postulatowi 1.
  4. Postulat jest pusty, gdyż α jest liczbą wymierną.
  5. Najmniejszą liczbą q, dla której qα ∈ ℕ, jest q = (a+b)/nwd(a,b); wówczas warunek 1/q ≤ α + α′ = α jest spełniony, a warunek ⌈qα′⌉ + ⌈qβ′⌉ = 1 przy α′ = 0 wymusza ⌈qβ′⌉ = 1, czyli 0 < β′ ≤ nwd(a,b)/(a+b). Każda dopuszczalna wartość generuje tę samą sekwencję (dopełnienie sekwencji B(a/(a+b), 0) w ℕ jest jednoznaczne); przyjmujemy β′ = nwd(a,b)/(a+b).

Sekwencją dopełniającą sekwencję B(a/(a+b), 0) w sensie postulatów Fraenkela jest zatem:

\[ \mathcal{B}\!\left( \frac{b}{a + b}, \frac{\operatorname{nwd}(a, b)}{a + b} \right) \]

Po przeindeksowaniu n ↦ n + 1, tak aby biegła od n = 0 – zgodnie z sekwencjami wyboru w definicji rozplątania – przyjmuje ona postać:

\[ \left( \left\lfloor \frac{(n + 1) - \frac{\operatorname{nwd}(a,b)}{a+b}}{\frac{b}{a+b}} \right\rfloor \right) _{n=0}^{\infty } \]

Rozwijając powyższe wyrażenie:

\[ \left\lfloor \frac{(n + 1) - \frac{\operatorname{nwd}(a,b)}{a+b}}{\frac{b}{a+b}} \right\rfloor = \left\lfloor n\frac{a}{b} + n + \frac{a}{b} + 1 - \frac{\operatorname{nwd}(a, b)}{b} \right\rfloor \]

Porównując to – wyraz po wyrazie dla n ≥ 0 – z sekwencją wyboru krotek strumienia odtwarzanego (operator Θ):

\[ \left( n + \left\lceil \frac{(n + 1)a}{b} \right\rceil \right) _{n=0}^{\infty } \]

i wydzielając część całkowitą n + 1 na mocy własności ⌊x + C⌋ = ⌊x⌋ + C, teza sprowadza się (po podstawieniu n w miejsce n + 1, tak że n przebiega zbiór ℕ>0) do tożsamości:

\[ \left\lfloor n\frac{a}{b} - \frac{\operatorname{nwd}(a, b)}{b} \right\rfloor + 1 = \left\lceil n\frac{a}{b} \right\rceil ,\quad n \in \mathbb{N}_{>0} \]

Dowód – część trzecia (analiza przypadków). Korzystając z własności współczynnika nwd(a, b), rozważamy dwa rozłączne przypadki pokrywające całą dziedzinę.

Przypadek 1: nwd(a, b) = b, czyli a/b ∈ ℕ. Wtedy n·a/b ∈ ℕ, więc na mocy tożsamości ⌊x⌋ = ⌈x⌉ ⟺ x ∈ ℤ mamy ⌈n·a/b⌉ = ⌊n·a/b⌋, a na mocy ⌊x + C⌋ = ⌊x⌋ + C:

\[ \left\lfloor n\frac{a}{b} - 1 \right\rfloor + 1 = \left\lfloor n\frac{a}{b} \right\rfloor \]

Obie strony dowodzonej tożsamości pokrywają się.

Przypadek 2: b ∤ a, czyli 1 ≤ nwd(a, b) < b oraz 0 < nwd(a,b)/b < 1.

Jeśli n·a/b ∉ ℤ, to na mocy ⌊x⌋ + 1 = ⌈x⌉ ⟺ x ∈ ℝ ∖ ℤ zachodzi ⌈n·a/b⌉ = ⌊n·a/b⌋ + 1. Część ułamkowa liczby n·a/b jest niezerową wielokrotnością nwd(a,b)/b, a więc wynosi co najmniej nwd(a,b)/b; odjęcie nwd(a,b)/b od n·a/b nie może zatem przekroczyć w dół liczby całkowitej poniżej ⌊n·a/b⌋, skąd:

\[ \left\lfloor n\frac{a}{b} - \frac{\operatorname{nwd}(a, b)}{b} \right\rfloor = \left\lfloor n\frac{a}{b} \right\rfloor \]

i dowodzona tożsamość zachodzi.

Jeśli n·a/b ∈ ℤ, to ⌈n·a/b⌉ = n·a/b, a ponieważ 0 < nwd(a,b)/b < 1:

\[ \left\lfloor n\frac{a}{b} - \frac{\operatorname{nwd}(a, b)}{b} \right\rfloor = n\frac{a}{b} - 1 \]

co ponownie daje dowodzoną tożsamość.

Obie sekwencje wyboru opisujące operację rozplątania są więc – z dokładnością do jednostkowego przeindeksowania z części drugiej – sekwencjami Beatty spełniającymi postulaty Fraenkela dla parametrów wymiernych: para B(a/(a+b), 0) i B(b/(a+b), nwd(a,b)/(a+b)) dokonuje podziału zbioru ℕ, a wraz z początkowym wyrazem residuum 0 z części pierwszej – podziału zbioru ℕ₀, pełnego zbioru indeksów strumienia splątanego. Strumień odtworzony i residuum są zatem dokładne. ∎

✅ Uwaga

Wniosek (dokładna odwracalność na liczbach wymiernych). Dla strumieni o tempach wymiernych operatory Θ i ∼Θ odtwarzają strumienie składowe φ(A, B) dokładnie (bit w bit): żadna krotka nie ginie, nie dubluje się ani nie zmienia kolejności względem swojego strumienia składowego. Para (φ; Θ, ∼Θ) zachowuje się więc jak mnożenie i dzielenie, a para (Σ; δ) jak dodawanie i odejmowanie w zbiorze regularnych serii czasowych.

⚠️ Ostrzeżenie

Praktyczny morał z tego dowodu: w implementacji nie wolno opuszczać dziedziny liczb wymiernych nawet na chwilę. Niejawne rzutowanie wyniku pośredniego na liczbę zmiennoprzecinkową łamie założenia powyższego twierdzenia. Materializację do postaci zmiennoprzecinkowej należy odłożyć do momentu jawnego zastosowania operacji podłogi lub sufitu.

Własności operatorów wykorzystywane w optymalizacji

W oparciu o przedstawioną algebrę można wykazać szereg własności strumieni danych. Mają one bezpośrednie zastosowanie w systemie zarządzania danymi – w trakcie optymalizacji planów zapytań oraz interpretacji wyników.

Zaburzenie kolejności zdarzeń

✅ Uwaga

Twierdzenie. Kolejność elementów w strumieniu nie odzwierciedla faktycznej kolejności występowania elementów w świecie rzeczywistym.

Dowód (przez kontrprzykład). Rozważmy dwa strumienie:

Alfa(znak),2:    {1,2,3,4,5,6,...}
Epsilon(znak),3: {a,b,c,d,e,f,...}

Wyrażenie φ(Epsilon, Alfa) tworzy strumień wynikowy:

Tau(znak),6/5:   {1,2,a,3,b,4,5,c,6,d,...}

W strumieniu Tau krotka oznaczona literą c występuje po krotce oznaczonej cyfrą 5. Tymczasem krotka c pojawia się w strumieniu Epsilon w 9. sekundzie, a krotka 5 w strumieniu Alfa – w 10. sekundzie. Naturalny porządek zdarzeń został w strumieniu wynikowym naruszony. Wniosek: prowadząc analizę względem czasu zawartego w strumieniach, konieczne jest zastosowanie operacji rozplątania w celu uzyskania pierwotnej postaci strumieni danych. ∎

Przemienność sumowania

✅ Uwaga

Twierdzenie. Operacja sumowania strumieni danych, z pominięciem kolejności atrybutów, jest przemienna.

Dowód. Załóżmy ∆a ≤ ∆b; przypadek przeciwny jest symetryczny. Pierwszy przypadek definicji sumy daje jako n-ty element strumienia Σ(A, B) krotkę:

\[ c_{n} = \left( a_{n},\ b_{\left\lfloor n\Delta_{a}/\Delta_{b} \right\rfloor} \right) \]

natomiast dla Σ(B, A) role argumentów są zamienione i zastosowanie ma jej drugi (a przy ∆a = ∆b – pierwszy) przypadek, co daje n-ty element:

\[ c_{n} = \left( b_{\left\lfloor n\Delta_{a}/\Delta_{b} \right\rfloor},\ a_{n} \right) \]

Oba strumienie niosą ∆c = ∆a. Pokrywają się więc z dokładnością do kolejności sklejonych atrybutów. ∎

Metoda dopasowania przeplotu

Operacja przeplotu nie jest w ogólności przemienna: ponieważ 0 < z < 1, w punkcie n = 0 zawsze zachodzi gałąź równości w definicji przeplotu, więc strumień φ(A, B) zaczyna się od elementu b₀, a strumień φ(B, A) – od elementu a₀. Przeplot jest jednak ekwiwariantny względem przesunięć czasowych dopasowanych do temp strumieni – co jest cenne w optymalizacji planów zapytań.

W realizacji przyczynowej strumień ma postać \(\widehat{S}=((s_n,\Delta),W_S)\), gdzie \(W_S\) jest ogonem startowym. Przeliczenie ogona producenta na sloty wyjścia definiujemy jako:

\[ \operatorname{conv}(w,\Delta_s,\Delta_o):= \left\lceil\frac{w\Delta_s}{\Delta_o}\right\rceil \]

Ogon przeplotu o interwale \(\Delta_c=\Delta_a\Delta_b/(\Delta_a+\Delta_b)\) wyprowadza się wprost z definicji operatora, bez pośrednictwa jednego członu fazowego.

Rekord \(i\) strumienia \(\varphi(A,B)\) niesie treść rekordu \(j(i)\) jednej ze składowych — tej, którą w slocie \(i\) wybiera definicja przeplotu. Oznaczmy przez \(\Delta_{s(i)}\) i \(W_{s(i)}\) interwał oraz ogon wybranej składowej. Rekord \(j(i)\) jest określony w chwili \(\bigl(j(i)+1+W_{s(i)}\bigr)\Delta_{s(i)}\), a slot \(i\) konsumenta kończy się w chwili \((i+1+W)\Delta_c\). Warunek przyczynowości dla każdego \(i\):

\[ W\ge \left\lceil\frac{\bigl(j(i)+1+W_{s(i)}\bigr)\Delta_{s(i)}}{\Delta_c}\right\rceil -1-i \]

Niech \(\Delta_a/\Delta_b=p/q\), gdzie \(p,q\in\mathbb{N}_{>0}\) i \(\gcd(p,q)=1\). Zarówno wybór składowej, jak i reszta wyznaczająca \(j(i)\) powtarzają się z okresem \(p+q\), więc maksimum prawej strony po jednym okresie jest maksimum po wszystkich rekordach:

\[ W_{\varphi(A,B)} =\max_{0\le i<p+q}\left( \left\lceil\frac{\bigl(j(i)+1+W_{s(i)}\bigr)\Delta_{s(i)}}{\Delta_c}\right\rceil -1-i \right) \]

Wzór jest dokładny: nie zawyża ani nie zaniża granicy zdarzeniowej dla żadnego węzła. Przegląd okresu zaczyna się od zera — początek logiczny przesuwa indeks konsumenta i indeks składowej o tę samą liczbę slotów, więc okno \([0,,p+q)\) daje tę samą wartość co okno przesunięte.

Wcześniejsza postać zamknięta

\[ W_{\varphi(A,B)} =\max\left( \operatorname{conv}(W_A,\Delta_a,\Delta_c), \operatorname{conv}(W_B,\Delta_b,\Delta_c) +H_{a,b} \right), \qquad H_{a,b}=\left\lceil\frac{p+q-1}{p}\right\rceil \]

zabezpieczała najgorszą fazę odczytu drugiego argumentu, ale nie sprawdzała, czy ta faza w ogóle wypada na rekord czekający najdłużej — dlatego zawyżała ogon o slot dla części węzłów. Pozostała w implementacji jako wariant awaryjny dla \(p+q\) powyżej progu przeglądu (kHashPhaseScanLimit w SOperations.hpp): zawyżenie kosztuje jeden slot opóźnienia, podczas gdy zaniżenie oznaczałoby rekord wyemitowany przed określeniem jego zależności. Sloty ogona nie są rekordami.

Przesunięcie \(\tau_m\) nie zmienia emitowanego ciągu rekordów, ale zmienia indeks, pod którym ten ciąg się pojawia: rekord \(n\) niesie treść rekordu \(n-m\). Rekordy o indeksie mniejszym od \(O_S+m\) nie mają definicji, więc

\[ O_{\tau_m(S)}=O_S+m, \qquad W_{\tau_m(S)}=\max\left(0,;W_S-m\right) \]

Ogon maleje: rekord \(n-m\) jest starszy od bieżącego, więc dostępny tym bardziej — deficyt slotu wynosi \(W_S-m\) i jest stały. Szczegóły i pomiar: Ogony, początki logiczne i obserwowalność operatorów.

✅ Uwaga

Twierdzenie (R1, przemienność przesunięcia z przeplotem). Jeśli liczby i, k ∈ ℕ wybrano tak, że i·∆a = k·∆b (oba argumenty przesunięte o ten sam czas), to przeplot strumieni przesuniętych i przeplot strumieni pierwotnych przesunięty o sumę tych liczb mają ten sam ciąg rekordów, ten sam interwał i ten sam początek logiczny. Ich ogony spełniają nierówność — strona sfaktoryzowana nigdy nie jest późniejsza.

Formalnie, dla \(L:=i+k\):

\[ \operatorname{Obs}\Bigl(\varphi\bigl(\tau_{i}(A),\tau_{k}(B)\bigr)\Bigr) =\operatorname{Obs}\Bigl(\tau_{i+k}\bigl(\varphi(A,B)\bigr)\Bigr), \qquad i\Delta_{a}=k\Delta_{b},\quad i,k\in\mathbb{N} \]

\[ W_{\mathrm{RHS}}=\max\left(0,;W_{\varphi(A,B)}-L\right)\le W_{\mathrm{LHS}} \]

gdzie \(\operatorname{Obs}\) jest częścią wartościową obserwacji (interwał, początek logiczny, ciąg rekordów z mapą NULL, deskryptor, ślad luk, polityka materializacji) — patrz Ogony, początki logiczne i obserwowalność operatorów.

Dowód.

Interwał. Obie strony powstają z tego samego przeplotu, więc mają \(\Delta_c=\Delta_a\Delta_b/(\Delta_a+\Delta_b)\).

Krok pomocniczy. Z założenia \(i\Delta_a=k\Delta_b\) wynika

\[ \frac{i\Delta_a}{\Delta_c} =\frac{i\Delta_a(\Delta_a+\Delta_b)}{\Delta_a\Delta_b} =\frac{i\Delta_a}{\Delta_b}+i =k+i =L\in\mathbb{N}, \]

i symetrycznie \(k\Delta_b/\Delta_c=L\). Przesunięcie każdego argumentu o jego własną liczbę slotów odpowiada więc tej samej liczbie \(L\) slotów wyniku.

Ciąg rekordów i początek logiczny. W jednym okresie przeplot pobiera \(i\) rekordów z A i \(k\) rekordów z B, wypełniając dokładnie \(L=i+k\) slotów C. Przesunięcie A o \(i\) i B o \(k\) przesuwa zatem próg odwzorowania obu składowych o dokładnie \(L\) slotów wyniku, nie zmieniając ich wzajemnej fazy: \(O_{\mathrm{LHS}}=O_{\varphi(A,B)}+L=O_{\mathrm{RHS}}\). Treść rekordu o danym indeksie logicznym jest po obu stronach ta sama, bo wybór składowej zależy wyłącznie od fazy, a ta jest niezmieniona.

Ogony. Niech \(s(n)\in\{A,B\}\) oznacza składową wybraną w fazie \(n\), a \(j(n)\) jej indeks. Oznaczmy przesunięcia przez \(t_A=i\) i \(t_B=k\). Po przesunięciu ogon składowej wynosi \(W_s^{\prime}=\max(0,W_s-t_s)\ge W_s-t_s\). Nie zmieniają się interwały ani wybór składowej i jej indeks w danej fazie przeplotu. Niech \(R_n\) będzie wymaganiem dostępności z powyższego wzoru fazowego dla ogonów \(W_A,W_B\), a \(R_n^{\prime}\) wymaganiem dla \(W_A^{\prime},W_B^{\prime}\). Z kroku pomocniczego mamy \(t_s\Delta_s/\Delta_c=L\in\mathbb{N}\) dla obu składowych. Monotoniczność sufitu i jego zgodność z przesunięciem o całkowite \(L\) dają w każdej fazie:

\[ \begin{aligned} R_n^{\prime} &=\left\lceil \frac{(j(n)+1+W_{s(n)}^{\prime})\Delta_{s(n)}}{\Delta_c} \right\rceil-1-n\\ &\ge\left\lceil \frac{(j(n)+1+W_{s(n)}-t_{s(n)})\Delta_{s(n)}}{\Delta_c} \right\rceil-1-n =R_n-L. \end{aligned} \]

Maksimum bierzemy po tym samym pełnym okresie \(p+q\), ponieważ przesunięcia nie zmieniają stosunku interwałów. Korzystając dodatkowo z nieujemności ogonów, otrzymujemy:

\[ W_{\mathrm{LHS}} \ge\max\left(0,\max_{0\le n<p+q}R_n-L\right) =\max\left(0,W_{\varphi(A,B)}-L\right) =W_{\mathrm{RHS}}. \]

Powyżej progu przeglądu silnik stosuje opisane wcześniej oszacowanie awaryjne \(O(1)\). Dla niego tę samą nierówność uzyskujemy przez monotoniczność obu członów \(\operatorname{conv}\): dopasowane przesunięcie zmniejsza każdy z nich najwyżej o \(L\), a składnik \(H_{a,b}\) pozostaje bez zmian. Obie strony korzystają z tego samego wariantu obliczeń, bo interwały się nie zmieniają. Oszacowanie awaryjne nie musi być równe dokładnemu maksimum fazowemu. ∎

⚠️ Zakres twierdzenia

Równość ogonów nie zachodzi. Kontrprzykład: \(\Delta_a=1/10\), \(\Delta_b=1/5\), \(W_A=W_B=0\), \(H_{a,b}=2\), \(i=2\), \(k=1\), \(L=3\). Wtedy \(W_{\mathrm{LHS}}=2\), a \(W_{\mathrm{RHS}}=\max(0,2-3)=0\). Strona niesfaktoryzowana czyta składowe po ich własnym przesunięciu, więc na tę samą treść czeka dłużej; strona sfaktoryzowana czyta ją wprost z przeplotu.

Konsekwencja praktyczna: reguła przepisywania \(\varphi(\tau_i(A),\tau_k(B))\to\tau_{i+k}(\varphi(A,B))\) jest optymalizacją opóźnienia, a nie przepisaniem neutralnym. Zachowuje całą część wartościową obserwacji i nigdy nie emituje rekordu przed określeniem jego zależności, ale wynik jest gotowy wcześniej.

Wcześniej obie strony miały ten sam ogon wyłącznie dlatego, że realizacja \(\tau_m\) zawyżała swój ogon o \(\min(W_S,m)\). Zawyżenie zdjęto, adresując producenta indeksem logicznym zamiast offsetem względnym. Regresje strzegące tego zakresu: it_r1_identity_nulls, it_optimizer_ablation-factor-name-collision-semantic.

W kompilatorze dodatkowe niezmienniki zachowują nazwy pól publicznych strumieni, mapy wartości pustych i politykę materializacji.

Dlaczego to ma znaczenie

Przedstawione twierdzenia nie są formalnością dla samej formalności. Każde z nich pełni konkretną rolę w działającym systemie:

  • Twierdzenie 1 i 2 gwarantują, że pary operacji przeplot/rozplątanie oraz suma/różnica są komplementarne – dane nie giną i nie powielają się w sposób niekontrolowany. To one pozwalają traktować te operacje jak mnożenie/dzielenie oraz dodawanie/odejmowanie w zbiorze regularnych serii czasowych.
  • Twierdzenie 2 w szczególności udowadnia, że całą konstrukcję da się zrealizować wyłącznie na liczbach wymiernych – a więc deterministycznie i dokładnie na komputerze. To jest warunek, bez którego system RetractorDB nie mógłby istnieć.
  • Twierdzenia o własnościach operatorów (przemienność sumowania, dopasowanie przeplotu, zaburzenie kolejności) dostarczają reguł przepisywania wyrażeń strumieniowych. Optymalizator planów zapytań korzysta z nich, aby przekształcać plany do postaci tańszej w realizacji, nie zmieniając wyniku.

Dział matematyki, w którym osadzone są te równania, to teoria układów pokrywających [4] w obszarze teorii liczb. Pełny formalizm wraz z kompletem dowodów przedstawiłem w pracy Deterministyczna metoda przetwarzania ciągów danych [3].

ℹ Info

Numeryczna weryfikacja powyższych równań – prototypy w języku Python operujące na liczbach wymiernych (biblioteka Fraction) – znajduje się na stronie Implementacja modelu oraz w repozytorium github.com/michalwidera/equations.

Ogony, początki logiczne i obserwowalność operatorów

Wykonanie przyczynowe rozszerza strumień \(S=(s_n,\Delta_S)\) o dwie wielkości całkowite, a nie o jedną. Rozróżnienie jest istotne, bo odpowiadają na różne pytania i różnie zachowują się przy przepisaniach planu.

WielkośćPytanieZnaczenie
początek logiczny \(O_S\)którego rekordu nie ma?indeks pierwszego rekordu, który w ogóle istnieje; rekordy o mniejszym indeksie nie mają definicji, bo sięgałyby przed początek strumienia źródłowego
ogon startowy \(W_S\)kiedy rekord jest gotowy?rekord \(n\) jest emitowany w chwili \((n+1+W_S)\Delta_S\)

Żadna z nich nie jest prefiksem zer ani rekordów all-null. Zasada brzegu obowiązuje bez zmian: NULL jest wartością danych, nigdy rezerwacją miejsca. Liczba początkowych slotów, w których strumień milczy, wynosi \(O_S+W_S\) — i tylko ta suma była widoczna przed rozdzieleniem obu wielkości.

Indeks logiczny jest walutą wszystkich odwzorowań między strumieniami. Strumień o niezerowym \(O_S\) nie ma rekordów wcześniejszych, więc jego rekord fizyczny 0 nosi indeks logiczny \(O_S\); przeliczenie na offset w buforze wykonuje wyłącznie dataModel::fetchForward().

Audyt operatorów

W tabeli „własny ogon” oznacza opóźnienie wymagane przez operator ponad dostępność producentów. Ogony producentów są wcześniej przeliczane na sloty wyniku.

OperatorIndeks źródłowy lub granicaPoczątek logicznyWłasny ogonTest
projekcja / PUSH_STREAMbieżąca krotka\(O_S\)0ut_compiler
przesunięcie >Nrekord \(n-N\)\(O_S+N\)\(-N\), patrz niżejut_compiler, ut_h10aGate
suma +bieżące współindeksowane krotkipróg odwzorowania0ut_compiler
przeplot #rekord \(j(i)\) składowej wybranej w slocie \(i\)próg odwzorowaniawzór niżejdeinterleave_roundtrip, ut_h10aGate
lewy rozplot & (DIV)\(n+\lceil(n+1)\Delta_a/\Delta_b\rceil\)próg odwzorowaniawzór fazowy poniżejdeinterleave_roundtrip, ut_h10aGate
prawy rozplot % (MOD)\(n+\lfloor n\Delta_b/\Delta_a\rfloor\)próg odwzorowaniawzór fazowy poniżejdeinterleave_roundtrip, ut_h10aGate
różnica C-Delta\(\lceil n\Delta/\Delta_C\rceil\)próg odwzorowaniawzór fazowy poniżejit_k19_boundaries, ut_h10aGate
AGSE @(k,L)pola od \(nk-(\lvert L\rvert-1)\) do \(nk\)wzór niżejwzór niżejagse1, agse2, agse3, it_k19_boundaries, ut_h10aGate
sumc, avgc, minc, maxcbieżąca pełna krotka\(O_S\)0ut_dataModel, it_k19_boundaries

„Próg odwzorowania” oznacza najmniejszy indeks \(n\), od którego wszystkie dalsze rekordy trafiają odwzorowaniem w istniejące rekordy składowych. Nie jest to „pierwszy indeks o kompletnych zależnościach”: przy przeplocie składowych o różnych początkach rekord 0 może mieć komplet, a rekord 1 już nie. Strumień jest ciągiem rekordów, nie zbiorem z dziurami — zasada brzegu zabrania wypełnić lukę NULL-em — więc początkiem logicznym jest pierwszy indeks bez żadnej dalszej luki. Wszystkie odwzorowania rekord–rekord są niemalejące, więc taki indeks istnieje i jest jednoznaczny.

Różnica przyjmuje docelowy interwał \(\Delta\), który nie może być mniejszy od interwału źródła \(\Delta_C\). Dla stosunku \(r=\Delta/\Delta_C=p/q\) maksymalne wyprzedzenie fazowe indeksu \(\lceil nr\rceil\) wynosi \((q-1)/q\).

Ogony operatorów fazowych

Różnica oraz oba rozploty używają wspólnej reguły dostępności. Niech \(r=\Delta_{out}/\Delta_{src}\), \(W_S\) będzie ogonem źródła, a \(e_{max}\) — największą osiąganą fazą odwzorowania indeksu. Wtedy:

\[ W_{out}=\max\left(0, \left\lceil\frac{e_{max}+W_S+1}{r}\right\rceil-1 \right) \]

Dla różnicy \(e_{max}=(q-1)/q\), gdzie \(q\) jest mianownikiem skróconego stosunku \(r\). Dla lewego rozplotu, przy \(\Delta_{out}/\Delta_{other}=a/b\), wartość wynosi \(e_{max}=(a+b-1)/b\). Dla prawego rozplotu \(e_{max}=0\). Oznacza to między innymi, że lewy rozplot nie dodaje bezwarunkowo jednego slotu: dla stosunku całkowitego jego własny ogon może wynosić zero.

Ogon przeplotu

Przeplot jest jedynym operatorem, którego ogon nie rozkłada się na „przeliczone ogony producentów plus stała własna“. Rekord \(i\) niesie treść rekordu \(j(i)\) tylko jednej ze składowych — tej, którą w slocie \(i\) wybiera definicja operatora — więc wymagane opóźnienie zależy od tego, na którą składową i na którą jej fazę wypada dany slot:

\[ W_{\#} =\max_{0\le i<p+q}\left( \left\lceil\frac{\bigl(j(i)+1+W_{s(i)}\bigr)\Delta_{s(i)}}{\Delta_c}\right\rceil -1-i \right), \qquad \frac{\Delta_a}{\Delta_b}=\frac{p}{q},\quad \gcd(p,q)=1 \]

Wybór składowej i faza powtarzają się z okresem \(p+q\), więc maksimum po jednym okresie jest maksimum po wszystkich rekordach; początek logiczny przesuwa oba indeksy o tyle samo i nie zmienia wyniku. Wzór jest dokładny. Dla okresów przekraczających kHashPhaseScanLimit implementacja używa bezpiecznej postaci zamkniętej. Może ona opóźnić emisję, ale nie może dopuścić do emisji rekordu przed udostępnieniem jego zależności.

Przesunięcie \(\tau_N\)

Rekord \(n\) niesie treść rekordu \(n-N\) producenta. Stąd obie wielkości:

\[ O_{\tau_N(S)}=O_S+N, \qquad W_{\tau_N(S)}=\max\left(0,;W_S-N\right) \]

Ogon maleje, a nie rośnie. Rekord \(n-N\) jest starszy od bieżącego, więc jest dostępny tym bardziej: deficyt slotu \(n\) wynosi \((n-N+1+W_S)-(n+1)=W_S-N\) i jest stały, niezależny od \(n\). Przesunięcie przenosi więc milczenie z ogona do początku logicznego i dodatkowo pochłania ogon producenta, gdy \(N\ge W_S\).

Suma \(O+W\) nie jest przy tym niezmiennikiem: dla \(N<W_S\) wynosi \(N+W_S-N=W_S\) po lewej, a była \(W_S+N\) w realizacji sprzed rozdzielenia wielkości. Wcześniejsza realizacja zawyżała ogon o \(\min(W_S,N)\); zawyżenie zdjęto, adresując producenta indeksem logicznym zamiast offsetem względnym.

Pełne okno AGSE

Okno jest stemplowane końcem przedziału: rekord \(n\) obejmuje spłaszczone pozycje źródła od \(nk-(\lvert L\rvert-1)\) do \(nk\). Dzięki temu jego najnowsze pole leży dokładnie w pozycji \(nk\), a indeks logiczny okna oznacza tę samą chwilę co indeks logiczny źródła — złączenie okna z jego własnym źródłem (potok FIR) nie wyprzedza sygnału.

Ceną konwencji jest to, że dla małych \(n\) okno sięgałoby przed początek źródła. Te rekordy nie powstają. Niech źródło ma \(F\) pól i początek logiczny \(O_S\); warunek zmieszczenia się całego okna daje

\[ O_{\operatorname{AGSE}} =\left\lceil\frac{O_S F+\lvert L\rvert-1}{k}\right\rceil \]

O dostępności decyduje pole najnowsze, leżące w rekordzie \(\lfloor nk/F\rfloor\). Podstawiając \(r_n=(nk)\bmod F\) warunek dostępności dla każdego \(n\) przyjmuje postać \(W\ge\bigl(F(1+W_S)-r_n\bigr)/k-1\). Reszty \(r_n\) przebiegają wielokrotności \(\gcd(F,k)\) okresowo, więc minimum \(r_n=0\) jest osiągane niezależnie od tego, od którego \(n\) zaczyna się strumień. Stąd

\[ W_{\operatorname{AGSE}} =\left\lceil\frac{(1+W_S)F}{k}\right\rceil-1 \]

Człon fazowy \(P_{F,k,L}=\lfloor(\lvert L\rvert-1)/g\rfloor,g\), obecny w postaci sprzed przestemplowania, zniknął z ogona: rozpiętość okna nie jest czekaniem, tylko niedefiniowalnością, i przeszła w całości do początku logicznego. Suma \(O+W\) opisuje to samo milczenie co poprzednio.

Dodatnia szerokość zachowuje historyczną konwencję RetractorDB — najnowsze pole jest pierwsze; ujemna szerokość daje odbicie lustrzane, czyli kolejność napływu.

Pojemność historii źródła nie ma tu postaci zamkniętej. Odległość wsteczna w chwili emisji rekordu \(n\),

\[ \left\lfloor\frac{(n+1+W)k}{F}\right\rfloor-W_S-1 ;-; \left\lfloor\frac{nk-\lvert L\rvert+1}{F}\right\rfloor, \]

jest okresowa o okresie \(F/\gcd(F,k)\) slotów wyjścia, więc maksimum liczy się dokładnie, przeglądając jeden pełny okres od \(O_{\operatorname{AGSE}}\). Postać zamknięta byłaby w tym miejscu domysłem, a zaniżenie oznacza odczyt poza historią, nie tylko slot opóźnienia. Źródło deklarowane ma rekord uzbrojony przy otwarciu storage i zerowy prefetch, dlatego jego granica pojemności zawiera dwa dodatkowe rekordy. Pojemność jest własnością wykonania, nie częścią wyniku.

Relacja obserwowalności

Obserwacja strumienia rozpada się na dwie części, bo przepisania planu zachowują je w różnym stopniu.

Część wartościowa — zachowywana przez przepisania dokładnie:

\[ \operatorname{Obs}(S) =\left(\Delta_S,O_S,D_S,(s_n,N_n)_{n\ge O_S},G_S,M_S\right) \]

gdzie:

  • \(O_S\) jest początkiem logicznym, czyli indeksem pierwszego rekordu;
  • \(D_S\) jest publicznym deskryptorem i kolejnością nazw pól;
  • \(N_n\) jest mapą NULL rekordu — prawdziwy NULL pozostaje wartością danych i jest przenoszony przez AGSE;
  • \(G_S\) jest śladem luk; obecnie detekcja działa dla deklaracji, a dla strumieni obliczanych obowiązuje \(G_S=\varnothing\);
  • \(M_S\) opisuje politykę materializacji (DEFAULT, MEMORY, VOLATILE i pozostałe storage).

Część opóźnieniowa — ogon \(W_S\) — podlega słabszej gwarancji:

przepisanie planu nigdy nie zwiększa \(W_S\) i nigdy nie emituje rekordu przed określeniem jego zależności; wolno mu natomiast \(W_S\) zmniejszyć.

Rozdzielenie nie jest formalnością. Faktoryzacja \(R_1\) (\(\varphi(\tau_i(A),\tau_k(B))\to\tau_{i+k}(\varphi(A,B))\)) zachowuje całą część wartościową, ale skraca ogon: postać sfaktoryzowana czyta treść bezpośrednio z przeplotu, podczas gdy postać niefaktoryzowana czyta składowe dopiero po ich własnym przesunięciu. Uzasadnienie: Formalne podstawy i dowody, twierdzenie o przemienności przesunięcia z przeplotem. Regresje: it_r1_identity_nulls, it_optimizer_ablation-factor-name-collision-semantic.

Zmiana którejkolwiek składowej części wartościowej zmienia obserwowalny artefakt. W szczególności przyszłe włączenie propagacji luk w strumieniach obliczanych wymaga wersjonowanej zmiany semantyki.

Odczyt poza dostępną historią zwraca wewnętrznie rekord all-null jako bezpiecznik. Poprawnie skompilowany plan nigdy go nie materializuje: logicalOrigin pomija sloty bez definicji, startupLatency — sloty jeszcze nieokreślone, a pojemność historii zachowuje każdy wymagany indeks. Test it_k19_boundaries rozróżnia ten przypadek od prawdziwego NULL znajdującego się wewnątrz pełnego okna.

Wzory operatorowe z tego rozdziału są egzekwowane przy każdym commicie: granice i obserwowalność przez it_k19_boundaries, głębokości historii przez it_k24_capacity, a zgodność początku logicznego i ogona wszystkich kanonicznych klas operatorów oraz ich złożeń przez test jednostkowy ut_h10aGate.

Wyrażenia algebraiczne

Zdefiniowana algebra pociąga za sobą możliwość definicji wyrażeń algebraicznych. Typowe wyrażenia algebraiczne w zbiorze liczb wymiernych to materiał przerabiany w szkole podstawowej. Wyrażenia algebraiczne w systemie RetractorDB występują w dwóch formach. Na liście pól polecenia SELECT – mamy wyrażenia typowe, znane ze szkoły podstawowej. Na liście argumentów polecenia SELECT w klauzuli FROM mamy wyrażenie algebraiczne zbudowane w oparciu o nową, zdefiniowaną algebrę.

Oznacza to że na liście pól po klauzuli SELECT operator plus oznacza jedno a w klauzuli FROM – oznacza zupełnie coś innego. Niewinnie wyglądające zapytanie z definicji łączy dwa zupełnie inne światy i pojęcia. Jeden algebry opartej na liczbach drugiej opartej na regularnych seriach czasowych.

Przykład. Jako przykład przedstawione zostanie wyrażenie algebraiczne zbudowane w zbiorze regularnych serii czasowych (zwanych dalej strumieniami). Zakładając istnienie dwóch strumieni: A(a1 int, a2 int),1 oraz B(b1 int),½ – gdzie,

  • A oznacza strumień zawierający w każdym rekordzie dwa pola o wartościach typu int – a1 oraz a2, napływające raz na sekundę, oraz
  • B zawierający w każdym rekordzie pole typu int o nazwie b1 napływające dwa razy na sekundę.

To wyrażenie algebraiczne postaci C=A+B stworzy strumień danych o polach C(a1 int, a2 int, b1 int),½.

Aby dokonać przeplotu strumienia danych zbiory A i B powinny posiadać te same schematy danych. Załóżmy więc że istnieje strumień D(d1 int),1 – napływający podobnie jak strumień A – raz na sekundę.

To wyrażenie algebraiczne postaci E=B#D stworzy strumień: E(e1 int),⅓. Szybkość ⅓ bierze się ze wzoru (1*½)/(1+½). Wzór znajdziesz przy definicji operacji przeplotu.

W tak zdefiniowanych strumieniach nadal poprawne jest wyrażenie:

F=((B#D)+A)>2

I takie wyrażenia mogą się pojawić jako poprawne względem opracowanej algebry szeregów czasowych w treści zapytania.

Dalsze przykłady

Pozostając przy zdefiniowanych powyżej strumieniach A(a1 int, a2 int),1, B(b1 int),½ i D(d1 int),1 oraz strumieniach wynikowych C=A+B i E=B#D – poniżej zestawiono kolejne poprawne wyrażenia algebraiczne. Odpowiedniki wszystkich tych wyrażeń występują w klauzulach FROM zapytań w testach integracyjnych systemu i są weryfikowane przy każdej kompilacji projektu.

Przeplot wyniku przeplotu:

G=E#D

Strumień E ma szybkość ⅓, strumień D szybkość 1, oba mają zgodny schemat z jednym polem int. Wzór z definicji przeplotu daje szybkość (⅓·1)/(⅓+1)=¼, więc G(g1 int),¼. Wynik jednej operacji jest pełnoprawnym strumieniem i może być argumentem kolejnej.

Suma trzech strumieni:

H=A+B+D

Suma skleja krotki, więc schemat wyniku to konkatenacja schematów, a tempo narzuca najszybszy składnik: H(a1 int, a2 int, b1 int, d1 int),½.

Suma z przesuniętym składnikiem oraz przesunięcie argumentu przeplotu:

I=D+((A+B)>1)
J=(B>1)#D

Przesunięcie sekwencji nie zmienia szybkości strumienia – zmienia tylko dostęp do danych o zadaną liczbę próbek. Dlatego I ma szybkość min(1,½)=½, a J – tak jak E – szybkość ⅓.

Rozplątanie:

K=E&1
L=E%½

Prawym argumentem operatorów rozplątania jest liczba wymierna, nie strumień. Podstawiając do wzorów z definicji rozplątania: K ma szybkość (⅓·1)/|⅓−1|=½ – rozplątanie lewostronne odzyskuje ze splotu E strumień B. Analogicznie L ma szybkość (⅓·½)/|⅓−½|=1 – rozplątanie prawostronne odzyskuje strumień D. Rozplątanie jest odwrotnością przeplotu, tak jak dzielenie jest odwrotnością mnożenia.

Różnica:

M=C-1

Różnica jest operacją odwrotną do sumy – wydobywa ze sklejonego strumienia C składnik wskazany liczbą wymierną po prawej stronie operatora.

Agregacja i serializacja:

N=A@(1,4)
P=A@(1,-4)
R=A@(2,2)
S=(A@(2,2))@(1,1)

N tworzy ruchome okno szerokości 4 przesuwane o jeden element, P – dzięki ujemnej szerokości – buduje te same okna w odbiciu lustrzanym, R tworzy okna rozłączne (skok równy szerokości). Wyrażenie S pokazuje, że wynik operacji Agse może być argumentem kolejnej operacji Agse.

Wszystkie powyższe formy można łączyć w dowolnie złożone wyrażenia – jak F=((B#D)+A)>2 z przykładu powyżej – o ile schematy danych argumentów spełniają wymagania poszczególnych operacji.

Pokrycie przykładów w testach integracyjnych

Każda z przytoczonych form wyrażeń ma swój odpowiednik we wspólnym katalogu test/IntegrationTest repozytorium RetractorDB, wykonywany przy każdej kompilacji projektu:

Wyrażenie z rozdziałuForma w teścieTest integracyjny
C=A+B (suma)s1+s2, core0+core1IntegrationTest/issue167_dedup_positive, IntegrationTest/Data (all-operators)
E=B#D (przeplot)core0#core1IntegrationTest/operations, IntegrationTest/Data (all-operators)
G=E#D (przeplot kaskadowy)(s1#s2)#s3, s1#s2#s3IntegrationTest/issue167_triarg
H=A+B+D (suma wieloargumentowa)s1+s2+s3, s1+s2+s3+s4IntegrationTest/issue167_triarg
I=D+((A+B)>1)s3+((s1+s2)>1)IntegrationTest/issue167_dedup_cascaded
J=(B>1)#D oraz (B#D)>1(core1>1)#core2, (core1#core2)>1IntegrationTest/subquery
K=E&1, L=E%½ (rozplątanie)core0&1.5, core0%4IntegrationTest/Data (all-operators)
M=C−1 (różnica)core0-1/2IntegrationTest/Data (all-operators)
przesunięcie sumy, jak w F(s1+s2)>1, (core0+core1)>5IntegrationTest/issue167_dedup_field_names, IntegrationTest/issue56_timeshift
N=A@(1,4), P=A@(1,−4), R=A@(2,2)core1@(1,4), core1@(1,-4), core1@(2,2)IntegrationTest/agse1 (dalsze warianty skoku i szerokości: agse2, agse3)
S=(A@(2,2))@(1,1) (Agse kaskadowe)signalText3@(1,1)IntegrationTest/agse1

Testy porównują wyniki wykonania zapytań z plikami wzorcowymi (pattern), więc powyższe wyrażenia są weryfikowane nie tylko składniowo, ale i co do wartości oraz szybkości strumieni wynikowych.

Implementacja modelu

Opracowane równania algebry zaimplementowano pierwotnie w języku Python. Jest to znany mi najbardziej efektywny sposób modelowania i numerycznego weryfikowania hipotez. Każdy z operatorów został zaimplementowany wewnątrz osobnej funkcji. Operacje realizowane na zmiennych wymiernych (biblioteka Fraction). Wyniki prezentowane są w postaci ograniczonych tablic. Operatory te jednak w końcowej implementacji realizują operacje na nieskończonych strukturach danych.

Operacja przeplotu

Na początku zbudujmy operację przeplotu:

Kod źródłowy

# Operacja splątania (hash) dwóch list z określonymi krokami (delta).
from fractions import Fraction
from math import floor, ceil

A = range(1, 24)
deltaA = Fraction(1, 2)
B = list(map(chr, range(ord('a'), ord('z')+1)))
deltaB = Fraction(1, 2)

def hash(A: list, deltaA: Fraction, B: list, deltaB: Fraction):
  result = []
  delta = deltaB / (deltaA + deltaB)
  for i in range(0, 20):
      if floor(i*delta) == floor((i+1)*delta):
          result.append(B[i-int(floor((i+1)*delta))])
      else:
          result.append(A[int(floor(i*delta))])
  deltaC = (deltaA*deltaB)/(deltaA+deltaB)
  return result, deltaC
  
def main():
    print("A:", A[0:10], " deltaA:", deltaA)
    print("B:", B[0:10], " deltaB:", deltaB)
    hash_result1, delta_hash1 = hash(A, deltaA, B, deltaB)
    hash_result2, delta_hash2 = hash(B, deltaB, A, deltaA)
    print("Hash(A,B):", hash_result1[0:10], " deltaHash:", delta_hash1)
    print("Hash(B,A):", hash_result2[0:10], " deltaHash:", delta_hash2)

if __name__ == '__main__':
    main()

Efekt uruchomienia

$ python hash.py
A: range(1, 11) deltaA: 1/2
B: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'] deltaB: 1/2
Hash(A,B): ['a', 1, 'b', 2, 'c', 3, 'd', 4, 'e', 5] deltaHash: 1/4
Hash(B,A): [1, 'a', 2, 'b', 3, 'c', 4, 'd', 5, 'e'] deltaHash: 1/4

Kod po uruchomieniu przedstawi dane wejściowe A oraz B - oraz wyniki operacji A#B oraz B#A. Jak widać operacja przeplotu nie jest przemienna.

Operacja rozplątania

Operacja rozplątania wymaga zaimplementowania dwóch komplementarnych operacji.

Kod źródłowy - even

# Operacja rozplątania (dehash) even.
from fractions import Fraction
from math import floor, ceil

A = range(1, 24)
deltaA = Fraction(1, 2)
B = list(map(chr, range(ord('a'), ord('z')+1)))
deltaB = Fraction(1, 2)

def hash(A: list, deltaA: Fraction, B: list, deltaB: Fraction):
  result = []
  delta = deltaB / (deltaA + deltaB)
  for i in range(0, 20):
      if floor(i*delta) == floor((i+1)*delta):
          result.append(B[i-int(floor((i+1)*delta))])
      else:
          result.append(A[int(floor(i*delta))])
  deltaC = (deltaA*deltaB)/(deltaA+deltaB)
  return result, deltaC

def dehasheven(C: list, deltaC: Fraction, deltaA: Fraction):

  result = []
  deltaB = deltaA*deltaC / (deltaA - deltaC)

  for i in range(0, 6):
      result.append(C[i+int(ceil((i+1)*deltaA/deltaB))])
  return result, deltaB

def main():
    hash_result, delta_hash = hash(B, deltaB, A, deltaA)
    print("Hash(A,B):", hash_result[0:10], " deltaHash:", delta_hash)
    mod_result, delta_mod = dehasheven(hash_result, delta_hash, deltaA)
    print("Mod(Hash):", mod_result[0:10], " deltaMod:", delta_mod)

if __name__ == '__main__':
    main()

wynik - even

$ python dehash_even.py
Hash(A,B): [1, 'a', 2, 'b', 3, 'c', 4, 'd', 5, 'e'] deltaHash: 1/4
Mod(Hash): ['a', 'b', 'c', 'd', 'e', 'f'] deltaMod: 1/2

Kod źródłowy - odd

# Operacja rozplątania (dehash) odd.
from fractions import Fraction
from math import floor, ceil

A = range(1, 24)
deltaA = Fraction(1, 2)
B = list(map(chr, range(ord('a'), ord('z')+1)))
deltaB = Fraction(1, 2)

def hash(A: list, deltaA: Fraction, B: list, deltaB: Fraction):
  result = []
  delta = deltaB / (deltaA + deltaB)
  for i in range(0, 20):
      if floor(i*delta) == floor((i+1)*delta):
          result.append(B[i-int(floor((i+1)*delta))])
      else:
          result.append(A[int(floor(i*delta))])
  deltaC = (deltaA*deltaB)/(deltaA+deltaB)
  return result, deltaC

def dehashodd(C: list, deltaC: Fraction, deltaB: Fraction):

  result = []
  deltaA = deltaB*deltaC / (deltaB - deltaC)

  for i in range(0, 6):
      result.append(C[i+int(i*deltaB/deltaA)])
  return result, deltaA

def main():
    hash_result, delta_hash = hash(B, deltaB, A, deltaA)
    print("Hash(A,B):", hash_result[0:10], " deltaHash:", delta_hash)
    div_result, delta_div = dehashodd(hash_result, delta_hash, deltaB)    
    print("Div(Hash):", div_result[0:10], " deltaDiv:", delta_div)

if __name__ == '__main__':
    main()

wynik - odd

$ python dehash_odd.py
Hash(A,B): [1, 'a', 2, 'b', 3, 'c', 4, 'd', 5, 'e']  deltaHash: 1/4
Div(Hash): [1, 2, 3, 4, 5, 6]  deltaDiv: 1/2

Tak zbudowany kod najpierw łączy dwa strumienie a następnie wyciąga dane źródłowe.

Operacja sumy

Sumowanie łączy dwa strumienie danych napływające z różną czestotliwością.

Kod źródłowy

# operacja sumowania dwóch list z określonymi krokami (delta).
from fractions import Fraction
from math import floor, ceil

A = range(1, 24)
deltaA = Fraction(1, 2)
B = list(map(chr, range(ord('a'), ord('z')+1)))
deltaB = Fraction(1)

def sum(A: list, deltaA: Fraction, B: list, deltaB: Fraction):
  result = []
  deltaC = min(deltaA, deltaB)
  for i in range(0, 20):
      if deltaC == deltaA:
          result.append(str(A[i])+B[int(i*deltaA/deltaB)]),
      else:
          result.append(str(A[int(i*deltaB/deltaA)])+B[i]),
  return result, deltaC

def main():
    print("A:", A[0:10], " deltaA:", deltaA)
    print("B:", B[0:10], " deltaB:", deltaB)
    sum_result, delta_sum = sum(A, deltaA, B, deltaB)
    print("Sum:", sum_result[0:10], " deltaSum:", delta_sum)

if __name__ == '__main__':
    main()

wynik

$  python sum.py
A: range(1, 11)  deltaA: 1/2
B: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']  deltaB: 1
Sum: ['1a', '2a', '3b', '4b', '5c', '6c', '7d', '8d', '9e', '10e']  deltaSum: 1/2

Operacja różnicy

Komplementarną operacją dla sumy jest operacja różnicy.

Kod źródłowy

# Operacja różnicy (diff) dwóch list z określonymi krokami (delta).
from fractions import Fraction
from math import floor, ceil

A = range(1, 24)
deltaA = Fraction(1, 2)
B = list(map(chr, range(ord('a'), ord('z')+1)))
deltaB = Fraction(1)

def sum(A: list, deltaA: Fraction, B: list, deltaB: Fraction):
  result = []
  deltaC = min(deltaA, deltaB)
  for i in range(0, 20):
      if deltaC == deltaA:
          result.append(str(A[i])+B[int(i*deltaA/deltaB)]),
      else:
          result.append(str(A[int(i*deltaB/deltaA)])+B[i]),
  return result, deltaC

def diff(C: list, deltaA: Fraction, deltaB: Fraction):
  result = []
  deltaC = min(deltaA, deltaB)
  for i in range(0, 10):
      if deltaA > deltaB:
          result.append(C[int(ceil(i*deltaA/deltaB))])
      else:
          result.append(C[i])
  return result, deltaC

def main():
    sum_result, delta_sum = sum(A, deltaA, B, deltaB)
    diff_result, delta_diff = diff(sum_result, deltaA, deltaB)
    print("Sum:", sum_result[0:10], " deltaSum:", delta_sum)
    print("Diff(Sum):", diff_result[0:10], " deltaDiff:", delta_diff)

if __name__ == '__main__':
    main()

wynik

$ python diff.py
Sum: ['1a', '2a', '3b', '4b', '5c', '6c', '7d', '8d', '9e', '10e']  deltaSum: 1/2
Diff(Sum): ['1a', '2a', '3b', '4b', '5c', '6c', '7d', '8d', '9e', '10e']  deltaDiff: 1/2

Kody źródłowe

Kody źródłowe przedstawionych przykładów zjadują się w repozytorium projektu w katalogu /examples/python-model/

Implementacja w języku javascript możliwa do przetestowania bezpośrednio na stonie:

https://retractordb.com/assets/interlace.html

https://retractordb.com/assets/sum.html

Reprezentacja graficzna

Na Rys. 2 przedstawiono schematycznie zależności pomiędzy opracowanymi operatorami algebry serii czasowych. Na rysunku połączyłem zależności opracowanych operatorów, ich symboliczne oznaczenia stosowane w języku zapytań oraz kierunki przetwarzania danych.

Przedstawiony rysunek stanowi też graficzne podsumowanie treści zaprezentowanych w tym rozdziale. Przedstawiony graficzny sposób reprezentacji mam nadzieję ułatwi przyswojenie zasad panujących pomiędzy wprowadzonymi operatorami. Dla czytelności pominięte zostały operatory agregacji i serializacji oraz przesunięcia czasowego. Należy mieć świadomość że do pełnego obrazu brakuje ich na tym schemacie.

Rys. 2. Zależności pomiędzy operatorami algebry

Podsumowanie

Równania te z początku modelowałem w postaci programów w języku Python. Przedstawioną formalną formę przyjęły na sam koniec procesu poszukiwań. Dowodząc numerycznie poprawności opracowanych równań konstruowałem sekwencje operacji na strumieniach. Jeśli jakieś elementy gubiły się w trakcie realizacji przedstawionych operacji – oznaczało to że popełniłem błąd. Okazuje się np. ze istotne jest w implementacji aby nie opuszczać nawet na chwilę dziedziny liczb wymiernych. Błąd można popełnić przypadkiem, niejawnie rzutując wynik na liczbę zmiennoprzecinkową. Materializację wyniku w formie zmiennoprzecinkowej należy w obliczeniach odłożyć do momentu jawnego przeniesienia wyniku operacją podłogi lub sufitu. Jeśli program w Pytonie złożymy w sekwencję operacji na nieskończonych strumieniach i żadne dane w wyniku tej operacji nie znikną – mamy obiekt do dalszych badań i analizy formalnej, gotowy do formalnego dowodu matematycznego poprawności. Formalny dowód (formalizm matematyczny) znajdziemy w pracy pt. Deterministyczna metoda przetwarzania ciagow danych [3].

Dział matematyki który zawiera prace badawcze związane z tymi równaniami nosi nazwę systemów pokrywających [4] w obszarze teorii liczb.

ℹ Info

Przedstawienie podstaw matematycznych systemu jest konieczne w celu zrozumienia dalszych technicznych aspektów rozwiązania. Przedstawione metody wybiegają poza standardowy materiał prezentowany obecnie na studiach z zakresu nauk technicznych. Wynika to z faktu, że podstawy matematyczne wydobyłem z obszaru dotychczas niemającego zastosowań w znanej mi technice. Są to metody umożliwiające zbudowanie nowego sposobu przetwarzania danych. Na tym polega jeden z aspektów różniących RetractorDB od reszty podobnych rozwiązań.

Konstrukcja języka zapytań

Komunikacja pomiędzy opracowanym systemem a użytkownikiem odbywa się za pomocą opracowanego, deklaratywnego języka zapytań. Konstrukcja języka oparta jest na przedstawionej w poprzednim rozdziale algebrze. Podobnie jak w przypadku systemów relacyjnych, gdzie algebra relacji tworzy podstawę dla języka SQL – w moim przypadku opracowana algebra tworzy podstawę dla języka zapytań RQL.

RQL to skrót od RetractorDB Query Language. Jego składnia jest bardzo podobna do składni języka SQL. Należy mieć jednak na uwadze, że właściwym określeniem w tym przypadku jest ang. False Friend. Czyli wygląda to jak SQL, ale nie ma z nim zbyt wiele wspólnego.

Poprawne zdania w języku RQL na chwilę obecną zaczynają się od kilku słów kluczowych. Najbardziej rozpoznawalne to polecenie zaczynające się od słowa kluczowego SELECT za którym występuje lista atrybutów w postaci wyrażeń algebraicznych. Algebry opartej na liczbach rzeczywistych.

Polecenia zapisuje się w pliku tekstowym. Jego rozszerzenie to zwyczajowo .rql ale dowolne inne też zostanie przyjęte i przetworzone. Plik tekstowy języka RQL zawiera ciąg poleceń zaczynających się od zdefiniowanych słów kluczowych.

Znak # rozpoczyna komentarz tylko wtedy, gdy jest pierwszym niebiałym znakiem wiersza. Cały taki wiersz, także wcięty, jest pomijany przed parsowaniem. W wyrażeniu FROM znak # zawsze oznacza przeplot, niezależnie od spacji. Komentarz na końcu wiersza rozpoczyna się od //; dostępne są również komentarze blokowe /* ... */.

Język zapytań został zaimplementowany przy pomocy generatora parserów Antlr4 [5]. Gramatyka języka RQL została zapisana, zdefiniowana i po każdej modyfikacji jest kompilowana do języka w którym stworzono system RetractorDB. Każde zdanie pliku zbioru zapytań nie będące komentarzem jest kompilowane, przetwarzane i modyfikuje wewnętrzny stan systemu. Zdanie może zajmować wiele wierszy — kontynuację wiersza sygnalizuje znak \ na jego końcu.

Polecenie DECLARE

Polecenie DECLARE służy do zadeklarowania źródła danych.

Jego składnia opisana jest następująco:

DECLARE pole typ[N] [, pole typ[N]]
STREAM nazwa, szybkość
FILE źródło
[DISPOSABLE]
[ONESHOT]
[HOLD]

Rys. 3. Diagram składni polecenia DECLARE

Diagram składni (railroad) przedstawiony na Rys. 3 został wygenerowany na podstawie reguły declare_statement z gramatyki ANTLR4 systemu (RQL.g4). Diagram czyta się, podążając liniami od lewej do prawej: zaokrąglone zielone pola to słowa kluczowe i symbole wpisywane dosłownie, prostokąty to wartości podawane przez użytkownika. Pętla powracająca przez przecinek oznacza, że deklaracji pól może być wiele; rozgałęzienie przy szybkości pokazuje, że można ją zapisać ułamkiem (licznik/mianownik) lub pojedynczą liczbą; tory omijające DISPOSABLE, ONESHOT i HOLD oznaczają, że każda z tych dyrektyw jest opcjonalna.

Typy pól

Każde pole ma nazwę i typ. Dostępne typy:

TypRozmiarOpis
BYTE1 Bliczba całkowita bez znaku 8-bit
INTEGER4 Bliczba całkowita ze znakiem 32-bit
UINT4 Bliczba całkowita bez znaku 32-bit
FLOAT4 Bliczba zmiennoprzecinkowa 32-bit
DOUBLE8 Bliczba zmiennoprzecinkowa 64-bit
STRINGN Bciąg bajtów o stałej długości N

Tablice pól (typ[N])

Do każdego pola można dodać mnożnik tablicowy [N] — pole zajmuje N × rozmiar_typu bajtów i tworzy N kolejnych pozycji w schemacie rekordu:

DECLARE coef INTEGER[25] \
STREAM filter, 1 \
FILE 'coefficients.txt'

Pole coef INTEGER[25] tworzy rekord o rozmiarze 25 × 4 = 100 bajtów i daje dostęp do indeksów filter[0]filter[24]. Jest to standardowy sposób przekazywania tablic współczynników (np. filtry FIR) do systemu.

Wiele pól różnych typów można łączyć w jednym rekordzie:

DECLARE id UINT, wartosc FLOAT, nazwa STRING[16] \
STREAM pomiar, 0.1 \
FILE 'czujnik.dat'

Rozmiar rekordu: 4 + 4 + 16 = 24 bajty.

System RetractorDB działając pod kontrolą systemu Linux pobiera i zapisuje dane do plików. W systemie Linux dostęp do większości zasobów jest realizowany za pomocą dostępu do różnego rodzaju plików. Takie rozwiązanie ujednolica sposób dostępu do danych.

Przykładem polecenia tworzącego w systemie RetractorDB obiekt zwracający wartości przypadkowe ze strumienia /dev/random 10 razy na sekundę o wartościach typu int wygląda następująco

DECLARE pole_przypadkowe INTEGER \
STREAM random_stream, 0.1 \
FILE ‘/dev/random’

Wspominane w poleceniu źródło, jeśli zostanie zadeklarowane jako plik tekstowy z rozszerzeniem .txt zostanie zinterpretowane przez system jako ciągły i nieskończony plik danych czytany wiersz po wierszu. Po napotkaniu końca pliku, odczyt danych zaczyna się od początku. Ta funkcjonalność została wbudowana w system RetractorDB. Zapewnione jest podstawowe wsparcie dla formatu – jeśli podamy dwa pola całkowite w deklaracji a w pliku po spacji podamy dwie wartości całkowite – wartości te trafią jako kolejne elementy czytanego rekordu.

DECLARE pole_1 INTEGER \
STREAM cykliczny_stream, 0.1 \
FILE ‘plik.txt’

Aby parsowanie pliku nastąpiło automatycznie, plik musi nosić rozszerzenie .txt. Na chwilę ta funkcjonalność została zaimplementowana na stałe i nie podlega parametryzacji. Planuję to zmienić w przyszłości.

NOTE: Opisana funkcjonalność ma pokrycie w teście: Pattern7 opisanym w załączniku pt. Testy Integracyjne.

Jeśli plik danych wejściowych będzie nosić rozszerzenie .dat – plik ten zostanie potraktowany jako plik binarny a odczyt danych z niego zostanie również zapętlony. Zapętlenie polega na tym że po przeczytaniu ostatniej wartości z pliku źródłowego, pozycja odczytu pliku kierowana jest na początek. Dane z takiego pliku czytane są w nieskończonej pętli, po zakończeniu wracając do początku.

Trzy opcjonalne dyrektywy (ONESHOT, DISPOSABLE, HOLD) sterują cyklem życia źródła danych — szczegółowy opis i tabela porównawcza znajdują się w rozdziale Opcje odczytu.

ℹ Info

Obsługa wartości NULL (per-pole) jest zaimplementowana w systemie RetractorDB. Metadane null przechowywane są w pliku .meta obok danych binarnych, zarządzanym przez klasę metaData.

Opcje odczytu w DECLARE

Polecenie DECLARE przyjmuje trzy opcjonalne dyrektywy wpływające na sposób odczytu i cykl życia zadeklarowanego źródła:

DECLARE pole typ STREAM nazwa, szybkość FILE źródło
    [DISPOSABLE]
    [ONESHOT]
    [HOLD]

ONESHOT

Bez ONESHOT źródło danych czytane jest w nieskończonej pętli — po osiągnięciu końca pliku pozycja odczytu wraca na początek. ONESHOT wyłącza pętlę: plik czytany jest dokładnie raz, a po jego wyczerpaniu strumień zwraca rekordy ze wszystkimi polami NULL. Bajty takiego rekordu są wyzerowane, ale znaczniki null odróżniają brak danych od wartości zero.

DECLARE pomiar INTEGER STREAM burst, 0.1 FILE 'dane.dat' ONESHOT

Zastosowanie: jednorazowe załadowanie danych historycznych do systemu.

DISPOSABLE

Po zakończeniu przesyłania danych ze źródła system usuwa plik danych, plik deskryptora (.desc) i plik metadanych (.meta). Dyrektywa działa przy destrukcji obiektu storage.

DECLARE temp INTEGER STREAM jednorazowy, 0.1 FILE 'temp.dat' DISPOSABLE ONESHOT

DISPOSABLE używa się razem z ONESHOT — dane wczytane raz, po wczytaniu usunięte. Kombinacja przydatna do tymczasowych plików danych wejściowych.

HOLD

Zadeklarowane źródło nie inicjuje odczytu od razu po starcie systemu. Fizyczny odczyt danych uruchamia się dopiero przy pierwszym zapytaniu wymagającym danych z tego strumienia (np. zapytanie Ad Hoc). Dopóki strumień nie zostanie odpytany — w systemie widoczne są wartości zerowe lub puste.

DECLARE rzadkie INTEGER STREAM opcjonalny, 1.0 FILE 'rzadkie.dat' HOLD

Zastosowanie: źródła danych aktywowane warunkowo, np. na żądanie użytkownika przez xqry.

Tabela porównawcza

DyrektywaPętla odczytuUsuwa pliki po odczycieOpóźniony start odczytu
(domyślnie)taknienie
ONESHOTnienienie
DISPOSABLEtaktaknie
HOLDtaknietak

Polecenie SELECT

Każde polecenie SELECT w systemie RetractorDB tworzy ciągłe zapytania. Zapytania te realizowane są od momentu pojawienia się w systemie aż do zakończenia pracy systemu.

Składnia polecenia SELECT przedstawia się następująco:

SELECT wyrażenie_algebraiczne [, wyrażenie_algebraiczne] 
STREAM nazwa_budowanego_strumienia [liczba_instancji]
FROM strumieniowe_wyrażnie_algebraiczne 
[FILE 'nazwa_pliku_artefaktu'] 
[RETENTION pojemność [segmenty]]
[VOLATILE | PERSISTENT]
[STORAGE profile]

Rys. 4. Diagram składni polecenia SELECT

Diagram składni (railroad) przedstawiony na Rys. 4 został wygenerowany na podstawie reguły select_statement z gramatyki ANTLR4 systemu (RQL.g4). Diagram czyta się, podążając liniami od lewej do prawej: zaokrąglone zielone pola to słowa kluczowe i symbole wpisywane dosłownie, prostokąty to wartości podawane przez użytkownika. Rozgałęzienie za słowem SELECT pokazuje, że lista pól to albo gwiazdka (pełny rekord), albo jedno lub więcej wyrażeń rozdzielonych przecinkami (pętla powracająca przez przecinek). Opcjonalny rozmiar w nawiasach kwadratowych po nazwie strumienia tworzy rodzinę strumieni. Tory omijające klauzule FILE, RETENTION (z opcjonalnym drugim parametrem — liczbą segmentów), VOLATILE/PERSISTENT i STORAGE oznaczają, że każda z nich jest opcjonalna.

Osoby posługujące się językiem SQL zauważą od razu że przedstawione powyżej polecenie odbiega znacząco od tego co znają z zakresu relacyjnych baz danych.

Pierwsza różnica poza składnią to fakt że polecenia te wprowadzone do systemu realizują się aż do zakończenia pracy systemu. Każde polecenie SELECT jest zapytaniem ciągłym. Klauzula STREAM wymaga nadania przez twórcę każdemu zapytaniu unikalnej nazwy. O ile wyrażenia algebraiczne na liście klauzuli SELECT nie odbiegają od formy znanej z systemów relacyjnych o tyle strumieniowe wyrażenie algebraiczne musi spełniać warunki przedstawione w poprzednim rozdziale dotyczącym wyrażeń algebraicznych. Opcjonalne klauzule FILE oraz RETENTION zapewniają procesy kierowania wyników i zarządzania formą ich retencji. Stare, podzielone pliki wynikowe mogą być usuwane na bieżąco zapewniając systemowi miejsce na nowe dane w ruchu ciągłym.

Przykładem zapytania tworzącego nowy strumień danych może być następujące polecenie w języku RQL.

SELECT str1[0]*10 + str1[1]*10, str1[2] \
STREAM str1 \
FROM A+B

Tak zbudowane zapytanie zakłada że ktoś zadeklarował strumienie A i B. Operację tą mógł wykonać za pomocą słowa kluczowego DECLARE lub innego polecenia SELECT. W oparciu tylko o wiersz zawierający zapytanie nie jesteśmy w stanie stwierdzić jak szybko dane strumienia str1 napływają. Ta informacja jest wyliczana na etapie kompilacji w oparciu o strumienie A i B i wyrażenie algebraiczne w klauzuli FROM.

Generatory strumieni

Opcjonalny rozmiar po nazwie w klauzuli STREAM rozwija jeden szablon na podaną liczbę zapytań. Symbol $ oznacza numer instancji liczony od zera:

DECLARE cell INTEGER[4] STREAM cells, 1/10 FILE 'cells.txt'

SELECT cells[$] STREAM cell[4] FROM cells
SELECT * STREAM grouped FROM cell[0]#cell[1]#cell[2]#cell[3]

Pierwsze polecenie SELECT tworzy fizyczne strumienie cell$0, cell$1, cell$2 i cell$3. Odwołanie cell[2] w klauzuli FROM oznacza instancję cell$2; w wyrażeniu listy SELECT zapis cells[2] nadal oznacza pole o indeksie 2.

W szablonie $ może wystąpić:

  • jako indeks pola, np. cells[$] albo cells[3-$];
  • jako wartość wyrażenia, np. cells[0]+$;
  • w odwołaniu do innej rodziny w klauzuli FROM, np. cell[$]@(2,4).

Wyrażenie indeksu generatora jest całkowite i może zawierać literały, $, nawiasy oraz operatory *, + i -. Rozmiar rodziny musi być dodatni, a szablon musi rzeczywiście używać $. Generator nie może mieć klauzuli FILE, ponieważ jedna nazwa pliku nie może opisywać wielu strumieni. Kompilator odrzuca także indeksy poza zakresem rodziny, ujemne indeksy pól, indeksy pól poza zakresem slotów źródła oraz kolizje nazw wygenerowanych z istniejącymi strumieniami. Zakres indeksu pola sprawdza ta sama kontrola co dla zapisu ręcznego — patrz Indeks poza zakresem.

Ekspansja jest pierwszym przebiegiem kompilatora. Po niej plan jest taki sam jak plan z ręcznie rozpisanymi strumieniami cell$0cell$3; runtime nie ma osobnego mechanizmu generatorów.

Generator może obejmować kolejne stopnie tego samego potoku. Pozwala to opisać obliczenie raz i zastosować je niezależnie do każdego kanału wejściowego:

DECLARE sample INTEGER[8] STREAM samples, 1/1000 FILE 'samples.txt'

SELECT sample[$]^2 STREAM square[8] FROM samples
SELECT * STREAM energy[8] FROM SUMC(square[$]@(25,100))

Powstaje osiem par strumieni square$N i energy$N, po jednej na kanał. Symbol $ wybiera numer instancji rodziny podczas rozwijania szablonu. Nie należy mylić go z [_], które powiela wyrażenie pola wewnątrz jednego zapytania według płaskiego schematu wejścia.

NOTE: Składnię generatora, w tym użycie [$], sprawdzają test integracyjny stream_generator oraz przypadki ut_compiler w test/UnitTest/test_compiler.cpp. Agregaty okien rekordów sprawdza test window_aggregate. Testy integracyjne opisano w załączniku Testy integracyjne.

Klauzula VOLATILE - tworzy ulotną formę zapytania. Dane pozostają w buforze pamięciowym, którego pojemność kompilator dobiera do potrzeb planu; na dysku pojawia się tylko deskryptor opisujący strukturę danych.

Klauzula STORAGE umożliwia wybór sposobu tworzenia i zarządzania tworzonymi artefaktami. Pełna tabela typów z opisem każdego z nich znajduje się w rozdziale Typy STORAGE.

Operatory klauzuli FROM

Strumieniowe wyrażenie algebraiczne w klauzuli FROM może zawierać:

OperatorSkładniaOpis
SumaA + BKonkatenacja schematów dwóch strumieni — patrz Sekwencjonowanie sumowania
PrzeplotA # BPrzeplot dwóch strumieni — patrz Sekwencjonowanie przeplotu
PrzesunięcieA > NPrzesuwa odczyt o N próbek
Zmiana interwałuA - rPrzetaktowuje strumień do interwału wymiernego r
RozplotA & r / A % rOdzyskuje lewą albo prawą składową przeplotu dla stosunku r
Okno AGSEA @ (k, w)Buduje ruchome okno danych — patrz Ruchome okno danych AGSE
RedukcjaMIN(A) / MAX(A) / AVG(A) / SUMC(A)Redukuje wielopolowy rekord do jednej wartości — patrz Operatory agregujące

Agregaty MIN/MAX/AVG/SUMC(wyrażenie : W) występują w liście SELECT, a nie w wyrażeniu strumieniowym FROM. Redukują historię W rekordów i mogą być operandem większego wyrażenia pola, na przykład 2*MIN(a : 5)+1. Obie osie agregacji porównuje rozdział Operatory agregujące.

Priorytet i łączność

Od operatorów wiążących najmocniej do najsłabiej:

  1. wywołanie reduktora, nazwa strumienia albo wyrażenie w nawiasach;
  2. łańcuchowalne operatory przyrostkowe @, &, %, >, - i wygaszana postać .agregator;
  3. przeplot #;
  4. suma +.

Operatory binarne # i + są lewostronnie łączne. Operatory przyrostkowe również składają się od lewej, np. A@(1,4)&2 oznacza (A@(1,4))&2.

⚠️ Ostrzeżenie A#B>N oznacza A#(B>N), ponieważ przesunięcie wiąże mocniej niż przeplot. Aby przesunąć wynik przeplotu, zapisz (A#B)>N. Ta sama reguła dotyczy A#B-r.

Spacje wokół # nie zmieniają znaczenia: A # B i A#B są tym samym przeplotem.

Wyrażenia pól

Lista SELECT oraz warunki RULE używają wyrażeń skalarnych z odwołaniami do pól, operatorami arytmetycznymi, wartościami NULL i funkcjami. Pełną składnię, priorytety, listę funkcji oraz reguły konwersji opisuje rozdział Wyrażenia pól i funkcje skalarne.

Potęgowanie

Operator ^ potęguje wartości liczbowe na liście SELECT i w warunkach RULE. Wiąże mocniej niż * i /, a te wiążą mocniej niż + i -. Potęgowanie jest prawostronnie łączne:

SELECT v*w^2, v^w^2 STREAM powers FROM source

Powyższy zapis oznacza v*(w^2) oraz v^(w^2). Zapis (v*w)^2 wymaga jawnych nawiasów.

Dla typów całkowitych i wymiernych nieujemna potęga całkowita ma dokładnie semantykę powtarzanego mnożenia, łącznie z promocją typu i przepełnieniem. Dla pozostałych przypadków używane jest obliczenie zmiennoprzecinkowe; wynik nieskończony albo NaN staje się NULL. Operandy tekstowe są niedozwolone.

ℹ Info Literał ujemny jest jednym atomem gramatyki: -2^2 oznacza (-2)^2. Dla pola -v^2 oznacza -(v^2). W razie wątpliwości użyj nawiasów.

⚠️ Ostrzeżenie Po przeplocie A#B nie wolno odwoływać się do jego składowych przez A[0], A.pole, A[_] ani A.*. Przeplot ma jeden wspólny schemat; użyj nazwy strumienia wynikowego albo odzyskaj składową operatorem &/%. Szczegóły opisuje rozdział Aliasowanie.

NOTE: Operator przesunięcia A > N ma pokrycie w teście: issue56_timeshift opisanym w załączniku pt. Testy Integracyjne.

NOTE: Propagacja wartości null przez wyrażenia SELECT ma pokrycie w teście: issue121_null_propagation opisanym w załączniku pt. Testy Integracyjne.

Domyślną ulotność całego planu ustawia DEFAULT VOLATILE. Klauzula PERSISTENT wyłącza ją dla konkretnego wyniku. Szczegóły: VOLATILE i PERSISTENT.

Wyrażenia pól i funkcje skalarne

Wyrażenie pola oblicza jedną wartość rekordu wynikowego. Występuje na liście SELECT, w warunku RULE oraz jako argument agregatu okna rekordowego. Nie należy go mylić z wyrażeniem strumieniowym klauzuli FROM, które buduje i taktuje cały strumień.

Budowanie wyrażenia

Operandami są literały liczbowe i tekstowe, symbol $ wewnątrz generatora, odwołania do pól oraz wyniki funkcji. Pole można wskazać nazwą, kwalifikowaną nazwą strumienia albo płaskim indeksem, na przykład temperature, src.temperature i src[0]. Dla liczbowej deklaracji a T[N] goła nazwa a oznacza cały wpis tablicowy i nie jest operandem skalarnym; należy podać a[0]a[N-1]. STRING[N] jest jednym polem tekstowym.

Podstawowe operatory arytmetyczne to +, -, *, / i ^. Nawiasy zmieniają grupowanie. Operatory * i / wiążą mocniej niż + i -, a potęgowanie ^ wiąże najmocniej i jest prawostronnie łączne:

SELECT v*w^2, v^w^2, (v*w)^2 STREAM powers FROM source

Powyższe pola znaczą odpowiednio v*(w^2), v^(w^2) i (v*w)^2. Literał ujemny jest jednym atomem gramatyki: -2^2 oznacza (-2)^2, natomiast -v^2 oznacza -(v^2).

Dla typów całkowitych i wymiernych nieujemna potęga całkowita ma semantykę powtarzanego mnożenia, łącznie z promocją typu i przepełnieniem. Pozostałe przypadki korzystają z obliczenia zmiennoprzecinkowego; wynik nieskończony albo NaN staje się NULL. Operandy tekstowe są niedozwolone.

Wartość NULL jest propagowana przez zwykłą arytmetykę. Dzielenie przez zero daje NULL dla każdego typu liczbowego i nie zatrzymuje dalszego przetwarzania strumienia. Reguły porównań i trójwartościowej logiki warunku RULE opisuje rozdział Warunek logiczny.

⚠️ Ostrzeżenie Po przeplocie A#B nie wolno odwoływać się do jego składowych przez A[0], A.pole, A[_] ani A.*. Przeplot ma jeden wspólny schemat; należy użyć nazwy strumienia wynikowego albo odzyskać składową operatorem & lub %. Szczegóły opisuje rozdział Aliasowanie.

Dostępne funkcje skalarne

Jedyną listą nazw i arności wspólną dla kompilatora i ewaluatora jest tabela rqlFunctions.hpp. Nazwy są dopasowywane bez względu na wielkość liter, a w planie zapisywana jest postać kanoniczna. Nieznana funkcja albo błędna arność zatrzymuje kompilację; błąd nie jest odkładany do wykonania.

GrupaFunkcje
MatematyczneSqrt, Ceil, Floor, Abs, round, trunc, sin, cos, exp, tan, log, log2
Obsługa wartościisnull, null2zero, IsZero, IsNonZero, Length
Konwersjeto_integer, to_float, to_double, to_string

Wszystkie funkcje przyjmują jeden argument wyrażeniowy. Jedynym wyjątkiem jest opcjonalna szerokość pola wynikowego w to_string(wyrażenie : szerokość).

Predykaty i wartości brakujące

  • isnull(x) zwraca 1 dla NULL i 0 dla wartości obecnej;
  • null2zero(x) zamienia NULL na całkowite zero, ale wartość obecną przepuszcza bez zmiany jej typu;
  • IsZero(x) i IsNonZero(x) zwracają całkowite 1 albo 0 dla argumentu liczbowego.

null2zero jest konwersją stratną: po jej wykonaniu nie da się odróżnić pierwotnego braku od rzeczywistego zera. Nie zastępuje bitmapy NULL przechowywanej w pliku .meta.

Długość napisu

Length(x) działa wyłącznie na napisie. Liczy długość rzeczywistej wartości do pierwszego bajtu zerowego, a nie zadeklarowaną szerokość STRING[N]. Dla pola STRING[8] zawierającego alpha wynikiem jest 5. Argument liczbowy jest błędem wykonania.

Konwersje

to_integer, to_float i to_double konwertują wartość liczbową albo tekstową do wskazanego typu. NULL przechodzi bez zmiany. to_integer obcina część ułamkową w stronę zera, nie podłoguje: to_integer(-8/3) daje -2.

to_string tworzy pole tekstowe. Bez drugiego członu jego szerokość wynosi 32 bajty; postać to_string(x : N) deklaruje N bajtów. Separatorem jest dwukropek, ponieważ przecinek rozdziela pola listy SELECT:

SELECT to_string(value : 10), Length(label), null2zero(optional) \
STREAM converted FROM source

Szerokość wyniku tekstowego jest ustalana po rozwiązaniu referencji do pól, dlatego czyste przepisanie STRING[N] i konkatenacja tekstowa zachowują poprawny deskryptor. Typ całego wyrażenia, także liczbowego, wyznacza compiler::inferFieldShapes() po rozwiązaniu odwołań do pól; reguły opisuje rozdział Równanie typów w górę.

Zadeklarowana szerokość jest własnością pola, a nie skutkiem konkretnego przebiegu kompilacji. Obowiązuje także wtedy, gdy cały argument jest stały: to_string(42 : 16) daje STRING[16], a nie STRING[2]. Upraszczanie wyrażeń zwija argument pod wywołaniem, ale samego to_string nie usuwa — inaczej deklaracja znikałaby razem z programem przy ponownej kompilacji planu, czyli po zapytaniu ad hoc (xqry -a), które kompiluje żywy plan drugi raz.

sin, cos i exp

Wszystkie trzy funkcje przyjmują argument liczbowy i zwracają DOUBLE, niezależnie od typu argumentu. sin i cos interpretują kąt w radianach. Na przykład dla pola k typu INTEGER wyrażenia sin(k), cos(k) i exp(k) dają pola typu DOUBLE; wynik nie traci części ułamkowej. NULL na wejściu daje NULL, a wynik niefinitywny (np. exp(1000)) również daje NULL bez zatrzymania strumienia.

Wyjątkiem jest argument typu RATIONAL: kompilator go odrzuca i wymaga jawnego to_double — tak samo jak dla Sqrt, patrz rozdział niżej.

Zmiana typu sin i cos względem starszego silnika może zmienić deskryptor .desc i układ rekordu: INTEGER oraz FLOAT zajmują 4 bajty, a DOUBLE 8 bajtów. Istniejący artefakt o starym schemacie wymaga ponownego utworzenia albo osobnego strumienia wynikowego.

Funkcje niewymierne nad wartością RATIONAL

Sqrt, sin, cos, exp, tan, log i log2 nie przyjmują argumentu typu RATIONAL — kompilator odrzuca taki zapis kanałem Check result: i podaje obejście. Dotyczy to w praktyce reduktorów strumieniowych nad polami BYTE, INTEGER, UINT i RATIONAL, bo ich wynik ma typ RATIONAL. Reduktory nad FLOAT i DOUBLE zachowują typ wejścia:

SELECT * STREAM m FROM AVG(src)
SELECT Sqrt(m[0]) STREAM o FROM m              // odrzucone przy kompilacji
SELECT Sqrt(to_double(m[0])) STREAM o FROM m   // poprawnie

Obowiązuje jedna reguła: funkcja o niewymiernej przeciwdziedzinie nad wartością wymierną wymaga jawnego to_double. Ta sama reguła obejmuje warunek reguły (RULE ... WHEN), który kompilator sprawdza osobnym przebiegiem.

Dla Sqrt, tan, log i log2 powodem bramki jest powrót z obliczenia przez double do RATIONAL: wynik zostawał przybliżony ułamkiem o dużym mianowniku (dla argumentu wymiernego 2/1 pierwiastek dawał 19601/13860, a logarytm 2731/3940). W starszej wersji dwa kolejne mnożenia takiego przybliżenia mogły przepełnić 32-bitowy licznik lub mianownik bez sygnału; Sqrt(x)*Sqrt(x)*Sqrt(x) zwracało -4,247 zamiast +2,828. Obecnie arytmetyka wartości pól typu INTEGER i RATIONAL wykrywa przepełnienie i zapisuje NULL, ale nie znosi to wymogu jawnego to_double dla tych funkcji.

Dla sin, cos i exp powód jest inny: te trzy kończą na DOUBLE i nigdy nie wracają do RATIONAL, więc policzyłyby się poprawnie. Ich odrzucenie jest decyzją o kontrakcie języka, podjętą po to, żeby nie trzeba było pamiętać listy wyjątków — jedna reguła zamiast siedmiu osobnych zachowań. Ceną jest to_double w każdym zapytaniu liczącym np. RMS nad reduktorem.

Ograniczenie nie obejmuje pozostałych funkcji ani innych typów argumentu. Zaokrąglenia Floor, Ceil, round i trunc nad RATIONAL są bezpieczne, bo ich wynik jest całkowity, czyli ma mianownik 1, a Abs liczy się wprost na wartości i mianownika nie rusza w ogóle.

Bramka dotyczy wyłącznie pary z RATIONAL i nie zmienia typu wyniku żadnej funkcji. tan, log i log2 nad INTEGER nadal dają INTEGER, czyli obcinają część ułamkową — to strata jawna i zamierzona, nie przepełnienie. Ewentualne doprowadzenie ich do DOUBLE, tak jak sin, cos i exp, zmieniłoby typ pola w .desc, więc jest osobnym zadaniem.

NOTE: Funkcje i propagację typów sprawdzają testy integracyjne fncall_runtime_case, string_field_passthrough, issue121_isnull, issue128_numeric_to_string i issue128_string_to_numeric oraz testy jednostkowe ut_compiler, ut_expeval i ut_facctxtsrc.

Sekwencjonowanie operacji sumowania

Do systemu napływają i są przetwarzane w nim dane. Określenie kolejności ich napływu i przetwarzania możemy opisać terminem - sekwencjonowanie. Sposób w jakim zostaną dane połączone opisywany jest przez wyrażenie algebraiczne umieszczone w klauzuli FROM. Wyrażenia te zapisane są w formie szeregu operacji algebraicznych, podlegającym ścisłym regułom. Podobne reguły poznaliśmy w trakcie nauki w szkole podstawowej – były to reguły dotyczące operacji arytmetycznych w zbiorze liczb takich jak dodawanie, mnożenie dzielenie i odejmowanie.

Na początku przeanalizujmy następujące zapytanie:

DECLARE a BYTE STREAM A, 1 FILE 'data1.txt'
DECLARE a BYTE STREAM B, 2 FILE 'data2.txt'
SELECT * STREAM str1 FROM A+B

Zapytanie zapiszę w pliku qplan1.rql. Następnie wykonam następujące polecenia:

$ xretractor -c qplan1.rql -w 1:3 > out.txt
$ swirly out.txt -o out.svg

Program swirly zainstalowany został z repozytorium GitHub [6]. Program ten służy do generacji diagramów kulkowych stosowanych w wyjaśnianiu zachowania operacji asynchronicznych RxJs [7].

Modyfikację jaką zastosowałem w moim przypadku użycia to alternatywne znaczenie pionowych linii. W moim przypadku pionowe linia oddzielają jednolite interwały czasowe – prezentujące ilość cykli o które poprosiliśmy przy wywołaniu (w tym przypadku to 3 cykle). Wygenerowany obraz przedstawia Rys. 5:

Rys. 5 Schemat Kulkowy - Operacja sumy

W tym miejscu konieczne jest kilka słów wyjaśnienia dotyczące tego generatora oraz sposobu generacji wytycznych dla tego generatora. Wbudowałem w kompilator opcję wizualizacji realizacji sekwencji operacji. Diagramy tworzone przez program Swirly są jednym z wygodnych sposobów prezentacji zależności czasowych. Na wejściu program Swirly oczekuje pliku tekstowego z opisem diagramu. Generator symulujący wskazaną ilość cykli w argumencie i budujący plik dla Swirly został wbudowany w kompilator.

Program xretractor po podaniu jako pierwszy parametr nazwy pliku z planem realizacji zapytania wymaga drugiego parametru ( -w [–diagram] ) – co jest wskazaniem że oczekujemy na wyjściu opisu diagramu kulkowego. Wymaganym argumentem parametru -w są dwie liczby oddzielone dwukropkiem. Pierwsza informuje czy program ma wstawić separatory czasowe na diagramie (to te pionowe linie oddzielające cykle), drugim parametrem jest ile cykli ma zostać zaprezentowane na diagramie.

Jeśli zajrzysz do wygenerowanego pliku out.txt zobaczysz następującą zawartość:

% Creating diagram output grid is on, cycle count:3
% Minimum interval is 1000ms
% Maximum interval is 2000ms
% Grid time is 500ms, divider:2
% Full cycle step count in grid is 4
-|a-a-|a-a-|a-a-|-
title = A,1

-|b---|b---|b---|-
title = B,2

> SELECT * STREAM str1 FROM A+B

-|c-c-|c-c-|c-c-|-
title = str1,1

W tym pliku proszę zwrócić uwagę na dane przedstawione w komentarzach. Są to czasy wyznaczone w trakcie generowania schematu a odnoszące się do skali prezentowanej na schemacie kulkowym. Jak widać, dla naszego zapytania minimalny interwał okna to 1 sekunda, maksymalny to 2 sekundy. Siatka jaka została zidentyfikowana i wyznaczona na pół sekundy. Na schemacie każda litera lub myślnik to właśnie półsekundowy czasokres pomiędzy kolejnymi operacjami.

Wygenerowaną zawartość możemy zawartość zmienić ręcznie. Jeśli zamienimy tą zawartość w następujący sposób:

-|a-b-|c-d-|e-f-|-
title = A,1

-|g---|h---|i---|-
title = B,2

> SELECT * STREAM str1 FROM A+B

-|j-k-|l-m-|n-o-|-
title = str1,1
j:=ag
k:=bg
l:=ch
m:=dh
n:=ei
o:=fi

Wywołamy następnie ponownie program swirly zobaczymy bardziej dokładny rysunek przedstawiający sekwencję zdarzeń występujących w systemie.

Rys. 6 Schemat kulkowy - Suma, diagram zmodyfikowany

Na diagramie przedstawionym na rysunku Rys. 6 widać, które kulki zostały połączone i z których kulek powstały. Przypominam jednak że to obraz poprawiony ręcznie, dla celów tego opracowania – generator wbudowany w kompilator nie realizuje tej funkcjonalności.

NOTE: Opisana funkcjonalność ma pokrycie w testach: Pattern1, issue167_triarg opisanych w załączniku pt. Testy Integracyjne.

Sekwencjonowanie operacji przeplotu

Przeanalizujmy teraz operację przeplotu. Stwórzmy plik qplan2.rql o następującej treści:

DECLARE a BYTE STREAM A, 1 FILE 'data1.txt'
DECLARE a BYTE STREAM B, 2 FILE 'data2.txt'
SELECT * STREAM str1 FROM A#B

Oprócz znaku # zamiast znaku + w klauzuli from oba pliki się niczym nie różnią. Wywołajmy kompilację oraz program swirly. Plik graficzny prezentować się będzie następująco:

Rys. 7 Schemat kulkowy - operacja przeplotu

Na Rys. 7 widać zmianę. Kulki strumienia str1 zostały równomiernie uporządkowane w czasie. Zdarzenia występujące w zadeklarowanych strumieniach danych wejściowych nie uległy zmianie. Uległa natomiast zmianie zasada budowy strumienia wynikowego str1.

Jeśli zajrzymy do wygenerowanego schematu tekstowego – zobaczymy że wartości czasowe również uległy zmianie:

% Minimum interval is 666ms
% Maximum interval is 2000ms
% Grid time is 333ms, divider:2
% Full cycle step count in grid is 6

Zachęcam do dalszego eksperymentowania z tym sposobem prezentacji zdefiniowanych operacji na seriach czasowych.

NOTE: Opisana funkcjonalność ma pokrycie w testach: operations, Pattern1 opisanych w załączniku pt. Testy Integracyjne.

Klauzula VOLATILE

Klauzula VOLATILE w poleceniu SELECT tworzy strumień przechowywany w pamięci. Na dysku pojawia się jedynie plik deskryptora .desc opisujący schemat danych — same dane nigdy nie są zapisywane.

Domyślna ulotność i wyjątek PERSISTENT

Dyrektywa DEFAULT VOLATILE ustawia przechowywanie w pamięci dla wyników SELECT bez jawnej polityki oraz dla substratów kompilatora. Zastępuje więc powtarzane VOLATILE i dyrektywę SUBSTRAT 'memory':

DEFAULT VOLATILE
DECLARE a INTEGER STREAM sensor, 0.1 FILE '/dev/sensor0'
SELECT sensor[0]*100 STREAM scaled FROM sensor
SELECT scaled[0] STREAM history FROM scaled PERSISTENT

scaled pozostaje w pamięci, a history zapisuje dane na dysku według zwykłych zasad FILE, RETENTION i STORAGE. PERSISTENT dotyczy tylko wyniku danego SELECT; jego substraty nadal dziedziczą domyślną ulotność.

Dyrektywa może wystąpić tylko raz, przed pierwszym DECLARE, SELECT lub RULE. Nie zmienia źródeł DECLARE. Bez niej dotychczasowe programy zachowują swoje ustawienia. VOLATILE i PERSISTENT są wzajemnie wykluczającymi się klauzulami.

Jawne STORAGE profil przy SELECT zastępuje ustawienie domyślne; np. STORAGE DEFAULT wybiera zwykły magazyn plikowy. Jawne VOLATILE zachowuje pierwszeństwo nad STORAGE, tak jak wcześniej. Połączenie PERSISTENT STORAGE MEMORY jest błędem. Jawne SUBSTRAT 'profil' wybiera magazyn substratów niezależnie od kolejności tych dwóch dyrektyw w nagłówku. Samo FILE lub RETENTION nie wyłącza domyślnej ulotności: do zapisu historii należy dodać PERSISTENT.

Działanie

SELECT wyrażenie STREAM nazwa FROM źródło VOLATILE

Parser ustawia typ przechowywania na MEMORY z początkową pojemnością 1:

if (ctx->VOLATILE()) {
    qry.policy = std::make_pair("MEMORY", 1);
}

Następnie kompilator wyznacza pojemność wymaganą przez plan. Jeśli inny strumień czyta historię wyniku VOLATILE, bufor może pomieścić więcej niż jeden rekord. Oznacza to, że:

  • bufor w pamięci przechowuje co najmniej ostatni rekord oraz historię potrzebną konsumentom,
  • dane nie trafiają na dysk,
  • deskryptor .desc jest tworzony — inne procesy mogą poznać schemat strumienia.

Różnica względem STORAGE MEMORY

CechaVOLATILESTORAGE MEMORY
Pojemność buforapoczątkowo 1 rekord; może wzrosnąć według planuzależna od RETENTION i potrzeb planu
Klauzula RETENTIONignorowanastosowana
Deskryptor na dyskutaktak
Dane na dyskunienie

VOLATILE przydaje się gdy wynik zapytania jest pobierany przez xqry na bieżąco i historia nie jest potrzebna — np. aktualna wartość czujnika udostępniana przez system operacyjny.

Przykład

DECLARE a INTEGER STREAM sensor, 0.1 FILE '/dev/sensor0'

SELECT sensor[0] * 100 STREAM scaled FROM sensor VOLATILE

Strumień scaled zawiera w każdej chwili jedną, aktualną wartość. Proces xqry może ją odczytać przez pamięć współdzieloną.

Typy STORAGE

Klauzula STORAGE w poleceniu SELECT oraz dyrektywa SUBSTRAT przyjmują jeden z następujących identyfikatorów. Każdy mapuje się na konkretną klasę akcesora danych w implementacji.

Tabela typów

Słowo kluczoweKlasa C++RetencjaShadowPrzeznaczenie
DEFAULTgroupFile<posixBinaryFileWithShadow>taktakDomyślny tryb produkcyjny; plik .shadow chroni modyfikacje
DIRECTgroupFile<posixBinaryFile>taknieRetencja bez ochrony shadow
MEMORYmemoryFiletak (RAM)nieDane wyłącznie w pamięci; bufor kołowy bez zapisu na dysk
POSIXposixBinaryFilenieniePojedynczy plik binarny; bez retencji
POSIXSHDposixBinaryFileWithShadownietakPojedynczy plik z ochroną shadow; bez retencji
GENERICgenericBinaryFilenienieGeneryczny plik binarny
DEVICEbinaryDeviceROnienieUrządzenie binarne; tylko odczyt; pętla zależna od ONESHOT
TEXTSOURCEtextSourceROnieniePlik tekstowy; tylko odczyt; pętla zależna od ONESHOT

Retencja — artefakty rotowane, starsze pliki usuwane automatycznie (wymaga RETENTION w SELECT).
Shadow — każda modyfikacja zapisywana jest do osobnego pliku .shadow; dane historyczne są chronione przed nadpisaniem.

W przypadku MEMORY retencja działa w pamięci jako bufor kołowy: kolejne dopisania nadpisują najstarszy slot (index % capacity). Dane nie są segmentowane do plików i nie trafiają na dysk.

NOTE: Typ MEMORY (SUBSTRAT ‘memory’) ma pokrycie w testach: issue61_tmpmem (sekwencyjny i równoległy) opisanych w załączniku pt. Testy Integracyjne.

Kiedy używać

Wybór zależy od wymagań środowiska:

  • Środowisko produkcyjne, dane krytyczneDEFAULT (retencja + shadow)
  • Środowisko produkcyjne, dane nieistotne historycznieMEMORY (zero dysku, retencja w RAM)
  • Rozwój i debugowanieDEFAULT lub DIRECT (dane widoczne na dysku)
  • Odczyt z urządzenia lub pliku tekstowegoDEVICE / TEXTSOURCE (odpowiednio)

Przykład

SELECT str1[0] STREAM str1 FROM core0 STORAGE MEMORY
SELECT str2[0] STREAM str2 FROM core0 RETENTION 100 STORAGE DIRECT

Dla substratów globalnie — dyrektywa SUBSTRAT:

SUBSTRAT 'memory'

Operatory agregujące

Dwie osie agregacji (MIN, MAX, AVG, SUMC)

Te same cztery słowa kluczowe opisują dwie różne konstrukcje. Po stronie FROM reduktor zwija pola jednego bieżącego rekordu. W liście SELECT agregat okna rekordowego zwija jedną wartość wyrażenia obliczoną dla każdego z kolejnych rekordów historii. Położenie konstrukcji rozstrzyga zatem, czy redukcja biegnie poziomo po polach, czy pionowo po czasie.

Reduktory bieżącego rekordu w FROM

Reduktory strumieniowe działają na strumieniu posiadającym wiele pól — typowo na wyniku operatora @(k,w) albo na rekordzie zawierającym tablicę liczbową. Redukują wszystkie płaskie sloty jednego rekordu do jednej wartości.

Składnia

FROM AGREGATOR(wyrażenie_strumieniowe)

gdzie AGREGATOR to jedno z:

Słowo kluczoweDziałanie
min / MINminimum ze wszystkich pól rekordu
max / MAXmaksimum ze wszystkich pól rekordu
avg / AVGśrednia arytmetyczna pól rekordu
sumc / SUMCsuma wszystkich pól rekordu

Słowa kluczowe akceptowane są zarówno małymi, jak i wielkimi literami. Są zastrzeżone, dlatego strumień nie może nazywać się min, MAX, avg ani SUMC.

Argumentem może być całe wyrażenie strumieniowe, nie tylko pojedyncza nazwa. Dzięki temu okno i redukcję można zapisać bez pomocniczego zapytania:

SELECT * STREAM total FROM SUMC(src@(1,5))

Postać przyrostkowa strumień.min, .max, .avg i .sumc pozostaje zgodna wstecz, ale jest wygaszana. Parser emituje ostrzeżenie i zaleca postać funkcyjną. Dotychczasowy zapis src@(1,5).sumc jest poprawny, lecz nowe zapytania powinny używać SUMC(src@(1,5)).

Wyniku reduktora nie czyta się po nazwie w liście SELECT. Zapis SELECT avg STREAM o FROM AVG(src) jest odrzucany przez kompilator kanałem Check result:, bo avg jest w tym miejscu operatorem strumieniowym, a nie polem — nie ma go czym wykonać. Wynik redukcji odczytuje się przez SELECT * albo, gdy potrzebne są dalsze obliczenia, przez zmaterializowanie reduktora w osobnym strumieniu:

SELECT * STREAM m FROM AVG(src)
SELECT m[0]*2 STREAM o FROM m

Pola tablicowe i wartości NULL

Liczbowa deklaracja T[N] jest jednym wpisem deskryptora, ale zajmuje N płaskich slotów rekordu. Reduktor odwiedza wszystkie te sloty. Dlatego poniższe zapytanie liczy minimum ze wszystkich 24 ogniw bieżącego rekordu, a nie tylko z cells[0]:

DECLARE cells INTEGER[24] STREAM battery, 1 FILE 'cells.txt'
SELECT * STREAM cell_min FROM MIN(battery)

Schematy strumieni pochodnych rozwijają tablice liczbowe do pól skalarnych, zachowując kolejność slotów i układ bajtów. STRING[N] jest natomiast jednym polem tekstowym o szerokości N bajtów, nie tablicą N liczb.

Wartości NULL są pomijane. Jeżeli wszystkie sloty rekordu mają wartość NULL, wynikiem redukcji jest NULL, a nie zero.

Interwał wyjściowy

Agregaty nie zmieniają częstotliwości strumienia — interwał wyniku jest taki sam jak źródła:

\[\Delta_{wynik} = \Delta_{strumień}\]

Typ wyniku

Typ wyniku zależy od typu wartości wejściowych:

Typ wejściowyTyp wyniku MIN/MAX/AVG/SUMC
BYTE, INTEGER, UINT, RATIONALRATIONAL
FLOATFLOAT
DOUBLEDOUBLE

Dla typów całkowitych i wymiernych rachunek idzie po liczbach wymiernych, więc AVG nie traci reszty z dzielenia. Dotyczy to także MIN i MAX: minimum trzech siódemek ma typ RATIONAL i wartość 7/1, a nie typ INTEGER. FLOAT i DOUBLE zachowują swój typ; artefakt z takim wejściem nie zmienia się w pole RATIONAL.

Odbiorca pola RATIONAL musi znać układ pary licznik-mianownik (→ Układ pola RATIONAL) albo jawnie przepuścić wynik przez to_string, to_double lub to_integer.

Przykład: średnia z rekordu okna AGSE

DECLARE val INTEGER STREAM src, 1 FILE 'data.txt'

# AGSE buduje rekord z pięciu próbek, AVG redukuje jego pięć pól
SELECT * STREAM ma5 FROM AVG(src@(1,5))

Strumień ma5 zawiera w każdej chwili średnią z pięciu kolejnych próbek src. Jest to kompozycja operatora AGSE z reduktorem rekordu, a nie agregat z listy SELECT opisany niżej.

Przykład: filtr sygnałowy (sumc)

Fragment z przykładu implementacji filtru sygnałowego:

SELECT source[_] * filter[_] STREAM accRow FROM source@(1,25)+filter
SELECT accRow[0] STREAM output FROM SUMC(accRow)

Okno znajduje się bezpośrednio w FROM, więc nie wymaga osobnego zapytania. source[_] rozwija się zgodnie z 25 slotami, które source@(1,25) wnosi do rekordu wejściowego. SUMC(accRow) sumuje wszystkie pola rekordu accRow — iloczyny próbek sygnału przez współczynniki filtru — produkując wyjście filtru FIR.

Przykład: MIN i MAX

DECLARE v INTEGER STREAM src, 0.1 FILE '/dev/urandom'
SELECT * STREAM min10 FROM MIN(src@(1,10))
SELECT * STREAM max10 FROM MAX(src@(1,10))

NOTE: Reduktory bieżącego rekordu mają pokrycie w testach simple_max, wide_from_names, agse_array i array_derived, opisanych w załączniku Testy integracyjne.


Agregaty okna rekordowego w SELECT

Składnia

SELECT wyrażenie_z_AGREGATOR(wartość_rekordu : szerokość) \
STREAM wynik FROM źródło

Sam AGREGATOR(wartość_rekordu : szerokość) jest operandem zwykłego wyrażenia pola. Można go łączyć z literałami, innymi polami, operatorami arytmetycznymi i funkcjami skalarnymi:

SELECT 2*MIN(a : 5)+1, null2zero(AVG(a+b : 5))-10 \
STREAM transformed FROM src

Nie wolno jedynie zagnieżdżać agregatu okna w argumencie innego agregatu okna. szerokość jest dodatnią liczbą rekordów. Dla rekordu wynikowego o indeksie logicznym n agregat oblicza wartość_rekordu osobno na rekordach źródła od n-(szerokość-1) do n, a następnie redukuje dokładnie te wartości. Okno jest stemplowane końcem i przesuwa się o jeden rekord. Interwał wyniku pozostaje równy interwałowi źródła, początek logiczny przesuwa się o szerokość-1, a ogon startowy jest dziedziczony ze źródła.

DECLARE a INTEGER, b INTEGER STREAM src, 1 FILE 'data.txt'

SELECT MIN(a : 5), MAX(a : 5), AVG(a+b : 5), SUMC(a : 5) \
STREAM stats FROM src

Kilka agregatów nad tym samym wyrażeniem, źródłem i szerokością współdzieli jedno przejście po historii. Wartości NULL są pomijane; okno bez ani jednej wartości obecnej daje NULL. Typ wyniku podlega tej samej tabeli promocji co reduktor bieżącego rekordu, a ustalony typ jest zachowywany przez czyste kopie, przesunięcia i pozostałe operatory kopiujące schemat.

Ograniczenia argumentu

Argument musi być liczbowym wyrażeniem odczytującym co najmniej jedno pole jednego przechowywanego źródła. Zapytanie z agregatem okna rekordowego musi mieć w FROM pojedyncze, zwykłe odwołanie do strumienia. Kompilator odrzuca:

  • szerokość niedodatnią;
  • wyrażenie tekstowe albo stałe, które nie odczytuje pola;
  • wyrażenie mieszające historię kilku strumieni;
  • zagnieżdżony agregat okna i użycie agregatu w warunku RULE;
  • złożoną klauzulę FROM, na przykład FROM src - 2;
  • gołą nazwę tablicy liczbowej.

Dla DECLARE a INTEGER[3] trzeba wskazać jeden kanał, na przykład MIN(a[0] : 5). MIN(a : 5) nie oznacza wszystkich elementów tablicy z każdego rekordu i zostaje odrzucone. Redukcję wszystkich elementów jednego rekordu zapisuje się osobno jako FROM MIN(strumień).

Łączenie redukcji po kanałach i po czasie

Obie osie można składać bez serializacji tablicy i bez ręcznego tworzenia osobnego strumienia dla każdego kanału:

DECLARE value INTEGER[24] STREAM sensors, 1/10 FILE 'sensors.txt'

SELECT * STREAM row_min FROM MIN(sensors)
SELECT MIN(row_min[0] : 10) STREAM interval_min FROM row_min

Pierwszy MIN redukuje 24 równoległe wartości jednego rekordu. Drugi redukuje wyniki z dziesięciu kolejnych rekordów, więc interval_min jest minimum z 240 wartości, ale zachowuje interwał źródła i emituje przesuwne okno po każdym rekordzie. Jeżeli potrzebny jest wynik rzadszy, należy rozrzedzić gotowy strumień zgodnie z następną sekcją.

Hopping window

Agregat w SELECT nie ma argumentu kroku. Hopping window powstaje przez rozrzedzenie gotowego strumienia okien operatorem - w drugim węźle:

SELECT MIN(a : 5) STREAM sliding FROM src
SELECT * STREAM hopping FROM sliding - 2

Argument operatora - jest docelowym interwałem wyniku. Dla skoku H nad źródłem o interwale \(\Delta\) należy podać \(H\Delta\). Rozdzielenie na dwa węzły zachowuje w każdym oknie pięć kolejnych rekordów i dopiero potem wybiera co H-ty wynik. Bezpośrednie SELECT MIN(a : 5) ... FROM src - 2 nie jest skrótem tej konstrukcji i nie kompiluje się.

NOTE: Składnię, typy, brzegi, współdzielenie obliczeń, wyrażenia, wartości NULL i ograniczenia agregatów okna rekordowego sprawdzają window_aggregate oraz testy jednostkowe ut_compiler i ut_expeval.


Dalszy rachunek na wyniku agregatu

Funkcje skalarne należą do składni wyrażeń pól, nie do żadnego rodzaju okna. Pełną listę, reguły nazw i arności oraz semantykę typów opisuje rozdział Wyrażenia pól i funkcje skalarne. Poniżej pozostają tylko konwersje szczególnie istotne przy odczycie wyniku agregatu.

isnull(x) zwraca 1 dla NULL i 0 dla wartości obecnej. null2zero(x) zamienia NULL na całkowite zero, ale wartość obecną przepuszcza bez zmiany jej typu. Jest to konwersja stratna, a nie sposób eksportu informacji o braku. Dzielenie przez zero daje NULL dla każdego typu liczbowego i nie zatrzymuje dalszego przetwarzania strumienia.


Przykład konwersji: to_string

Funkcja to_string konwertuje wyrażenie liczbowe na ciąg tekstowy o zadanej szerokości. Wynik trafia do pola typu STRING w strumieniu wynikowym.

Składnia

to_string(wyrażenie : szerokość)
to_string(wyrażenie)

Parametr szerokość (liczba naturalna po dwukropku :) określa szerokość pola wyjściowego w bajtach. Pominięcie parametru daje domyślną szerokość 32 bajtów.

ℹ Info

Separator argumentów to dwukropek :, nie przecinek ,. Przecinek jest separatorem listy SELECT — użycie przecinka w to_string(x, n) spowoduje błąd parsowania.

Przykład

DECLARE v INTEGER STREAM src, 1 FILE 'data.txt'

SELECT to_string(src[0]:10) STREAM labels FROM src

Strumień labels zawiera wartości src sformatowane jako tekst w polu 10-bajtowym.

Konkatenacja z literałem

Ciąg wynikowy można łączyć z literałem stringowym operatorem +:

SELECT to_string(src[0]:8) + '_ok' STREAM tagged FROM src

Rozmiar pola wynikowego: 8 (z to_string) + 3 (literal _ok) = 11 bajtów.

Zastosowanie

to_string przydaje się przy eksporcie do systemów przyjmujących dane tekstowe (Graphite, InfluxDB przez xqry) lub przy tworzeniu etykiet zdarzeń łączonych z wyjściem DO DUMP.

NOTE: Opisana funkcjonalność ma pokrycie w testach: issue121_isnull, issue128_numeric_to_string, issue128_string_to_numeric opisanych w załączniku pt. Testy Integracyjne.


Przykład konwersji: to_integer

Funkcja to_integer konwertuje wyrażenie liczbowe na pole typu INTEGER. Jest podstawową drogą odczytu artefaktu z agregatem: pole RATIONAL zamienia na liczbę całkowitą, którą czytelnik odczyta bez znajomości układu pary licznik-mianownik.

Składnia

to_integer(wyrażenie)

Zaokrąglenie

⚠️ Ostrzeżenie

to_integer obcina w stronę zera, a nie podłoguje. Dla wartości ujemnych wynik różni się od podłogi o jeden.

Reguła jest ta sama dla argumentu wymiernego i zmiennoprzecinkowego — w obu przypadkach część ułamkowa jest odrzucana, a znak zachowany:

Wartość wejściowato_integerpodłoga (dla porównania)
8/322
-8/3-2-3
-4/3-1-2
-2.6666…-2-3

Wartość NULL przechodzi przez funkcję bez zmiany — to_integer(NULL) daje NULL, a nie zero.

Pułapka przy przenoszeniu na Pythona

Operator // w Pythonie podłoguje, więc naiwne przepisanie zapytania rozjeżdża się z silnikiem na każdej wartości ujemnej:

>>> -8 // 3        # Python: podłoga
-3
>>> int(-8 / 3)    # to samo, co robi to_integer
-2

Model odtwarzający zachowanie silnika musi liczyć średnią obciętą jawnie:

def truncated_mean(values):
    """Obcięcie w stronę zera, tak jak rzutowanie średniej wymiernej."""
    total = sum(values)
    quotient = abs(total) // len(values)
    return quotient if total >= 0 else -quotient

Ten sam problem dotyczy każdego języka, w którym dzielenie całkowite podłoguje.

Zastosowanie

to_integer jest właściwe tam, gdzie odbiorca artefaktu ma przyjąć liczbę całkowitą i część ułamkowa nie jest potrzebna. Tam, gdzie wartość ma pozostać dokładna, właściwe jest to_string, które zapisuje ułamek jako tekst licznik/mianownik, albo odczyt pary wprost (→ Układ pola RATIONAL).

NOTE: Opisana funkcjonalność ma pokrycie w testach: issue128_string_to_numeric opisanym w załączniku pt. Testy Integracyjne, oraz w testach jednostkowych ut_payload i ut_convertTypes, przypinających układ pola RATIONAL i regułę zaokrąglenia.

Polecenie RULE

To polecenie to jedno z ostatnich opracowanych przeze mnie rozszerzeń systemu. Rozszerza ono funkcjonalność systemu o mechanizm alarmowania.

NOTE: Opisana funkcjonalność ma pokrycie w testach: issue42_rule opisanych w załączniku pt. Testy Integracyjne.

Składnia polecenia RULE przedstawia się następująco:

RULE nazwa_reguły
ON nazwa_strumienia_danych
WHEN warunek_logiczny
DO DUMP kroki_wstecz TO kroki_w_przód [RETENTION segmenty]

Lub w taki sposób:

RULE nazwa_reguły
ON nazwa_strumienia_danych
WHEN warunek_logiczny
DO SYSTEM polecenie_systemu

Rys. 8. Diagram składni polecenia RULE

Diagram składni (railroad) przedstawiony na Rys. 8 został wygenerowany na podstawie reguły rule_statement z gramatyki ANTLR4 systemu (RQL.g4) i obejmuje obie przedstawione formy polecenia jednym torem: rozgałęzienie za słowem DO prowadzi albo do wariantu DUMP (ze zrzutem okna danych i opcjonalną retencją), albo do wariantu SYSTEM (z poleceniem systemowym w apostrofach). Zaokrąglone zielone pola to słowa kluczowe i symbole wpisywane dosłownie, prostokąty to wartości podawane przez użytkownika; tory omijające znak minus i klauzulę RETENTION oznaczają ich opcjonalność.

Tak zdefiniowane zdarzenia podpinają się do zdefiniowanych strumieni danych. Nazwa reguły powinna być unikalna. Strumień danych powinien zostać zdefiniowany przed pojawieniem się polecenia stworzenia reguły w pliku rql.

W obu wersjach polecenia RULE tworzona jest nazwa reguły, warunek logiczny oraz nazwa strumienia do którego proces uruchamiany poleceniem DO jest podłączany. Warunek logiczny powinien odwoływać się do zmiennych dostępnych w schemacie strumienia danych występującego po klauzuli ON.

W pierwszej wersji polecenia w której występuje klauzula DO DUMP definiujemy proces, który umożliwia zebranie danych, które napłyną w przyszłości. Jeśli pominiemy klauzulę RETENTION, zrzut nastąpi bezpośrednio do pliku z nazwą reguły poprzedzonej nazwą strumienia. Jeśli dołączymy klauzulę RETENTION, pliki będą podlegały retencji w zakresie zdefiniowanej w parametrze ‘segmenty’. Będą dołączane sekwencyjne numery na końcu każdego zrzutu. Zrzuty są binarne i zachowują schemat wszystkich pól źródłowego strumienia danych. Tutaj na uwagę powinno zasługiwać to, że polecenie tworzy proces w systemie, który po pojawieniu się warunku logicznego którego wartość powinna być prawdą – pobiera dane z przeszłości oraz zakłada ich napływ i rejestrację w przyszłości. Nic nie stoi na przeszkodzie aby jednak zebrać dane tylko z przeszłości lub tylko z przyszłości. Jeśli wartości kroki_* przyjmą wartości ujemne to odnosimy się do przeszłości (tzn. do danych historycznych w stosunku do momentu wystąpienia zdarzenia opisanego warunkiem logicznym)

Klauzula DO SYSTEM umożliwia wywołanie zdarzenia systemowego po zajściu w warunku logicznego opartego na zarejestrowanych danych. W ten sposób dowolne polecenie systemowe może zostać wywołane.

Przykłady deklaracji reguł w języku RQL:

RULE testrule1 \
ON str1 \
WHEN str1[0] > 11 \
DO DUMP -5 TO 5 RETENTION 100

RULE testrule2 \
ON str1 \
WHEN str1[0] = 13 OR str1[0] = 11 \
DO SYSTEM 'echo "systemcall"'

Zakładamy, że zdefiniowano uprzednio strumień str1 którego dane w postaci liczb o typie całkowitym pojawiają się co sekundę. W takim przypadku pierwsza reguła podpinając się do tego strumienia oczekuje aż dane, których wartość przekracza wartość 11. Jeśli takie zdarzenie zajdzie dokona się zrzut danych obejmujących obszar 5 sekund wstecz i 5 sekund po zajściu zdarzenia opisanego w warunku logicznym.

Druga reguła z trochę innym warunkiem logicznym wyświetli na ekranie w którym został uruchomiony proces systemu RetractorDB tekst o treści „systemcall”.

Składnia polecenia RULE

Pełna składnia polecenia RULE ma postać:

RULE <nazwa>
ON <strumień>
WHEN <warunek>
DO <akcja>

Gdzie <akcja> może przyjąć jedną z dwóch form:

SYSTEM '<polecenie_systemowe>'
DUMP [-]<krok_wstecz> TO [-]<krok_wprzód> [RETENTION <n>]

Ograniczenie

Reguła może być podpięta wyłącznie pod strumień zadeklarowany poleceniem SELECT (artefakt lub substrat). Podpięcie pod strumień wejściowy DECLARE jest błędem kompilacji:

# NIEPRAWIDŁOWE — core0 jest deklaracją, nie można podpiąć reguły
RULE r1 ON core0 WHEN core0[0] > 10 DO SYSTEM 'echo alarm'

Warunek WHEN

Warunek to wyrażenie logiczne ewaluowane do wartości prawda/fałsz po każdej nowej próbce strumienia.

Operatory porównania: =, !=, <, >, <=, >=. Operatory logiczne: OR, AND, NOT. Przykłady:

WHEN str1[0] > 100
WHEN str1[0] = 0 OR str1[0] = 255
WHEN str1[0] >= 10 AND str1[0] <= 90
WHEN NOT str1[0] = 0

Akcja DO SYSTEM

Akcja DO SYSTEM wykonuje podane polecenie powłoki (przez wywołanie system(3)) w momencie spełnienia warunku. RetractorDB loguje kod wyjścia polecenia — niezerowy kod jest raportowany jako błąd w logu.

RULE alert1 \
ON wyniki \
WHEN wyniki[0] > 1000 \
DO SYSTEM 'curl -s http://monitoring/alert'

W poleceniu można użyć dowolnego programu dostępnego w PATH: skryptów powłoki, programów Pythona, wywołań REST, wysyłki powiadomień, etc.

Akcja DO DUMP

Akcja DO DUMP zapisuje okno próbek strumienia do pliku binarnego w momencie spełnienia warunku. Pozwala zachować kontekst zdarzenia: dane przed jego wystąpieniem i dane po nim.

RULE zdarzenie \
ON wyniki \
WHEN wyniki[0] > 500 \
DO DUMP -10 TO 5

Parametry zakresu:

ParametrZnaczenie
ujemny step_back (np. -10)dołącz 10 próbek historycznych sprzed zdarzenia
0 jako step_backzacznij zrzut od chwili zdarzenia
dodatni step_back (np. 2)opóźnij start zrzutu o 2 próbki po zdarzeniu
step_forward (np. 5)zbierz łącznie step_forward - step_back próbek

Całkowita liczba zrzucanych rekordów: abs(step_forward - step_back). Przykład: DUMP -5 TO 5 → 10 rekordów (5 historycznych + 5 kolejnych). DUMP 0 TO 1 → 1 rekord (bieżąca próbka).

Zakres step_back musi być mniejszy lub równy step_forward. Wartość step_back może być ujemna (historia) lub nieujemna (opóźnienie). Obie wartości ujemne nie są obsługiwane.

Pliki zrzutu

Pliki są tworzone w katalogu konfigurowanym przez dyrektywę STORAGE. Konwencja nazewnictwa:

<strumień>_<nazwa_reguły>_dump.tmp          # bez RETENTION
<strumień>_<nazwa_reguły>_dump_<n>.tmp      # z RETENTION (n = 0..N-1)

Format pliku to surowe dane binarne zgodne z deskryptorem strumienia (bez nagłówka). Do odczytu pliku można użyć narzędzia xtrdb.

Opcja RETENTION

Parametr RETENTION <n> ogranicza liczbę przechowywanych zrzutów — stary plik jest nadpisywany przez nowy (bufor cykliczny). Bez RETENTION każde wyzwolenie nadpisuje jeden plik _dump.tmp.

RULE zdarzenie \
ON wyniki \
WHEN wyniki[0] > 500 \
DO DUMP -10 TO 5 RETENTION 20

Powyższy przykład przechowuje 20 ostatnich zrzutów w plikach wyniki_zdarzenie_dump_0.tmpwyniki_zdarzenie_dump_19.tmp.

Wiele reguł dla jednego strumienia

Do jednego strumienia można przypiąć dowolną liczbę reguł różnych typów:

RULE alert_wysoki \
ON pomiary \
WHEN pomiary[0] > 900 \
DO SYSTEM 'notify-send "Przekroczono prog"'

RULE alert_niski \
ON pomiary \
WHEN pomiary[0] < 10 \
DO SYSTEM 'notify-send "Zbyt niska wartosc"'

RULE zapis_anomalii \
ON pomiary \
WHEN pomiary[0] > 900 \
DO DUMP -20 TO 10 RETENTION 5

Wszystkie reguły danego strumienia są ewaluowane przy każdej nowej próbce.

Konstrukcja mechanizmu

Przez alarmowanie rozumiemy proces przetwarzania danych bieżących i bieżącego reagowania systemu w razie rozpoznania zaistniałego zjawiska przez system. Aby alarmowanie mogło funkcjonować, musza w systemie istnieć mechanizmy wspierające ten proces. W systemie RetractorDB opracowałem model alarmowania oparty na deklaracji reguł związanych z obserwacją strumieni danych. Reguły te zawierają operacje matematyczne umożliwiające analizę warunków logicznych i uruchomienie zewnętrznych procesów lub realizację zrzutu danych w wybranym oknie czasowym.

NOTE: Opisana funkcjonalność ma pokrycie w testach: issue42_rule opisanych w załączniku pt. Testy Integracyjne.

Prezentacji składni polecenia RULE na stronie 24 wspomina o tej funkcjonalności. W tym rozdziale chciałbym przybliżyć zasady działania tego rozwiązania.

Budując przykład przedstawiający zasadę działania alarmowania stwórzmy następujący plik zapytania – query.rql:

DECLARE a UINT STREAM core0, 1 FILE 'datafile1.txt'
SELECT str4[0] STREAM str4 FROM core0>1

RULE regulation1 \
ON str4 \
WHEN str4[0] = 20 or str4[0] = 23 \
DO SYSTEM 'echo "test"'

W pliku datafile1.txt znajdują się liczby w postaci tekstowej od 20 do 28.

$ seq 20 28 > datafile1.txt

Powyższe 3 polecenia deklarują efemeryczne źródło danych, jedno polecenie przetwarzania danych poprzez przesunięcie w czasie o jedną próbkę w czasie. Oraz regułę alarmowania. Wykonanie następującego polecenia:

$ xretractor -c query.rql -d -u -p -i > out.dot &&
dot -Tpng out.dot -o out.png

Wyświetlając plik out.png zobaczymy na ekranie coś zbliżonego (Rys. 9):

Rys. 9 Zależność obiektów w przypadku użycia alarmowania

Obraz zaprezentuje jaka zachodzi zależność pomiędzy procesami odpowiedzialnymi za artefakty, alarmowanie oraz efemerydy. Równie dobrze powinno się udać podłączyć proces odpowiedzialny za alarmowanie do substratu.

Obiekty alarmowania przedstawiane są w kolorze błękitnym i połączone z obiektami, które monitorują za pomocą czerwonych, nieskierowanych linii.

Obiektów odpowiedzialnych za alarmowanie można podłączyć więcej niż jeden. Można podać więcej poleceń RULE skojarzonych z danym poleceniem tworzącym strumień danych.

Jeśli przyjrzymy się bliżej zobaczymy, że z procesem odpowiedzialnym za alarmowanie jest uruchamiany warunkiem. Następującym poleceniem możemy podejrzeć co tam właściwie się dzieje:

$ xretractor -c query.rql -d -u -p > out.dot &&
dot -Tpng out.dot -o out.png

Plik wyjściowy prezentuje się w następujący sposób (Rys. 10):

Rys. 10 Kod odpowiedzialny za warunek uruchomienia alarmowania.

Ten warunek musi zostać w ostatecznej formie wyliczony do wyrażenie reprezentującego prawdę lub fałsz.

Warunek logiczny w RULE

Klauzula WHEN polecenia RULE przyjmuje wyrażenie logiczne, które jest ewaluowane na każdym nowym rekordzie wskazanego strumienia. Jeśli wyrażenie zwraca prawdę — uruchamiany jest proces zdefiniowany w klauzuli DO.

Operatory porównania

OperatorZnaczenie
=równy
!=różny
>większy
<mniejszy
>=większy lub równy
<=mniejszy lub równy

Spójniki logiczne

OperatorZnaczenie
ANDkoniunkcja — oba warunki muszą być spełnione
ORalternatywa — wystarczy jeden warunek
NOTnegacja — warunek musi być niespełniony

Struktura wyrażenia

Warunek buduje się z pól schematu strumienia wskazanego w klauzuli ON. Pola identyfikowane są tak samo jak w SELECT — przez nazwę strumienia z indeksem:

WHEN strumień[indeks] operator wartość

Złożone warunki łączymy spójnikami:

WHEN strumień[0] > 10 AND strumień[1] != 0
WHEN strumień[0] = 5 OR strumień[0] = 7
WHEN NOT strumień[0] < 0

Przykłady

RULE alarm_wysoki \
ON pomiary \
WHEN pomiary[0] > 100 OR pomiary[0] < -100 \
DO DUMP -10 TO 10 RETENTION 50

RULE sygnalizacja \
ON status \
WHEN status[0] = 1 AND status[1] != 0 \
DO SYSTEM 'systemctl restart sensor-reader'

RULE jednorazowy \
ON dane \
WHEN NOT dane[0] = 0 \
DO DUMP -5 TO 0

Dostęp do pól

Warunek odwołuje się do pól strumienia wskazanego w ON. Indeks pola odpowiada pozycji w schemacie tego strumienia — tak samo jak w klauzuli SELECT. Warunek czyta rekord wyjściowy strumienia, więc indeks równy liczbie jego pól lub większy jest błędem kompilacji (zob. Indeks poza zakresem). Aliasowanie działa identycznie jak opisano w rozdziale Aliasowanie.

Jeżeli strumień z ON powstał przez przeplot A#B, warunek musi używać nazwy strumienia wynikowego:

RULE poprawna ON wynik WHEN wynik[0] > 0 DO DUMP -1 TO 0

Odwołanie do nazwanej składowej przeplotu jest niejednoznaczne i kończy kompilację błędem:

RULE bledna ON wynik WHEN A[0] > 0 DO DUMP -1 TO 0

Reguła obowiązuje również wtedy, gdy # jest ukryty w substracie złożonego wyrażenia FROM.

Przykład alarmowania

W oknie terminala uruchamiamy proces xretractor uruchamiając przedstawiony z początku rozdziału plik query.rql

$ xretractor query.rql
test
test
test
…

W drugim oknie terminala proponuję uruchomić polecenie:

$ xqry -s str4
27
28
20
21
22
23
24
25
26
27

Oba okna proponuję ustawić obok siebie. Zobaczymy, że pojawianie się wartości 20 i 23 powoduje uruchomienie akcji po stronie serwera wyświetlającej napis test. Należy pamiętać, że w systemie może pojawić się dowolne polecenie systemowe lub wywołanie dowolnego programu w zależności o tego co umieścimy w deklaracji DO SYSTEM.

Zapis sesji (Animacja poniżej):

Animacja. Zapis sesji przykładu alarmowania

Przykład 2: zapis kontekstu zdarzenia (DO DUMP)

Akcja DO DUMP pozwala utrwalić okno próbek z otoczenia zdarzenia — dane sprzed i po jego wystąpieniu. Jest to przydatne gdy chcemy zachować kontekst anomalii do późniejszej analizy.

Tworzymy plik query.rql:

STORAGE 'temp'

DECLARE a INTEGER STREAM core0, 1 FILE 'datafile1.txt'
SELECT str1[0] STREAM str1 FROM core0

RULE zapis_anomalii \
ON str1 \
WHEN str1[0] > 24 \
DO DUMP -3 TO 3

Dane wejściowe — liczby od 20 do 28:

$ seq 20 28 > datafile1.txt

Uruchamiamy xretractor:

$ xretractor query.rql

Gdy wartość strumienia str1 przekroczy 24, reguła wyzwoli zapis 6 rekordów (3 historyczne + 3 kolejne) do pliku binarnego temp/str1_zapis_anomalii_dump.tmp.

Odczyt pliku zrzutu

Plik zrzutu nie zawiera nagłówka .desc — przy otwieraniu w xtrdb należy podać schemat ręcznie:

$ xtrdb
> storage temp
> open str1_zapis_anomalii_dump { INTEGER a }
> size
> list 6
> quit

Przykład 3: rotacja zrzutów (DO DUMP z RETENTION)

Bez RETENTION każde kolejne wyzwolenie reguły nadpisuje ten sam plik. Gdy zdarzenia powtarzają się, użyj RETENTION N aby zachować ostatnie N zrzutów w osobnych plikach.

STORAGE 'temp'

DECLARE a INTEGER STREAM core0, 1 FILE 'datafile1.txt'
SELECT str1[0] STREAM str1 FROM core0

RULE zapis_anomalii \
ON str1 \
WHEN str1[0] > 24 \
DO DUMP -3 TO 3 RETENTION 5

Każde wyzwolenie tworzy kolejny plik (rotacja cykliczna):

temp/str1_zapis_anomalii_dump_0.tmp
temp/str1_zapis_anomalii_dump_1.tmp
temp/str1_zapis_anomalii_dump_2.tmp
temp/str1_zapis_anomalii_dump_3.tmp
temp/str1_zapis_anomalii_dump_4.tmp

Po przekroczeniu pojemności (RETENTION 5) najstarszy plik jest nadpisywany przez nowy.

Przykład 4: wiele reguł na jednym strumieniu

Do jednego strumienia można przypiąć dowolną liczbę reguł. Poniższy przykład łączy obie akcje — powiadomienie systemowe i zapis kontekstu:

STORAGE 'temp'

DECLARE a INTEGER STREAM core0, 1 FILE 'datafile1.txt'
SELECT str1[0] STREAM str1 FROM core0

RULE prog_dolny \
ON str1 \
WHEN str1[0] < 21 \
DO SYSTEM 'echo "ALARM: wartosc ponizej progu dolnego" >> alarm.log'

RULE prog_gorny \
ON str1 \
WHEN str1[0] > 26 \
DO SYSTEM 'echo "ALARM: wartosc powyzej progu gornego" >> alarm.log'

RULE zapis_kontekstu \
ON str1 \
WHEN str1[0] > 26 \
DO DUMP -5 TO 5 RETENTION 10

Reguły prog_gorny i zapis_kontekstu reagują na ten sam warunek niezależnie — przekroczenie progu górnego jednocześnie zapisuje log i utrwala okno danych. Reguła prog_dolny obsługuje osobno próg dolny.

Wszystkie trzy reguły są ewaluowane przy każdej nowej próbce strumienia str1.

Dyrektywy konfiguracyjne

Dostępne są cztery dyrektywy konfiguracyjne:

  • STORAGE
  • SUBSTRAT
  • ROTATION
  • DEFAULT VOLATILE

STORAGE, SUBSTRAT i ROTATION przyjmują parametr tekstowy w apostrofach. DEFAULT VOLATILE nie przyjmuje napisu. Przykład dyrektyw z parametrem:

STORAGE 'temp_folder'
SUBSTRAT 'memory'
ROTATION 'rotation_counter.txt'

Rys. 11. Diagram składni dyrektyw konfiguracyjnych

Diagram składni (railroad) przedstawiony na Rys. 11 został wygenerowany na podstawie reguły compiler_option z gramatyki ANTLR4 systemu (RQL.g4). Trzy pokazane dyrektywy mają identyczną budowę: jedno ze słów kluczowych STORAGE, SUBSTRAT lub ROTATION (zaokrąglone zielone pola), po którym następuje wartość ujęta w apostrofy — dowolny tekst (ścieżka katalogu dla STORAGE, nazwa pliku licznika dla ROTATION) albo nazwa jednego z predefiniowanych profili pamięci (dla SUBSTRAT).

Storage służy do wskazania w którym katalogu systemowym powinny powstawać wszystkie pliki wynikowe. Bez tej dyrektywy, domyślnie pliki tworzone przez system umieszczane są w bieżącym katalogu w którym został uruchomiony główny proces systemu RetractorDB.

Substraty to zapytania oraz ich efekty, które powstają w wyniku rozkładu poleceń systemu przez kompilator na podstawie wyrażeń algebry szeregów czasowych. Są to zapytania, które widać w planie realizacji zapytań ale nie są one specyfikowane bezpośrednio w pliku .rql. Wynikają one z implementacji procesu konstrukcji planu realizacji zapytań.

Bez DEFAULT VOLATILE lub jawnego SUBSTRAT takie zapytania materializują dane na dysku w postaci nieskończonych plików. Tego typu zachowanie może być pożądane w przypadku prowadzenia procesu rozwoju oprogramowania, w przypadku umieszczenia systemu w środowisku produkcyjnym lepiej substraty przechowywać w tymczasowych obszarach pamięci.

Możliwe opcje w poleceniu SUBSTRAT to: memory, default, direct, posix, posixshd, generic, device, textsource. Pełny opis każdego typu — klasa C++, obsługa retencji i shadow — znajdziesz w rozdziale Typy STORAGE.

Ostatnia dyrektywa - Rotation to dyrektywa wskazująca na odmienny tryb kończenia pracy przez system. Domyślnie po kompilacji wszystkie pliki wytworzone przez system pozostają w stanie w jakim system zarejestrował dane. Po kolejnym wywołaniu polecenia systemowego – wszystkie pliki artefaktów i substratów są usuwane. Użycie dyrektywy Rotation w pliku rql z deklaracją zapytań sprawi że system utworzy plik wymieniony w parametrze dyrektywy i umieści tam licznik zwiększany z każdym uruchomieniem systemu. Plikom z artefaktami i substratami po każdym zakończeniu pracy systemu zostanie zmieniona nazwa – dostaną rozszerzenie .old oraz numer wynikający ze wzrastającego licznika. Ten proces nazywamy rotacją artefaktów.

DEFAULT VOLATILE

DEFAULT VOLATILE

Ta dyrektywa (reguła default_statement) ustawia domyślny magazyn pamięciowy zarówno dla nazwanych wyników SELECT, jak i substratów kompilatora. Umieszcza się ją raz w nagłówku, przed DECLARE, SELECT i RULE. Jawny SUBSTRAT ma pierwszeństwo dla substratów; PERSISTENT lub jawne STORAGE przy SELECT zastępuje ustawienie domyślne dla jego wyniku. Źródła DECLARE pozostają bez zmian. Przykład i pełne zasady: VOLATILE i PERSISTENT.

Architektura systemu

Konstrukcja systemu przetwarzania danych to rozdział stricte techniczny. Przedstawię tutaj jak system został zaprojektowany, zbudowany gdzie i jak obecnie rozmieszczone są jego funkcjonalności.

System RetractorDB został zaimplementowany w języku C++ pod kontrolą systemu Linux. Kod źródłowy podlega procesowi ciągłej integracji i testowania na platformie GitHub wspieranej przez CircleCI. Kod uruchamiany i rozwijany jest lokalnie na platformie Linux WSL2. Porzuciłem rozwój i implementację systemu pod kontrolą systemu Windows. W początkowej fazie utrzymywałem taką opcję i być może w przyszłości do niej powrócę. Jednak utrzymanie zbyt wielu platform rozwojowych znacząco opóźnia proces szybkiego prototypowania i rozwoju systemu. Nadal zachowuję i utrzymuję funkcjonalność systemu na platformie Linux ARM. Kod kompiluję i testuje się pod kontrolą maszyn opartych na architekturze ARM i x86-64 pracujących w zasobach CircleCI. Raspberry PI to jedna z docelowych platform produkcyjnych systemu RetractorDB przewidziana dla potrzeb Edge IoT.

Kompilacja kodu systemu odbywa się ze wsparciem managera pakietów Conan [8]. Jeśli chcemy poznać jak zbudowany jest toolchain budujący kod systemu możemy zajrzeć do pliku /.circleci/config.yml zawierający procedurę budowy i uruchamiania systemu w środowisku kontenerów lub maszyn firmy CircleCI. W plikach /docker/ci/Dockerfile oraz /docker/ci/DockerConan.txt znajdują się instrukcje w jaki sposób obraz kontenera budującego system z prekonfigurowanymi dependencjami. Analiza tych plików wskaże co jest potrzebne i jak należy zainstalować w swoim systemie aby źródła systemu skompilować lokalnie u siebie.

Przegląd poruszonych w rozdziale tematów

Rozdział zbudowany jest warstwowo — od widoku ogólnego do szczegółów implementacyjnych.

  • Perspektywa ogólna

    System jako trójka współpracujących programów: xretractor jako proces realizujący plan zapytań, xqry jako wieloinstancyjny klient danych bieżących, xtrdb jako narzędzie inspekcji plików binarnych. Na jednym hoście może działać wiele nazwanych procesów xretractor, każdy z własnym obszarem Boost IPC. Na schemacie Rys. 12 widać granicę odpowiedzialności komponentów dla jednej takiej instancji.

  • Wiele instancji i magistrala

    Nazwy instancji, rozdzielenie obiektów IPC, rejestr xrdbbus, globalna ochrona nazw strumieni i plików magazynu oraz reguły automatycznego kierowania poleceń xqry. Rozdział opisuje również stałą tożsamość service, wymianę całego planu przez xqry --reset i znaczenie trybów pokazywanych przez xqry --bus.

  • Przepływ danych i sterowania

    Które ścieżki danych są zawsze aktywne (napływ danych → xretractor → artefakty), a które opcjonalne lub diagnostyczne. Opisano też mechanizm graceful shutdown — xretractor reaguje na sygnały SIGINT, SIGTERM i SIGHUP kończąc bieżący cykl bez ryzyka uszkodzenia plików.

  • Artefakty, substraty i efemerydy

    Kluczowy podział taksonomiczny systemu. Każdy typ strumienia ma inne przeznaczenie i inną strategię składowania: artefakty materializowane na dysku jako trwały wynik, substraty to strumienie pośrednie niezbędne podczas obliczeń, efemerydy — ulotne źródła danych, których nie można ani nie warto przechowywać.

  • Format zapisu danych

    Czteroplikowa struktura artefaktu: plik binarny z danymi (stałej długości rekordy, brak nagłówka), deskryptor .desc opisujący schemat rekordu w gramatyce ANTLR4, plik metadanych .meta z indeksem wartości null i przerw w transmisji (kodowanie RLE), opcjonalny plik cienia .shadow do niedestruktywnej modyfikacji historycznych rekordów. Deskryptor określa strategię składowania przez pole TYPE.

  • Kompilacja i budowa planu

    Proces przekształcania pliku .rql w gotowy plan realizacji zapytania. Flaga -c uruchamia tryb kompilacji bez wykonania; połączona z -d -f -s generuje wyjście DOT, które graphviz zamienia w graf przepływu danych. Graf pokazuje dwie domeny: stos wyrażeń arytmetycznych (PUSH, ADD, itp.) i algebrę strumieniową. Opisano pełny zestaw flag trybu kompilacji i wykonania.

  • Przetwarzanie i dystrybucja danych

    Kompletny walkthrough: od przygotowania pliku danych przez uruchomienie xretractor, przez podgląd statystyk strumieniowania (xqry -d), po wizualizację na żywo w gnuplot (xqry -s str1 -p 50,50 | gnuplot) i transmisję przez sieć za pomocą nc. Przykład łączy dwa źródła — plik tekstowy i /dev/urandom — ilustrując jak operator + w klauzuli FROM realizuje algebraiczne łączenie strumieni.

  • Analiza artefaktów

    Narzędzie xtrdb — interaktywny inspektor plików binarnych wzorowany na stylu dbase. Polecenia .open, .desc, .list, .rlist i .meta pozwalają przeglądać zawartość artefaktów bez znajomości formatu binarnego. Narzędzie służy też do weryfikacji deterministyczności: te same dane wejściowe powinny zawsze dawać identyczne wyniki.


Trzy polecenia wystarczające do uruchomienia kompletnego przepływu:

xretractor -c query.rql          # weryfikacja poprawności pliku zapytań
xretractor query.rql             # uruchomienie przetwarzania
xqry -s <strumień>               # odczyt danych bieżących

Czwarty element — xtrdb — pojawia się przy diagnostyce i testowaniu, nie w typowym przepływie produkcyjnym.

Perspektywa ogólna

System zbudowany jest w oparciu o 3 programy dostępne jako polecenia systemowe. Pierwszym jest kompilator oraz system realizujący plany zapytań. Drugim jest klient dostępu do danych bieżących, trzecim jest program umożliwiający dostęp zrzutów binarnych. Ich nazwy to kolejno:

  • xretractor
  • xqry
  • xtrdb

Program xretractor tworzy proces wykonujący jeden niezależny plan systemu RetractorDB. Na hoście może działać wiele nazwanych instancji, każda z własnym obszarem pamięci współdzielonej. Program xqry tworzy procesy komunikujące się z wybraną instancją, a wspólna magistrala umożliwia ich wykrywanie i routing. Program xtrdb służy do analizy danych i metadanych zapisywanych w plikach bazy danych.

Poniżej przedstawiona jest na Rys. 12 schematycznie architektura systemu RetractorDB. Uwzględniono wszystkie istniejące aktualnie komponenty. Obszary ujęte w prostokątach z nagłówkami wypełnionymi poleceniami systemowymi odpowiadają istniejącym komponentom. Obszar zapisu artefaktów to symboliczna reprezentacja systemu plików.

Rys. 12 Schemat przepływu danych pomiędzy procesami RetractorDB

Na Rys. 12 widzimy procesy realizowane przez programy xretractor, xtrdb oraz xqry. Rysunek przedstawia jedną instancję wykonawczą; przy uruchomieniu wieloserwerowym blok xretractor wraz z jego IPC i klientami powtarza się dla każdej nazwy. Relacje pomiędzy instancjami opisano w rozdziale Wiele instancji i magistrala.

Proces xretractor komunikuje się z procesami xqry poprzez własny, nazwany obszar pamięci współdzielonej. W tej pamięci dla każdej subskrypcji xqry tworzona jest kolejka danych. Dane są odbierane na bieżąco przez procesy xqry. Zadaniem procesów xqry jest wysyłka danych dalej do innych systemów lub procesów. Jeśli proces xqry ginie lub jest kończony, właściwa instancja xretractor zwalnia zasoby dedykowane temu klientowi.

Oprócz kierowania danych do wysyłki poprzez pamięć współdzieloną, system RetractorDB zapisuje dane do tzw. Obszaru zapisu artefaktów. Aktualnie jest to katalog do którego zapisywane są na bieżąco efekty procesu przetwarzania strumieni danych w oparciu o plany realizacji zapytań realizowane w systemie RetractorDB.

⚠️ Ostrzeżenie

Przedstawiona na rysunku Baza danych to nie jest Relacyjna baza danych. Przez bazę danych na przedstawionym rysunku rozumiemy zbiór plików binarnych lub tekstowych, którymi zarządza RetractorDB. Dane pobierane są z urządzeń i zapisywane w rotujących lub nie plikach binarnych lub tekstowych. Dostęp do tych danych realizowany jest za pomocą narzędzia xtrdb lub w trakcie działania systemu przez proces xqry.

Plik z zapytaniami i dyrektywami RQL podaje się jako pierwszy argument polecenia uruchamiającego system. Argument ten jest opcjonalny: wywołanie xretractor bez pliku zapytań uruchamia tryb bezczynny (idle) — proces wstaje, zajmuje blokadę usługi, otwiera kanał IPC i czeka, nie budując planu ani siatki czasu. Dzięki temu jednostka systemd może wstać razem z systemem operacyjnym, zanim operator dostarczy zestaw zapytań. Wyjątkiem jest tryb --onlycompile, gdzie brak pliku pozostaje błędem — nie ma czego kompilować.

Zestaw zapytań można dostarczyć później trzema drogami. xqry -a dodaje pojedyncze SELECT, DECLARE albo RULE do aktywnego planu. xqry --reset plik.rql atomowo zastępuje cały plan bez restartu procesu i może uruchomić pierwszą epokę instancji bezczynnej. Uruchomienie xretractor plik.rql przy działającej usłudze może natomiast zweryfikować plik, zapisać go jako plan startowy i zrestartować jednostkę systemd. Pełny zestaw wraz z dyrektywami :STORAGE, :SUBSTRAT i :ROTATION przyjmują dwie ostatnie drogi.

NOTE: Tryb idle ma pokrycie w teście service_idle (warianty z flagą --service i ze zmienną XRETRACTOR_SERVICE).

Wiele instancji i magistrala

Na jednym hoście może działać równocześnie wiele procesów xretractor. Każda instancja ma własną nazwę, blokadę, obszar IPC, plan i klientów. Wspólna magistrala xrdbbus rejestruje żywe instancje, umożliwia ich wyszukiwanie przez xqry i pilnuje, aby dwa plany nie przejęły zasobów, których nie mogą bezpiecznie współdzielić.

Rys. 13. Równolegle działające instancje i wspólna magistrala xrdbbus

Na Rys. 13 każda instancja kompiluje własny plan i zgłasza własny zestaw nazw strumieni w slocie magistrali; ponumerowane węzły zastępują tam nazwy, bo istotne jest tylko to, że nie powtarzają się one między instancjami. Rozłączne są nazwy obiektów, nie obszar pamięci: segment magistrali i obiekty IPC wszystkich instancji leżą w tym samym /dev/shm, a odróżnia je sufiks nazwy instancji — dla instancji alfa są to kolejka poleceń RetractorQueryQueue.alfa, segment odpowiedzi RetractorShmemMap.alfa, muteks mapy RetractorMapMutex.alfa i kolejka odpowiedzi klienta brcdbr.alfa.<pid>. Wspólny jest również katalog magazynu, w którym pliki poszczególnych instancji pozostają rozłączne.

Wyjątkiem jest tryb usługowy: w domyślnej przestrzeni hosta może działać dokładnie jedna instancja oznaczona jako usługa. Domyślnie otrzymuje ona stałą nazwę service, dzięki czemu skrypty mogą kierować polecenia do --server service bez wcześniejszego przeglądania magistrali.

Tożsamość instancji

Instancję można wskazać na trzy sposoby:

MechanizmZnaczenie
xretractor --name pomiary plan.rqlStała nazwa podana przez operatora.
xretractor --autoname plan.rqlLosowa nazwa w stylu nazw kontenerów; jest wypisywana przy starcie.
server.autoname = trueAutomatyczna nazwa ustawiona w pliku TOML, o ile nie podano --name.

Jawne --name ma pierwszeństwo przed konfiguracją. Nazwa musi pasować do [a-z][a-z0-9_-]* i może mieć najwyżej 32 znaki. --name i --autoname wzajemnie się wykluczają.

Brak nazwy zachowuje historyczną tożsamość: nazwy obiektów IPC i pliku blokady nie mają sufiksu. Taka instancja również pojawia się w magistrali, jako (unnamed), i uczestniczy w kontroli kolizji.

Zmienna RDB_NAMESPACE tworzy osobną przestrzeń nazw instancji, magistrali i IPC oraz, jeżeli nie podano --name ani --autoname, staje się domyślną nazwą serwera i celem xqry. Jest używana przede wszystkim przez równoległe testy integracyjne. Jawne opcje --name, --autoname i --server pozostają nadrzędne. Również limit jednej usługi jest egzekwowany osobno w każdej takiej przestrzeni.

Zasoby prywatne i wspólne

Nazwane instancje mają rozłączne obiekty Boost.Interprocess. Nazwy bazowe kolejki poleceń, segmentu odpowiedzi i muteksu otrzymują sufiks instancji, a kolejka subskrybenta zawiera również PID klienta. Zatrzymanie jednej instancji usuwa wyłącznie jej IPC i kończy tylko jej subskrypcje.

Magistrala jest wspólna dla hosta lub przestrzeni RDB_NAMESPACE. Każdy żywy serwer publikuje w niej nazwę, PID, tryby pracy, plik planu i nazwy strumieni. Slot jest uznawany za żywy tylko wtedy, gdy PID oraz czas startu zgadzają się z /proc; proces zombie nie blokuje zasobów.

Przed uruchomieniem albo wymianą planu magistrala sprawdza rozłączność:

  • nazw wszystkich strumieni, również wygenerowanych przez kompilator i dodanych ad hoc;
  • znormalizowanych ścieżek zapisywanych plików magazynu;
  • pliku licznika dyrektywy :ROTATION.

Roszczenie następuje przed usuwaniem starych artefaktów i zakładaniem IPC. Przegrana instancja nie może więc skasować danych działającego właściciela. Komunikat odmowy podaje kolidujący zasób, nazwę instancji i jej PID.

Przy xqry --reset zasoby nowego planu są najpierw rezerwowane. Dopiero po poprawnym zbudowaniu nowej epoki rezerwacja atomowo zastępuje aktywny zestaw. Błąd parsowania, kompilacji, limitu lub kolizja pozostawia dotychczasowy plan i jego roszczenia bez zmian.

⚠️ Ostrzeżenie

Niedostępność lub uszkodzenie magistrali nie zatrzymuje pojedynczego serwera. Start jest dopuszczany z ostrzeżeniem, ale globalna ochrona przed kolizjami nie jest wtedy egzekwowana. To tryb awaryjny, a nie poprawna konfiguracja wieloserwerowa.

Routing poleceń xqry

xqry rozstrzyga cel na podstawie jednej migawki magistrali, bez odpytywania kolejnych serwerów i bez czekania na ich timeouty.

SytuacjaWynik
Podano --server nazwaWskazana instancja jest używana bez automatycznego routingu.
Działa dokładnie jedna instancjaKlient wybiera ją automatycznie.
Kilka instancji, --select lub --detailWybierany jest właściciel strumienia.
Kilka instancji, ad hoc SELECTWszystkie źródła muszą należeć do jednej instancji.
Kilka instancji, ad hoc RULECelem jest właściciel strumienia z klauzuli ON.
Kilka instancji, ad hoc DECLARETrzeba podać --server, bo deklaracja nie ma właściciela wejścia.
Kilka instancji, polecenie całej instancji--hello, --dir, --kill i --reset wymagają --server.

Zapytanie ad hoc nie może łączyć źródeł z różnych instancji. RetractorDB nie przesyła strumieni pomiędzy serwerami; plany są niezależnymi grafami wykonania.

Przegląd magistrali

Polecenie xqry --bus nie kontaktuje się z żadnym serwerem. Pokazuje nazwę, PID, tryb, plik zapytań i strumienie każdej żywej instancji. Modyfikator --yaml daje dokument apiVersion: xqry/v1, dogodny do przetwarzania przez skrypty.

Kolumna MODE może zawierać kilka liter:

LiteraTryb
Nzwykłe wykonanie taktowane zegarem
R--realtime
F--no-clock
U--until-eof
Mustawiony limit --llimitqry
X--xqrywait
Stryb usługowy lub jednostka systemd

Przykładowa sesja:

xretractor alfa.rql --name alfa --noanykey &
xretractor beta.rql --name beta --noanykey &

xqry --bus
xqry --select temperatura          # routing według właściciela strumienia
xqry --server alfa --dir           # jawne polecenie całej instancji
xqry --server beta --kill          # zatrzymuje tylko instancję beta

Przepływ danych i sterowania

Dane i sterowanie w systemie RetractorDB tworzą kilka potencjalnych sposobów użycia komponentów systemu. Na Rys. 14 przedstawiono schematycznie przepływ danych pomiędzy procesami systemu RetractorDB, procesami systemu Linux oraz danymi źródłowymi i rezultatami pracy poszczególnych procesów.

Najgrubsze linie przedstawiają przepływ, który występuje zawsze w procesie przetwarzania regularnych serii czasowych. Po otrzymaniu pliku .rql proces xretractor kompiluje go, buduje drzewo planu i rozpoczyna przetwarzanie napływających danych oraz tworzenie plików binarnych zawierających artefakty. Bez pliku może uruchomić się w stanie bezczynnym i czekać na pełny plan przesłany przez xqry --reset.

NOTE: Opisana funkcjonalność ma pokrycie w teście: consistency opisanym w załączniku pt. Testy Integracyjne.

Aby móc sterować procesem xretractor po wystartowaniu używamy procesu xqry. Za jego pomocą możemy zatrzymać proces xretractor, pobrać statystyki lub zażądać dostępu do danych bieżących.

Reszta strzałek prezentuje przepływy danych zależne od prowadzonego z użyciem RetractorDB procesu. Strzałki przerywane są typowo przeznaczone do celów diagnostycznych.

Każdy z procesów na schemacie został oznaczony dodatkowo liczbą utrzymywanych ciągłych procesów w systemie. Historyczne oznaczenie „1” przy xretractor opisuje jedną instancję planu przedstawioną na rysunku, a nie współczesny limit całego hosta. Nazwane instancje mogą działać równocześnie; w domyślnej przestrzeni hosta dokładnie jedna może pracować jako usługa. Magistrala xrdbbus egzekwuje rozłączność ich zasobów. Program xtrdb nie utrzymuje ciągłego procesu: czyta dane, zwraca wynik i kończy pracę, ewentualnie działa interaktywnie. Proces xqry oznaczony jest jako „N”, ponieważ do każdej instancji xretractor może być podłączonych wielu klientów.

Rys. 14. Przepływ danych i sterowania

Zatrzymanie xretractor

Proces xretractor obsługuje sygnały systemowe i kończy pracę w kontrolowany sposób po otrzymaniu:

SygnałPolecenieZnaczenie
SIGINTCtrl+C w terminaluprzerwanie interaktywne
SIGTERMkill <pid>standardowe zakończenie procesu
SIGHUPkill -HUP <pid>zakończenie przy zamknięciu terminala

Wszystkie trzy sygnały powodują ten sam efekt: graceful shutdown — pętla przetwarzania kończy bieżący cykl i zatrzymuje się. Pozwala to bezpiecznie zamknąć xretractor działającego jako usługa bez ryzyka uszkodzenia plików artefaktów.

Zatrzymanie przez xqry

Obok sygnałów systemowych xretractor można zatrzymać programowo — za pomocą polecenia:

xqry --server nazwa --kill

Jak przebiega zamknięcie krok po kroku

1. xqry wysyła żądanie „kill“

Proces xqry rozstrzyga instancję z opcji --server albo z magistrali, buduje komunikat IPC i umieszcza go w jej kolejce poleceń. Nazwa bazowa RetractorQueryQueue otrzymuje sufiks nazwanej instancji. Wiadomość zawiera identyfikator procesu xqry (PID) i polecenie kill.

2. xretractor odbiera polecenie i ustawia flagę zatrzymania

Wątek komunikacyjny obiektu IpcServer wybranej instancji stale nasłuchuje na swojej kolejce. Dyspozytor executorsm::commandProcessor po odebraniu komunikatu kill ustawia atomowy licznik iLoopLimitCnt na wartość stop_now i budzi pętlę wykonawczą. Ten sam mechanizm jest używany przez obsługę sygnałów systemowych — niezależnie od źródła efekt jest identyczny dla tej jednej instancji.

3. Główna pętla przetwarzania wykrywa flagę i kończy bieżący cykl

Pętla główna sprawdza iLoopLimitCnt przy każdej iteracji. Gdy wykryje wartość stop_now, kończy bieżący cykl i wychodzi z pętli — bez przerywania w połowie obliczeń. Zapewnia to integralność zapisywanych artefaktów.

4. xretractor powiadamia wszystkich podłączonych klientów (broadcast OOB)

Po wyjściu z pętli xretractor wywołuje IpcServer::broadcastOutOfBusiness(). Obiekt IPC przegląda rejestr subskrypcji, w którym polecenie show zapisało PID klienta i nazwę strumienia. Dla każdego zarejestrowanego klienta wysyła do jego dedykowanej kolejki komunikat specjalny o wartości OUT_OF_BUSSINESS.

5. Każdy klient xqry odbiera sygnał zakończenia i kończy działanie

Każda subskrypcja xqry ma własną kolejkę zawierającą nazwę serwera i PID klienta. Po odebraniu komunikatu OUT_OF_BUSSINESS xqry ustawia wewnętrzną flagę done i kończy działanie w kontrolowany sposób — niezależnie od tego, ile danych zdążył odebrać.

6. Sprzątanie zasobów IPC

Na zakończenie xretractor usuwa własny segment odpowiedzi, kolejkę poleceń, muteks i kolejki swoich klientów, zwalnia plik blokady oraz slot w magistrali. Zasoby innych instancji pozostają nietknięte.

Błąd krytyczny i sprzątanie awaryjne

Błąd krytyczny podczas startu albo w wątku komunikacyjnym przechodzi przez tę samą końcową politykę własności zasobów, ale nie próbuje kontynuować cyklu. Dziennik spdlog jest opróżniany, a nie niszczony przed procedurami atexit. Jeżeli błąd powstał w samym wątku komunikacyjnym, sprzątanie odłącza ten wątek zamiast próbować dołączyć go do niego samego. Następnie usuwa kolejki i pamięć IPC, a blokadę usługi zwalnia jako ostatnią.

Proces kończy się statusem 1. Dzięki temu kolejny start nie zastaje osieroconych zasobów ani blokady, a błąd pierwotny nie jest maskowany wtórnym SIGSEGV lub SIGABRT podczas zamykania.

NOTE: Obie ścieżki — błąd podczas startu i błąd zgłoszony z wątku komunikacyjnego — sprawdza test fatal_exit_path.

Co się dzieje przy wielu procesach xqry

RetractorDB jest zaprojektowany do pracy z wieloma równoległymi klientami. Gdy w systemie działają jednocześnie — powiedzmy — trzy procesy xqry subskrybujące różne strumienie, a jeden z nich wywoła xqry --kill:

  • wskazany xretractor przetworzy żądanie kill jednorazowo, niezależnie od tego, który klient je wysłał,
  • mechanizm IpcServer::broadcastOutOfBusiness() roześle komunikat OUT_OF_BUSSINESS do wszystkich zarejestrowanych klientów tej instancji,
  • każdy z trzech procesów xqry otrzyma sygnał zakończenia i zakończy działanie samodzielnie,
  • klienci, którzy nie subskrybowali żadnego strumienia (np. xqry wywołany tylko z --dir lub --hello), nie są wpisani do mapy i nie muszą być powiadamiani — te polecenia kończą działanie natychmiast po udzieleniu odpowiedzi.

Klienci podłączeni do innych nazwanych instancji nie otrzymują tego komunikatu i pracują dalej.

Warto zwrócić uwagę, że xqry wykrywa również nieaktywność serwera: jeżeli przez 10 sekund nie napłyną żadne dane, klient sam się wyłącza z ostrzeżeniem w logu. Jest to zabezpieczenie na wypadek nagłej awarii xretractor bez możliwości rozesłania komunikatu OOB.

Artefakty, Substraty, Efemerydy

Z racji faktu, że system przeznaczony jest do pracy ciągłej i teoretycznie otrzymywane wyniki bez prowadzenia procesu retencji danych zapełniłby każdy nośnik danych wprowadzamy dodatkowe definicje związane z charakterem przetwarzanych danych.

Przedstawiając opis Rys. 14 wspominano o artefaktach. Jest to jedna z definicji wymagających wyjaśnienia.

✅ Uwaga

Definicja (Artefakt): Przez artefakty rozumiemy dane przetwarzane w systemie w postaci strumieni, które docelowo zostają zmaterializowane jako utrwalony wynik i efekt przetwarzania innych danych.

Ciągłe serie czasowe możemy czytać z urządzeń, następnie je przetwarzać – redukować lub dopasowywać rozmiar danych w czasie i wymiarze. Ale z reguły pewne dane powinny zostać zapisane. Czy te dane potem będą podlegać retencji – jest sprawą drugorzędną. Takie dane, które stanowią efekt i oczekiwaną odpowiedź systemu będziemy nazywać artefaktami. Czymś co oczekujemy i materializujemy dla potrzeb użytkownika końcowego.

✅ Uwaga

Definicja (Substrat): Substraty to obiekty pośrednie. W wyniku przetwarzania serii czasowych mogą powstać strumienie danych, które są ulotne. Potrzebne jedynie do i w trakcie przetwarzania.

Ich rozmiar może być znaczący biorąc pod uwagę jak daleko cofamy się wstecz w przypadku np. konieczności zrzutów danych monitorowania z przeszłości. Jednak ich istnienie jest bez znaczenia w aspekcie pożądanych wyników działania systemu. Takie strumienie danych nazywamy substratami. Pojawiają się w wyniku działania systemu, nie występują z reguły jawnie w zapytaniach – ale wynikają z procesu przetwarzania serii czasowych, jednak ich wyniki są niezbędne do realizacji zadania.

✅ Uwaga

Definicja (Efemeryd): Efemerydy to obiekty, w oparciu o które tworzymy źródłowe strumienie danych, danych których nie można zmagazynować. Są to z reguły dane ulotne, efemeryczne.

System czyta np. liczby przypadkowe z odpowiednią częstotliwością i to właśnie źródło danych dostarcza danych ulotnych. Nie można ich zwrócić, przechowywanie ich z reguły mija się z celem – należy je przekazać do dalszego przetwarzania w celu wytworzenia artefaktów lub substratów a następnie zniszczyć i pobrać, nowe aktualne.

Format zapisu danych

W systemie przetwarzane są serie czasowe w trzech postaciach: artefaktów, efemerydów i substratów. Każdy typ ma inne przeznaczenie i inną strategię przechowywania.

Substraty i Artefakty - formalnie niczym nie różnią się w systemie. Jedyna różnica to fakt, że substraty zostały wygenerowane w oparciu o równiania algebry strumieni danych i nie zostały zapisane bezpośrednio w ciągu poleceń dla kompilatora. Jeśli zadeklarujemy strumień Artefaktu, który pokryje postać substratu - substrat zostanie zredukowany. Efemerydy to strumienie, które powstały za pomocą polecenia Declare - zawierają wartości które istnieją tylko przez chwilkę.

Typy akcesorów składowania

NOTE: Opisana funkcjonalność ma pokrycie w teście: txtsrc opisanym w załączniku pt. Testy Integracyjne.

Pole TYPE w deskryptorze (lub dyrektywa STORAGE w RQL) wybiera implementację FileInterface:

Typ (TYPE_PROFILE)Klasa implementacjiZastosowanie
DEFAULTgroupFile<posixBinaryFileWithShadow>Artefakty domyślne — plik danych + plik cienia, z retencją
DIRECTgroupFile<posixBinaryFile>Zapis bezpośredni bez cienia, z retencją
POSIXposixBinaryFileSurowy zapis POSIX bez cienia
POSIXSHDposixBinaryFileWithShadowPOSIX z plikiem cienia
MEMORYmemoryFileSkładowanie wyłącznie w RAM (efemerydy)
GENERICgenericBinaryFileOgólny akcesor binarny
DEVICEbinaryDeviceROZewnętrzne urządzenie binarnych danych wejściowych (tylko odczyt)
TEXTSOURCEtextSourceROTekstowe źródło danych wejściowych (tylko odczyt)

Zestaw plików artefaktu i substratu

Artefakty i substraty zapisywane na dysk mogą być skojarzone z maksymalnie pięcioma plikami:

PlikRozszerzenieCel
Plik danych binarnych(nazwa strumienia)Główny strumień rekordów — append-only
Plik deskryptora.descSchemat rekordu (pola, typy, rozmiary, typ składowania)
Plik metadanych.metaIndeks wartości null i przerw w transmisji (RLE)
Plik cienia danych.shadowModyfikacje rekordów bez nadpisywania danych oryginalnych
Plik cienia indeksu.meta.shadowNadpisania wzorców null towarzyszące .shadow
%% pdf-width: 70%
graph TD
  D[".desc: deskryptor (schemat rekordu)"]
  B["Plik danych binarnych (rekordy N×R bajtów)"]
  M[".meta: metadane (indeks null i przerw)"]
  S[".shadow: plik cienia danych (modyfikacje rekordów)"]
  MS[".meta.shadow: cień indeksu (nadpisania null)"]

    D -->|"opisuje strukturę"| B
    B -->|"towarzyszący indeks"| M
    B -->|"opcjonalne nadpisania"| S
    S -.->|"para spójności"| MS
    M -->|"nadpisania wzorców"| MS

    style S fill:#f9c,color:#000
    style MS fill:#f9c,color:#000
    style M fill:#cdf,color:#000

Rys. 15. Zestaw plików artefaktu i ich powiązania

Diagram na Rys. 15 przedstawia statyczną relację między plikami artefaktu: .desc definiuje strukturę rekordu, .meta indeksuje null i przerwy, .shadow przechowuje opcjonalne nadpisania rekordów, a .meta.shadow — odpowiadające im nadpisania wzorców null. Dwa pliki cienia zawsze idą w parze.

Pliki cienia i plik metadanych są opcjonalne. Przy ciągłym napływie danych bez przerw i bez modyfikacji wystarczy sam plik danych binarnych i deskryptor.

Efemerydy nie mają własnego pliku danych — ich źródłem jest obiekt zewnętrzny (plik tekstowy, urządzenie), którego system nie tworzy ani nie usuwa. Powstaje dla nich natomiast deskryptor .desc opisujący schemat odczytu. Indeks .meta nie powstaje: dla źródeł deklarowanych wstrzykiwany jest inertny wariant indeksu metadanych, działający wyłącznie w pamięci.


Rozdziały

Pliki

Rozdział opisuje pięć plików tworzących kompletny zestaw artefaktu lub substratu: deskryptor schematu (.desc), główny plik danych binarnych, indeks metadanych (.meta), plik cienia danych (.shadow) i plik cienia indeksu (.meta.shadow). Dla każdego pliku przedstawiono format binarny, semantykę pól oraz reguły zapisu i odczytu. Rozdział obejmuje też klasę metaData — mechanizm kompresji RLE, obsługę przerw w transmisji, interfejs aktualizacji i persystencję po restarcie. Sekcja końcowa pokazuje relacje między wszystkimi plikami na poziomie operacji append, update i read.

Zakres rozdziału nie obejmuje mechanizmu rotacji plików między sesjami (→ Rotacja) ani narzędzia inspekcji xtrdb -s (→ Narzędzie inspekcji).


Plik deskryptora (.desc)

Plik .desc opisuje strukturę rekordu. Jest parsowany przez gramatykę ANTLR4 (DESC.g4) i może zawierać pola danych, metainformację o typie składowania oraz politykę retencji.

Składnia

{ <polecenie>* }

Każde polecenie to jedno z poniższych:

BYTE     nazwa [N]          # tablica N bajtów (domyślnie N=1)
INTEGER  nazwa [N]          # 32-bitowe liczby całkowite ze znakiem
UINT     nazwa [N]          # 32-bitowe bez znaku
FLOAT    nazwa [N]          # 32-bitowe zmiennoprzecinkowe (IEEE 754)
DOUBLE   nazwa [N]          # 64-bitowe zmiennoprzecinkowe
RATIONAL nazwa [N]          # para int32: licznik i mianownik
STRING   nazwa [rozmiar]    # ciąg znaków o stałej długości
REF      "ścieżka/plik"     # referencja do zewnętrznego pliku deskryptora
TYPE     identyfikator      # typ składowania (DEFAULT, MEMORY, POSIXSHD, …)
RETENTION pojemność segment # retencja cykliczna na dysku
RETMEMORY pojemność         # retencja cykliczna w pamięci

Przykłady plików .desc

Artefakt domyślny — dwa pola numeryczne, składowanie DEFAULT (plik danych + plik cienia):

{
  INTEGER  ts
  FLOAT    value
  TYPE     DEFAULT
}

Efemeryd — strumień ulotny wyłącznie w RAM:

{
  DOUBLE   x
  DOUBLE   y
  TYPE     MEMORY
}

Substrat z retencją — cykliczny bufor ostatnich 1000 rekordów na dysku (10 segmentów po 100):

{
  INTEGER  ts
  FLOAT    a
  FLOAT    b
  TYPE     DEFAULT
  RETENTION 1000 100
}

Deklaracja źródła binarnego (DECLARE w RQL generuje ten schemat):

{
  INTEGER  a
  FLOAT    b
  TYPE     DEVICE
  REF      "sensor/data.bin"
}

Rozmiary typów pól

TypRozmiar pojedynczej wartości
BYTE1 B
INTEGER4 B
UINT4 B
FLOAT4 B
DOUBLE8 B
RATIONAL8 B (dwa int32)
STRINGN B (deklarowany rozmiar)

Dla pól tablicowych nazwa[N] całkowity rozmiar = rozmiar_typu × N. Pola TYPE, REF, RETENTION i RETMEMORY nie zajmują miejsca w rekordzie — są metadanymi deskryptora.

Rozmiar rekordu R = suma rozmiarów wszystkich pól danych.

Układ pola RATIONAL

Pole RATIONAL przechowuje liczbę wymierną jako parę liczb całkowitych ze znakiem, zapisaną w rekordzie wprost, bez nagłówka i bez znacznika typu:

offset +0   int32   licznik
offset +4   int32   mianownik

Kolejność bajtów jest natywna dla maszyny — na x86-64 i ARM64 little-endian, tak samo jak dla pól INTEGER i UINT. Pole zajmuje 8 bajtów; dla pola tablicowego RATIONAL nazwa[N] pary leżą jedna za drugą, 8 × N bajtów.

Wartość jest zawsze zapisana w postaci nieskracalnej, a mianownik jest zawsze dodatni — znak liczby niesie wyłącznie licznik. Wynika to z arytmetyki boost::rational, która normalizuje wynik przy każdym przypisaniu, a nie z konwencji zapisu. W szczególności:

  • zero zapisuje się jako 0/1, nigdy jako 0/0 ani 0/5;
  • liczba całkowita zapisuje się jako n/1 — pole RATIONAL o mianowniku 1 to dokładnie liczba całkowita, bez zaokrągleń (na tym niezmienniku opiera się też test podzielności slotu w algorytmie przeglądu drzewa zapytań);
  • mianownik nigdy nie jest zerem, więc czytelnik nie musi tego przypadku obsługiwać.

Pola typu RATIONAL produkują reduktory MIN, MAX, AVG i SUMC, gdy wartość wejściowa ma typ BYTE, INTEGER, UINT albo RATIONAL. Dotyczy to reduktorów bieżącego rekordu w FROM, wygaszanej notacji .min/.max/.avg/.sumc oraz agregatów historii AGG(wyrażenie : W) w liście SELECT. Wejście FLOAT lub DOUBLE zachowuje własny typ (→ Operatory agregujące). Reduktor nad wejściem całkowitym lub wymiernym jest w praktyce głównym źródłem typu RATIONAL w artefakcie.

Przykład zmierzony

Plan liczący średnią z okna trzech próbek:

DECLARE v INTEGER STREAM src, 1 FILE 'data.txt'

SELECT * STREAM ravg FROM AVG(src@(1,3))

dla wejścia -3, -3, -2, 7, 7, 7, … daje deskryptor { RATIONAL avg } i plik danych, w którym pierwszy rekord ma osiem bajtów:

f8 ff ff ff   03 00 00 00
└─ licznik ─┘ └ mianownik ┘
   -8            3            →  -8/3

Czwarty rekord to 07 00 00 00 01 00 00 00, czyli 7/1 — średnia z trzech siódemek zapisana jako liczba wymierna o mianowniku 1, a nie jako INTEGER.

Odczyt bez rozbierania bajtów

Układ pary trzeba znać tylko przy czytaniu pliku binarnego wprost. Sam fakt, że pole jest typu RATIONAL i zajmuje 8 bajtów, wypisuje xtrdb -s nazwa z deskryptora (→ Narzędzie inspekcji) — narzędzie pokazuje strukturę, nie wartości. Wartość odczytuje się natomiast, przepuszczając pole przez konwersję już w zapytaniu; trzy funkcje dają trzy różne kompromisy:

Zapis w SELECTWynik dla -8/3Uwaga
to_string(pole : N)napis -8/3 w polu STRING[N]postać dokładna; liczba całkowita wychodzi jako 7/1, nie 7
to_double(pole)pole DOUBLE o wartości -2.6666…przybliżenie, ale bez utraty znaku i rzędu wielkości
to_integer(pole)pole INTEGER o wartości -2obcięcie w stronę zera, nie podłoga — → Wyrażenia pól i funkcje skalarne

Do eksportu do systemów tekstowych właściwe jest to_string, bo zachowuje wartość dokładnie; to_integer jest wygodne, ale gubi część ułamkową i robi to inaczej, niż podłoguje Python — opis zaokrąglenia jest przy funkcjach wyrażeń.

Pole TYPE a strategia składowania

Pole TYPE w deskryptorze bezpośrednio wyznacza, który akcesor (FileInterface) zostanie użyty przez storage::initializeAccessor(). Brak pola TYPE jest równoznaczny z DEFAULT. Wartość jest nieczuła na wielkość liter (MEMORY = memory).


Plik danych binarnych

Plik danych to sekwencja rekordów o stałej długości, zapisywanych jeden po drugim bez żadnego nagłówka. Rozmiar pojedynczego rekordu R wyznaczany jest przez deskryptor jako suma bajtów wszystkich pól.

Offset w plikuZawartośćRozmiar
0Rekord 0R bajtów
RRekord 1R bajtów
2RRekord 2R bajtów
(N-1) × RRekord N-1R bajtów

Każdy rekord zawiera upakowane wartości pól w kolejności zdefiniowanej przez deskryptor:

Offset w rekordziePoleRozmiar
0pole_0len_0 bajtów
len_0pole_1len_1 bajtów
len_0 + len_1
len_0 + len_1 + … + len_npole_nlen_n bajtów

Operacja append (dodanie nowego rekordu) dopisuje dane na koniec pliku. Operacja update (modyfikacja istniejącego rekordu) — jeśli istnieje plik cienia — trafia do pliku cienia, a nie do pliku głównego.

Przykład

DECLARE a INTEGER, b FLOAT STREAM str1, 0.1 FILE 'data.dat'

Rozmiar rekordu: INTEGER (4 B) + FLOAT (4 B) = 8 bajtów. Po 5 sekundach napływu danych (10 Hz) plik data.dat ma rozmiar 5 × 10 × 8 = 400 bajtów.


Plik metadanych (.meta)

Plik .meta to indeks wartości null i przerw w transmisji. Przechowuje informację o tym, które pola rekordów mają wartość null i gdzie wystąpiły przerwy — bez duplikowania samych danych.

Format pliku

PozycjaZawartośćRozmiar
Nagłówekpole zarezerwowane (int64, zawsze 0)8 bajtów
Wpis RLE 0gapFlag | count | bitsetSize | bitsetzmienny
Wpis RLE 1gapFlag | count | bitsetSize | bitsetzmienny
Wpis RLE kwpis bieżący (w pamięci)zmienny

Format wpisu RLE

Każdy wpis opisuje ciąg kolejnych rekordów z identycznym wzorcem null:

PoleRozmiarOpis
gapFlag1 B0 = normalny rekord, 1 = przerwa
recordCount8 B (size_t)liczba rekordów w ciągu
bitsetSize8 B (size_t)liczba pól (N)
bitset⌈N/8⌉ Bbit i = pole i ma wartość null

Kompresja RLE

Kolejne rekordy z tym samym wzorcem null są scalane w jeden wpis przez zwiększenie recordCount. Nowy wpis tworzony jest dopiero gdy wzorzec się zmienia.

10 rekordów, 2 pola, bez null:

WpisisGapcountbitset
wpis 0F10[F,F]

Null w polu 1 od rekordu 5:

WpisisGapcountbitset
wpis 0F5[F,F]
wpis 1F5[F,T]

Przerwa w transmisji po rekordzie 3:

WpisisGapcountbitset
wpis 0F3[F,F]
wpis 1T7[T,T]
wpis 2F[F,F]

Marker przerwy w transmisji (gap)

Przerwa w transmisji (np. wyłączenie systemu, zanik sygnału) rejestrowana jest jako wpis z isGap=true i wszystkimi bitami null ustawionymi na true. Parametr count przechowuje długość przerwy w jednostkach interwału strumienia. Sam plik danych binarnych nie zawiera żadnych dodatkowych rekordów dla przerwy — informacja żyje wyłącznie w pliku .meta.

NOTE: Opisana funkcjonalność ma pokrycie w testach: issue113_meta_internal, issue113_meta_autocreate opisanych w załączniku pt. Testy Integracyjne.


Klasa metaData

Plikiem .meta zarządza klasa rdb::metaData. Jest ona koordynatorem: sama utrzymuje politykę RLE (leniwe nadpisanie ostatniego wpisu, numeracja rekordów), a wyspecjalizowane fragmenty deleguje do osobnych jednostek:

JednostkaNagłówekRola
IndexRecordindexRecord.hppformat pojedynczego wpisu i jego (de)serializacja
MetaIndexStoremetaIndexStore.hppsurowe I/O pliku .meta — nagłówek, wpisy zatwierdzone, cache
GapDetectorgapDetector.hppmaszyna stanów wykrywania przerw (nullfill, absorpcja, przerwa oczekująca)
splitSegment(), sumNonGapRecords()rleSegment.hppoperacje na segmentach RLE
storageShadowstorageShadow.hppwariant kierujący aktualizacje do cienia indeksu (.meta.shadow)

Sama metaData hermetyzuje trzy obszary odpowiedzialności:

  1. Agregację RLE w pamięci — buforuje bieżący segment (ostatnią serię rekordów z identycznym wzorcem null) w polu currentEntry_, nie zapisując go do pliku przy każdym rekordzie.
  2. Trwałość danych — wyłącznie zakończone segmenty (gdy wzorzec się zmienia lub gdy nastąpi jawne wywołanie flushCurrentEntry()) trafiają do pliku jako wpisy zatwierdzone (committed).
  3. Indeks zapytań — udostępnia interfejs do odpytywania wzorca null dla dowolnego rekordu oraz wykrywania przerw w transmisji.

Klasa przechowuje dwa stany:

StanLokalizacjaOpis
Zatwierdzone segmentyplik .meta na dyskuwszystkie zakończone przebiegi RLE
Segment bieżący (currentEntry_)pamięć operacyjnaaktualnie akumulowany przebieg (jeszcze niezapisany lub do nadpisania)

Cykl życia obiektu

Diagram stanów (Rys. 16) przedstawia przejścia między fazami obiektu metaData:

%% pdf-width: 30%
stateDiagram-v2
    [*] --> Budowa : konstruktor
    Budowa --> Aktywny : loadIndex()
    Aktywny --> Aktywny : onRecordAppended()
    Aktywny --> Aktywny : onRecordModified()
    Aktywny --> Aktywny : onTransmissionGap()
    Aktywny --> Aktywny : flushCurrentEntry()
    Aktywny --> [*] : destruktor (auto flush)

Rys. 16. Cykl życia obiektu metaData

Konstruktor (metaData(descriptor, path)):

  • Inicjalizuje pusty currentEntry_ na podstawie liczby pól deskryptora.
  • Wywołuje loadIndex() — jeżeli plik istnieje, wczytuje wszystkie zatwierdzone segmenty, wyznacza committedRecordCount_, a ostatni niegapowy segment przenosi z powrotem do currentEntry_ (umożliwia kontynuację serii RLE po restarcie).
  • Jeżeli plik nie istnieje, tworzy go i zapisuje nagłówek (8 bajtów zarezerwowanych, zera).

Destruktor automatycznie wywołuje flushCurrentEntry(), gwarantując, że bieżący bufor trafi na dysk nawet gdy program zakończy pracę w normalnym trybie.

Interfejs aktualizacji

Klasa wyróżnia trzy scenariusze zmiany stanu metadanych:

onRecordAppended(nullBitset)

Wywoływany przez storage po każdym dołączeniu nowego rekordu do pliku danych.

wzorzec identyczny z currentEntry_?
├─ TAK → zwiększ currentEntry_.recordCount (akumulacja RLE, brak I/O)
└─ NIE → flushCurrentEntry() (poprzedni segment na dysk)
          ustaw currentEntry_ = {nullBitset, count=1}

Operacja I/O następuje wyłącznie przy zmianie wzorca — dla serii identycznych rekordów koszt to jedna inkrementacja licznika w pamięci.

onRecordModified(index, nullBitset)

Wywoływany przez storage przy aktualizacji istniejącego rekordu. Zachowanie zależy od trybu pracy:

Tryb normalny (brak pliku cienia danych): lokalizuje rekord w segmentach RLE i rozbija segment na maksymalnie trzy części: przed modyfikowanym rekordem, sam rekord, za nim.

rekord w currentEntry_ (pamięć)?
├─ TAK → splitSegment() w pamięci, nowe fragmenty dołączone do pliku
└─ NIE → wczytaj plik, splitSegment(), przepisz plik (rewriteFile)

Przykład rozbicia segmentu [allNull × 5] przy modyfikacji rekordu 2:

Przed:  [allNull × 5]
Po:     [allNull × 2] [allPresent × 1] [allNull × 2]

Wariant z cieniem (storageShadow, wstrzykiwany zamiast bazowego metaData dla magazynów utrzymujących plik .shadow): zamiast modyfikować główny indeks, dopisuje jedno nadpisanie wzorca null do pliku .meta.shadow. Główny indeks .meta pozostaje nienaruszony i spójny z głównym plikiem danych.

obiekt indeksu to storageShadow?
├─ TAK → metaShadow::appendOverride(index, nullBitset) → wpis w .meta.shadow
└─ NIE → modyfikacja głównego indeksu → splitSegment()

onTransmissionGap(duration)

Rejestruje przerwę w transmisji o podanej długości (w jednostkach interwału strumienia). Najpierw zatwierdza bieżący segment (flushCurrentEntry()), następnie dołącza do pliku wpis z isGap=true (Rys. 17).

sequenceDiagram
    participant S as storage
    participant M as metaData
    participant F as plik .meta

    S->>M: onTransmissionGap(5)
    M->>F: flushCurrentEntry() — zapisz [normalny, count=N]
    M->>F: appendEntry(isGap=true, count=5)
    Note over F: plik zawiera teraz marker przerwy

Rys. 17. Sekwencja rejestracji przerwy — onTransmissionGap

Mechanizm bezpieczeństwa: flushCurrentEntry() i nadpisywanie (tail_.dirty)

Klasa storage wywołuje flushCurrentEntry() po każdym wywołaniu write(), aby zagwarantować przeżycie awarii procesu. Naiwna implementacja dopisywałaby nowy wpis do pliku przy każdym flushu — powodując wzrost pliku proporcjonalny do liczby rekordów, nawet bez zmian wzorca null.

Rozwiązanie: mechanizm lazy overwrite oznaczany flagą tail_.dirty.

flushCurrentEntry() → zapis [wzorzec, count=2] na dysk
onRecordAppended(ten sam wzorzec):
    currentEntry_.count = 2 (przywrócony z dysku)
    tail_.markDirty()        ← następny flush nadpisze, nie doda
    currentEntry_.count++    → count = 3
flushCurrentEntry() → seek na ostatni wpis, overwrite [wzorzec, count=3]
    (rozmiar pliku bez zmian)

Diagram sekwencji dla typowego wzorca storage (append + flush po każdym rekordzie) ilustruje Rys. 18:

sequenceDiagram
    participant S as storage
    participant M as metaData
    participant F as plik .meta

    S->>M: onRecordAppended([F,F])
    S->>M: flushCurrentEntry()
    M->>F: appendEntry([F,F], count=1)

    S->>M: onRecordAppended([F,F])
    S->>M: flushCurrentEntry()
    Note over M: tail_.dirty=true, overwrite last entry
    M->>F: overwrite last entry: [F,F] count=2

    S->>M: onRecordAppended([F,F])
    S->>M: flushCurrentEntry()
    M->>F: overwrite last entry: [F,F] count=3

    S->>M: onRecordAppended([T,F])
    Note over M: inny wzorzec → nowy wpis
    S->>M: flushCurrentEntry()
    M->>F: appendEntry([T,F], count=1)

Rys. 18. Mechanizm lazy overwrite — nadpisywanie ostatniego wpisu .meta

Dzięki temu plik .meta rośnie wyłącznie przy zmianie wzorca null — nie przy każdym rekordzie. Przy ciągłym napływie jednorodnych danych plik ma stały rozmiar niezależnie od liczby rekordów.

Persystencja i odtwarzanie stanu

Po restarcie procesu nowy obiekt metaData wczytuje plik przez loadIndex() (sekwencja na Rys. 19):

  1. Pomija nagłówek — 8 bajtów zarezerwowanych; nic z nich nie jest interpretowane.
  2. Wczytuje wszystkie zatwierdzone wpisy z pliku.
  3. Jeżeli ostatni wpis nie jest gap-em — przenosi go z powrotem do currentEntry_ i usuwa z pliku (umożliwia kontynuację RLE po restarcie bez duplikacji).
  4. Wyznacza committedRecordCount_ jako sumę recordCount wszystkich niegalowych wpisów pozostałych w pliku.
sequenceDiagram
    participant Proc1 as Pierwsza sesja
    participant F as plik .meta
    participant Proc2 as Druga sesja

    Proc1->>F: zapisuje segmenty [A×500][B×200]
    Note over Proc1: destruktor → flushCurrentEntry()
    Proc1->>F: ostatni segment zatwierdzony

    Proc2->>F: loadIndex()
    F-->>Proc2: odczyt wszystkich segmentów
    Note over Proc2: ostatni segment przeniesiony do currentEntry_
    Note over Proc2: gotowość do kontynuacji RLE
    Proc2->>Proc2: totalRecords() = 700

Rys. 19. Persystencja i odtwarzanie stanu po restarcie

Interfejs zapytań

MetodaOpis
getNullBitset(i)Zwraca wzorzec null dla rekordu i. Metoda wirtualna: w wariancie storageShadow najpierw sprawdza nadpisania w metaShadow (od końca — ostatnie wygrywa), a dopiero przy braku wpisu sięga do głównego indeksu.
nullBitsetFor(i)Jak wyżej, ale dla rekordu spoza zakresu indeksu zwraca wzorzec „nic nie jest null“ zamiast rzucać wyjątkiem. Pozwala storage::read() nakładać metadane null bez kontroli zakresu.
isGapBefore(i)Zwraca true, jeżeli bezpośrednio przed rekordem i w indeksie RLE znajduje się wpis isGap=true. Rekord 0 nigdy nie ma przerwy przed sobą.
segments()Zwraca wszystkie segmenty RLE: zatwierdzone (z dysku) oraz bieżący (z pamięci), jeżeli jest niepusty. Nie obejmuje nadpisań z .meta.shadow. Służy do inspekcji i testów.
totalRecords()Suma rekordów we wszystkich segmentach (committed + pending).
isEmpty()Skrót: totalRecords() == 0.
rotate(percounter)Rotuje plik indeksu: przemianowuje bieżący plik .meta na .meta.old<N>, tworzy nowy pusty plik. Wywoływana przez storage::detectStartupState() po wykryciu rotacji pliku danych (plik danych pusty, indeks niepusty). Gdy percounter < 0, plik nie jest przemianowywany — wykonywany jest tylko reset indeksu.
reset()Czyści indeks w miejscu: zeruje liczniki, przepisuje plik z samym nagłówkiem bez zmiany jego nazwy. Wywołuje też discardShadow(). Wywoływany przez storage przy czyszczeniu bez zachowania historii (np. po purge()).

Interfejs cienia indeksu

Metody klasy storageShadow — wariantu indeksu wstrzykiwanego przez makeMetaIndex() dla magazynów utrzymujących plik cienia danych. Bazowy metaData ich nie ma; nie ma też przełącznika trybu, bo o obecności cienia decyduje wybór klasy przy inicjalizacji magazynu.

MetodaOpis
konstruktorWczytuje istniejące nadpisania z pliku .meta.shadow (metaShadow::load()), przywracając stan cienia po restarcie procesu.
mergeShadow()Scala nadpisania z cienia do głównego indeksu (aplikuje każde nadpisanie w kolejności zapisu — ostatnie wygrywa), a następnie usuwa plik .meta.shadow. Odpowiednik merge() dla pliku cienia danych.
discardShadow()Czyści listę nadpisań w pamięci i usuwa plik .meta.shadow. Wywoływany przy odrzuceniu cienia danych (purge, reset, rotacja).
metaShadowFilePath(p)Statyczna: zwraca ścieżkę pliku cienia indeksu odpowiadającą danemu plikowi .meta, bez tworzenia obiektu. Używana przez storage przy porządkowaniu zasobów.

Przykład użycia — typowy scenariusz produkcyjny

storage.write(rec0)           → onRecordAppended([F,F,F]) + flushCurrentEntry()
storage.write(rec1)           → onRecordAppended([F,F,F]) + flushCurrentEntry()
storage.write(rec2_val_null)  → onRecordAppended([T,F,F]) + flushCurrentEntry()
storage.write(rec3)           → onRecordAppended([F,F,F]) + flushCurrentEntry()

Plik .meta po powyższych operacjach (4 flushe, 2 segmenty):
  [isGap=F, count=2, bitset=[F,F,F]]   ← wpis 0
  [isGap=F, count=1, bitset=[T,F,F]]   ← wpis 1  (rec2)
  [isGap=F, count=1, bitset=[F,F,F]]   ← wpis 2  (rec3, bieżący w pamięci)

getNullBitset(2) → [T,F,F]   (pole 0 rekordu 2 jest null)
isGapBefore(2)  → false
totalRecords()  → 4

Plik cienia (.shadow)

Plik cienia umożliwia modyfikację zarejestrowanych rekordów bez niszczenia danych oryginalnych. Usunięcie pliku .shadow przywraca oryginalny stan danych.

Format wpisu

PoleRozmiarOpis
position8 B (size_t)indeks rekordu w pliku głównym
dataR bajtównowe wartości rekordu

Każda modyfikacja dopisuje nowy wpis na koniec pliku cienia. Przy wielu modyfikacjach tego samego rekordu plik może zawierać wiele wpisów dla tej samej pozycji — aktualny jest ostatni.

Priorytety odczytu

Priorytety odczytu to reguła rozstrzygania, z którego źródła system ma zwrócić wartość rekordu, gdy ten sam indeks może występować jednocześnie w pliku głównym i w pliku cienia. W RetractorDB priorytet definiowany jest deterministycznie: najpierw sprawdzany jest .shadow (od końca, aby wybrać najnowszą modyfikację), a dopiero przy braku wpisu wykonywany jest odczyt z pliku głównego. Pojęcie to dotyczy aspektu spójności i wersjonowania odczytu danych po modyfikacjach, a nie samego fizycznego formatu zapisu rekordu w pliku binarnym.

%% pdf-width: 100%
flowchart LR
    Q["Odczyt rekordu na pozycji P"]
    Q --> SH{"Szukaj P w .shadow\n(od końca)"}
    SH -->|znaleziono| RET1["Zwróć dane z .shadow\n(najnowsza modyfikacja)"]
    SH -->|nie znaleziono| MAIN["Odczyt z pliku głównego\npread(fd, pos=P×R)"]
    MAIN --> RET2["Zwróć dane oryginalne"]

Rys. 20. Priorytety odczytu rekordu z pliku cienia

Rys. 20 przedstawia logikę odczytu rekordu: system najpierw sprawdza wpis w .shadow, a dopiero przy jego braku odczytuje rekord z pliku głównego.

Scalanie (merge)

Operacja merge() scala zmiany z pliku cienia do pliku głównego i zeruje plik cienia. Po scaleniu dane oryginalne są bezpowrotnie nadpisane.

sequenceDiagram
    participant App
    participant Shadow as .shadow
    participant Main as plik główny

    App->>Shadow: odczyt wszystkich wpisów (i, data_i)
    loop dla każdego wpisu
        Shadow-->>App: (position=i, data=data_i)
        App->>Main: pwrite(data_i, offset=i×R)
    end
    App->>Shadow: ftruncate(0) — wyczyść plik cienia

Rys. 21. Scalanie pliku cienia z plikiem głównym

Rys. 21 przedstawia przebieg merge(): kolejne wpisy (position, data) z .shadow są zapisywane do pliku głównego, a po zakończeniu plik cienia jest czyszczony.

Przykład: modyfikacja rekordu

# Strumień str1: 2 pola INTEGER (4B każde), recordSize = 8B
# Rekord 2 (oryginał): [100, 200]
# Modyfikacja: pole 0 → 999

# Plik .shadow po modyfikacji:
# offset 0: [position=2 (8B)][999, 200 (8B)]

Odczyt rekordu 2 zwróci [999, 200]. Odczyt rekordu 0 i 1 zwróci dane z pliku głównego (nie ma ich w shadow).


Plik cienia indeksu (.meta.shadow)

Plik .meta.shadow jest odpowiednikiem .shadow na poziomie indeksu null. Rejestruje nadpisania wzorców null dla poszczególnych rekordów bez modyfikowania głównego pliku .meta, zachowując spójność pary: plik główny ↔ .meta oraz plik cienia ↔ .meta.shadow.

Kiedy powstaje

Plik .meta.shadow jest tworzony automatycznie, gdy spełnione są dwa warunki:

  1. Magazyn jest typu DEFAULT lub POSIXSHD — czyli taki, który trzyma modyfikacje rekordów w pliku .shadow (nie w pliku głównym).
  2. W danej sesji wykonana zostanie przynajmniej jedna modyfikacja istniejącego rekordu (storage::write() na indeks inny niż maksymalny).

Warunek 1 nie jest przełącznikiem trybu, lecz wyborem klasy. Przy inicjalizacji magazynu fabryka makeMetaIndex() (accessorFactory.hpp) pyta akcesor o hasShadow() i zwraca:

WarunekZwracany obiektZachowanie
źródło deklarowane (DECLARE)metaData z pustą ścieżkąwariant inertny — indeks działa w pamięci, nic nie trafia na dysk
akcesor ma plik cienia danychstorageShadowonRecordModified() kieruje nadpisania do metaShadow (.meta.shadow)
pozostałemetaDatamodyfikacje przepisują główny indeks .meta

storageShadow dziedziczy po metaData i nadpisuje wirtualne onRecordModified(), getNullBitset() oraz reset(); samym plikiem .meta.shadow zarządza jego pole typu metaShadow. Dzięki temu storage nie zawiera żadnego rozgałęzienia na tryb cienia.

Format pliku

Plik .meta.shadow nie ma nagłówka. Jest sekwencją wpisów w tym samym formacie binarnym co wpisy w pliku .meta, z tą różnicą, że pole recordCount przechowuje bezwzględny indeks rekordu (nie liczbę rekordów w serii RLE):

PoleRozmiarZnaczenie w .meta.shadow
gapFlag1 Bzawsze 0 (nadpisania nie są przerwami)
recordCount8 B (size_t)bezwzględny indeks nadpisywanego rekordu
bitsetSize8 B (size_t)liczba pól deskryptora (N)
bitset⌈N/8⌉ Bnowy wzorzec null dla tego rekordu

Każde wywołanie onRecordModified() w trybie cienia dopisuje jeden wpis na koniec pliku. Wiele wpisów dla tej samej pozycji jest dozwolone — obowiązuje ostatni wpis (semantyka „last-write-wins“, zgodna z plikiem .shadow).

Priorytety odczytu

storageShadow::getNullBitset(i) skanuje listę nadpisań od końca. Jeżeli znajdzie wpis dla indeksu i, zwraca jego wzorzec null bez sięgania do głównego indeksu (Rys. 22):

flowchart TD
    Q["getNullBitset(i)"]
    Q --> SM{"obiekt to storageShadow?"}
    SM -->|tak| SCAN{"metaShadow::lookup(i)\n(od końca): wpis dla i?"}
    SCAN -->|znaleziono| RET1["Zwróć nullBitset z nadpisania\n(najnowsze wygrywa)"]
    SCAN -->|nie znaleziono| MAIN["Wyszukaj w głównym indeksie\n(segmenty RLE na dysku)"]
    SM -->|nie| MAIN
    MAIN --> RET2["Zwróć wzorzec z .meta"]

Rys. 22. Priorytety odczytu wzorca null — główny indeks vs. cień indeksu

Cykl życia

Plik .meta.shadow jest zarządzany równolegle z plikiem cienia danych:

Zdarzenie na pliku .shadowAkcja na .meta.shadow
Pierwsza modyfikacja rekorduTworzenie pliku; dołączenie pierwszego wpisu
Kolejne modyfikacjeDołączanie kolejnych wpisów
merge() — scalenie cienia z plikiem głównymmergeShadow() — nadpisania aplikowane do .meta; plik usuwany
purge() / reset() — odrzucenie cieniadiscardShadow() — plik usuwany bez scalania
Restart procesukonstruktor storageShadowmetaShadow::load() — plik odczytywany; nadpisania przywrócone w pamięci
Usunięcie tymczasowego magazynu (destruktor)Plik .meta.shadow usuwany razem z .meta

Persystencja po restarcie

Po restarcie procesu nowy obiekt storageShadow przywraca stan cienia już w konstruktorze, przez metaShadow::load() (Rys. 23):

  1. Odczytuje wszystkie wpisy z .meta.shadow (brak nagłówka — format bezpośredni).
  2. Ładuje je do listy nadpisań w kolejności zapisu.
  3. getNullBitset() i kolejne onRecordModified() działają tak samo jak przed restartem.
%% pdf-width: 100%
sequenceDiagram
    participant Proc1 as Pierwsza sesja
    participant MS as .meta.shadow
    participant Meta as .meta

    Proc1->>Meta: onRecordAppended([F,F,F]) × 5
    Proc1->>MS: onRecordModified(2, [T,T,T]) → dołącz wpis (index=2)
    Note over Meta: .meta bez zmian [allNull×5]
    Note over MS: .meta.shadow: [(index=2, [T,T,T])]

    Note over Proc1: restart

    participant Proc2 as Druga sesja
    Proc2->>MS: konstruktor storageShadow → metaShadow::load()
    MS-->>Proc2: [(index=2, [T,T,T])]
    Note over Proc2: getNullBitset(2) → [T,T,T]
    Proc2->>Meta: mergeShadow() → applyModificationToMainIndex(2, [T,T,T])
    Proc2->>MS: usuń plik .meta.shadow

Rys. 23. Cień indeksu — odtwarzanie wzorców null po restarcie

Przykład użycia — korekta rekordu z zachowaniem spójności

# 5 rekordów w strumieniu str1, 3 pola FLOAT
# Rekord 2 ma wartość null w polu 0: nullBitset=[T,F,F]
# Operator koryguje pole 0 rekordu 2 → zmiana wzorca na [F,F,F]

# Operacje:
storage.write(rec2_corrected, pos=2)
  → .shadow: dołącz (position=2, data_corrected)
  → storageShadow.onRecordModified(2, [F,F,F])
    → .meta.shadow: dołącz (index=2, [F,F,F])

# Stan plików:
# .meta        — bez zmian: [isGap=F, count=2, [F,F,F]], 
# >> [isGap=F, count=1, [T,F,F]], [isGap=F, count=2, [F,F,F]]
# .meta.shadow — nowy wpis: [gapFlag=0, recordCount=2, bitset=[F,F,F]]

# Odczyt:
getNullBitset(2) → [F,F,F]  (z .meta.shadow)
getNullBitset(1) → [F,F,F]  (z .meta)

# Po scaleniu:
storage.merge() → .shadow wchłonięty do pliku głównego
storageShadow.mergeShadow() → .meta przebudowany, .meta.shadow usunięty
# .meta po merge: [isGap=F, count=5, [F,F,F]]  (wszystkie rekordy pełne)

NOTE: Mechanizm .meta.shadow jest testowany w testach jednostkowych ut_metaShadow_usage (format i cykl życia pliku cienia) oraz ut_storageShadow_usage (integracja z metaData i akcesorem).


Relacja pomiędzy plikami

W tej części relacje między plikami są pokazane na dwóch poziomach. Poziom strukturalny opisuje, że plik danych jest nośnikiem rekordów, deskryptor .desc definiuje ich format, plik .meta przechowuje informację o wartościach null i przerwach transmisji, .shadow gromadzi modyfikacje danych bez niszczenia oryginału, a .meta.shadow gromadzi analogicznie nadpisania wzorców null. Poziom operacyjny (Rys. 24) pokazuje przebieg odczytu i zapisu: odczyt najpierw sprawdza .shadow i .meta.shadow, a dopiero przy braku wpisu sięga do pliku głównego i .meta. Operacje append, update i read utrzymują w ten sposób spójność danych i metadanych w całym cyklu życia artefaktu. Scalenie merge() / mergeShadow() nie należy do tego przebiegu: to osobna operacja API, której silnik nie wywołuje samodzielnie (Rys. 21).

%% pdf-width: 100%
graph LR
    UP["update<br/>write(N), N < count"] -->|dopisz| CIEN
    AP["append<br/>write(N), N ≥ count"] -->|dopisz na koniec| GL

    subgraph CIEN["warstwa cienia"]
        direction TB
        S[".shadow<br/>(N·size, data)"]
        MS[".meta.shadow<br/>(N, nullBitset)"]
    end

    subgraph GL["warstwa główna"]
        direction TB
        D["plik główny<br/>data"]
        M[".meta<br/>nullBitset"]
    end

    CIEN ==>|"1. jest wpis N"| RD["read(pos=N)<br/>data + nullBitset"]
    GL -->|"2. brak wpisu N"| RD

Rys. 24. Relacja pomiędzy operacjami zapisu, modyfikacji i odczytu artefaktu (typy DEFAULT i POSIXSHD)

Rys. 24 przedstawia przepływ operacji append, update i read przez warstwę storage oraz ich bezpośredni wpływ na plik danych, .meta, .shadow i .meta.shadow. O rodzaju zapisu decyduje indeks rekordu: N równy lub większy od liczby rekordów to append, mniejszy to update. Wpis w .shadow jest kluczowany przesunięciem w bajtach (N·size, przy retencji liczonym względem segmentu), wpis w .meta.shadow indeksem rekordu N. Rekord złożony wyłącznie z wartości null poza fazą nullfill nie trafia do pliku głównego — zostaje po nim wpis przerwy w .meta. Warstwa cienia istnieje tylko dla typów DEFAULT i POSIXSHD; w pozostałych typach (POSIX, DIRECT, GENERIC, MEMORY) update nadpisuje rekord bezpośrednio w pliku głównym i w .meta.

Punkt wyjścia — plik binarny bez metadanych

Najprostszy możliwy zapis serii czasowej to sekwencja surowych wartości w pliku binarnym: stały rozmiar rekordu, brak nagłówka, brak opisu struktury. Takie podejście ma jedną zaletę — minimalny narzut — i szereg istotnych ograniczeń:

  • Interpretacja danych wymaga wiedzy zewnętrznej wobec pliku (nazwy pól, typy, kolejność).
  • Brak informacji o przerwach w transmisji — ciągłość danych jest pozorna.
  • Każda modyfikacja historycznego rekordu niszczy dane oryginalne nieodwracalnie.
  • Zmiana struktury rekordu unieważnia cały plik.

RetractorDB rejestruje dane z czujników działających w czasie rzeczywistym, gdzie przerwy zasilania, zaniki sygnału i konieczność retrospektywnej korekty danych są normalnym zjawiskiem eksploatacyjnym, nie wyjątkiem. Struktura czterech plików odpowiada bezpośrednio na każde z tych ograniczeń.

Co wnosi każdy plik

Deskryptor (.desc) — samoopisywalność i niezależność od kodu

Plik danych binarnych jest bezużyteczny bez znajomości struktury rekordu. Deskryptor przechowuje tę wiedzę obok danych, co oznacza:

  • Dane można odczytać i zinterpretować bez dostępu do kodu źródłowego ani konfiguracji — wystarczy plik .desc.
  • Narzędzie xtrdb może analizować dowolny artefakt bez dodatkowych parametrów.
  • Zmiana struktury strumienia (dodanie pola, zmiana typu) jest jawna i wersjonowalna.
  • Pole TYPE w deskryptorze decyduje o strategii składowania, co pozwala temu samemu silnikowi obsługiwać trwałe artefakty, ulotne efemerydy i zewnętrzne źródła danych bez zmiany logiki zapytań.

Plik metadanych (.meta) — wiarygodność serii czasowej

Seria czasowa z dziurami, traktowana jako ciągła, prowadzi do błędnych obliczeń okien czasowych, błędnych agregacji i fałszywych korelacji. Plik .meta zapewnia:

  • Odróżnienie rekordu z wartością zero od rekordu nieobecnego (null) — semantycznie zupełnie różnych stanów.
  • Rejestrację przerw w transmisji bez wstawiania fikcyjnych rekordów do pliku danych — plik binarny pozostaje gęsty i adresowalny pozycyjnie.
  • Kompresję RLE — typowe serie czasowe mają długie okresy bez null, więc koszt metadanych jest bliski zeru dla danych dobrej jakości.
  • Możliwość odtworzenia dokładnego harmonogramu rejestracji, w tym długości przerw, co jest niezbędne przy obliczaniu interwałów w algebrze strumieni.

Plik cienia (.shadow) — niedestruktywna korekta danych

W systemach pomiarowych korekta błędnych próbek po fakcie jest standardową procedurą. Nadpisanie pliku binarnego jest nieodwracalne i usuwa dowód oryginalnego pomiaru. Plik cienia:

  • Pozwala skorygować dowolny historyczny rekord bez modyfikacji pliku głównego.
  • Zachowuje oryginalny pomiar jako domyślny — usunięcie pliku .shadow w pełni przywraca stan wyjściowy.
  • Umożliwia scalenie (merge) korekt do pliku głównego wtedy, gdy jest to świadoma decyzja operatora, nie skutek uboczny zapisu.
  • Separuje dane certyfikowane (plik główny) od danych roboczych (plik cienia), co ma znaczenie w zastosowaniach wymagających audytowalności.

Plik cienia indeksu (.meta.shadow) — spójność metadanych przy korekcie

Korekta rekordu w pliku cienia danych musi znaleźć odzwierciedlenie w indeksie null — inaczej getNullBitset() zwróciłoby przestarzały wzorzec z głównego .meta. Plik .meta.shadow:

  • Utrzymuje spójność między parami: plik główny ↔ .meta oraz .shadow ↔ .meta.shadow.
  • Pozwala getNullBitset() zwrócić aktualny wzorzec null dla skorygowanego rekordu bez modyfikowania głównego indeksu.
  • Śledzi cykl życia pliku cienia danych — scalany i usuwany dokładnie razem z .shadow.
  • Umożliwia pełne odtworzenie stanu po restarcie: nadpisania załadowane z .meta.shadow są natychmiast dostępne bez ponownego skanowania pliku cienia danych.

Mechanizm rotacji plików

Przez rotację plików rozumiemy kontrolowane zamykanie bieżącego zestawu plików danych i metadanych oraz przeniesienie ich do wersji historycznych (.old<N>), tak aby nowa sesja mogła rozpocząć zapis od czystego stanu bez utraty wcześniejszych pomiarów. Stosuje się to po to, aby oddzielić kolejne sesje akwizycji, zachować pełną ścieżkę audytu i ułatwić diagnostykę problemów w czasie. Celem rotacji jest jednocześnie utrzymanie porządku operacyjnego (aktualny zestaw roboczy + archiwum sesji) oraz zapewnienie możliwości odtworzenia i porównania danych historycznych.

NOTE: Opisana funkcjonalność ma pokrycie w testach: rotation_test, retention opisanych w załączniku pt. Testy Integracyjne.

Domyślne zachowanie (bez dyrektywy ROTATION)

Bez dyrektywy ROTATION w skrypcie RQL, xretractor przy każdym starcie usuwa pliki artefaktów (dane binarne, .desc, .meta) i zaczyna rejestrację od nowa.

Rotacja i usuwanie nie dotyczą efemerydów (DECLARE). Nie znaczy to, że efemeryda nie ma żadnego pliku: jej źródło danych (plik tekstowy, urządzenie) jest zewnętrzne wobec systemu i nietykalne, a obok niego powstaje deskryptor .desc opisujący schemat odczytu — storage::attachDescriptor() zapisuje go dla każdego strumienia, także deklarowanego. Efemeryda nie dostaje natomiast indeksu .meta: fabryka makeMetaIndex() wstrzykuje dla źródeł deklarowanych wariant inertny (metaData z pustą ścieżką pliku), który utrzymuje wzorce null w pamięci i nie wykonuje żadnego I/O. Jest to więc brak persystencji metadanych, nie brak samego obiektu indeksu.

Dyrektywa ROTATION i licznik sesji

Dyrektywa ROTATION włącza tryb zachowania historii. Przyjmuje ścieżkę do pliku przechowującego trwały licznik sesji:

ROTATION rdb_counter

Obiekt PersistentCounter wczytuje wartość N z pliku przy starcie (getCount() = N) i zapisuje N+1 przy zamknięciu. Licznik rośnie monotonicznie z każdą sesją xretractor.

Przepływ sterowania w procesie rotacji

W tym punkcie chcemy pokazać pełną sekwencję życia plików podczas jednej sesji i przejścia do kolejnej. Diagram (Rys. 25) ma wyjaśnić kolejność zdarzeń: wykrycie rotacji przy starcie, utworzenie nowego indeksu .meta, normalny zapis danych w trakcie pracy oraz archiwizację plików przy zamknięciu procesu. Kluczowy przekaz jest taki, że rotacja nie jest pojedynczą operacją, lecz procesem rozłożonym w czasie, który łączy moment startu i stopu sesji.

%% pdf-width: 100%
sequenceDiagram
    participant RQL as xretractor
    participant D as plik danych
    participant M as plik .meta
    participant Old as pliki .old*

    Note over RQL: start sesji N, percounter = N
    RQL->>D: detectStartupState(): dane puste, meta niepusta → rotacja
    RQL->>Old: metaData::rotate(N): rename .meta → .meta.oldN
    RQL->>M: nowy pusty plik .meta

    Note over RQL: praca — zapis rekordów
    RQL->>D: dopisuje rekordy
    RQL->>M: aktualizuje indeks RLE

    Note over RQL: stop (Ctrl+C / SIGTERM)
    RQL->>Old: ~posixBinaryFile: rename → (name).oldN
    RQL->>Old: ~posixBinaryFile: rename → (name).shadow.oldN (jeśli istnieje)
    Note over RQL: PersistentCounter zapisuje N+1 do pliku

Rys. 25. Sekwencja rotacji plików — start i stop sesji

Rotacja pliku .meta następuje przy starcie sesji N — detectStartupState() wykrywa niezgodność (plik danych pusty, indeks niepusty ze starej sesji) i wywołuje metaData::rotate(N). Plik danych binarnych jest przemianowywany dopiero przy zamknięciu sesji przez destruktor posixBinaryFile.

Co trafia do plików .old<N>

PlikKiedy powstaje
<name>.oldNZamknięcie sesji N — destruktor posixBinaryFile przemianowuje plik danych
<name>.shadow.oldNZamknięcie sesji N — destruktor posixBinaryFileWithShadow przemianowuje plik cienia
<name>.meta.oldNStart sesji N — detectStartupState() wykrywa rotację i przemianowuje .meta pozostawiony przez sesję N−1

Wskutek tej kolejności: plik .meta.oldN zawiera metadane null dla danych z sesji N−1, podczas gdy plik .oldN zawiera dane sesji N. W sekcji ROTATED FILES narzędzia xtrdb -s pliki są grupowane według numeru suffiksu — pary .oldN i .meta.oldN różnią się więc o 1 w stosunku do sesji, której fizycznie odpowiadają.

Przykład sekwencji trzech sesji

Po trzech zakończonych sesjach (0, 1, 2) i w trakcie czwartej (3):

pomiar.old0         ← dane z sesji 0 (zapis sesji 0, przemianowanie
                      w destruktorze sesji 0)
pomiar.meta.old1    ← metadane z sesji 0 (przemianowanie przy starcie sesji 1)
pomiar.old1         ← dane z sesji 1
pomiar.meta.old2    ← metadane z sesji 1 (przemianowanie przy starcie sesji 2)
pomiar.old2         ← dane z sesji 2
pomiar.meta.old3    ← metadane z sesji 2 (przemianowanie przy starcie sesji 3)
pomiar              ← dane bieżące (sesja 3)
pomiar.meta         ← metadane bieżące (sesja 3)

Widok xtrdb -s w trakcie sesji 3:

$ xtrdb -s pomiar
...
├──────────────────────────────────────────────────────────────┤
│  ROTATED FILES                                               │
│  [3] pomiar.meta.old3                                   26 B │
│  [2] pomiar.old2                                       800 B │
│      pomiar.meta.old2                                   26 B │
│  [1] pomiar.old1                                       800 B │
│      pomiar.meta.old1                                   26 B │
│  [0] pomiar.old0                                       400 B │
└──────────────────────────────────────────────────────────────┘

Plik pomiar.meta.old3 jest w grupie [3] sam — odpowiadający mu plik pomiar.old3 powstanie dopiero przy zamknięciu bieżącej sesji.

Otwieranie pliku rotowanego w xtrdb

Pliki rotowane można analizować poleceniem open w trybie interaktywnym xtrdb. Polecenie open automatycznie wyciąga nazwę bazową (usuwa .old<N>) i szuka deskryptora <nazwa_bazowa>.desc:

$ xtrdb
. open pomiar.old1
ok
. print
...

Narzędzie inspekcji: xtrdb -s

Polecenie xtrdb -s <ścieżka> wyświetla kompletny obraz stanu składowania artefaktu — bez otwierania procesu xretractor, bez wchodzenia w tryb interaktywny. Wystarczy wskazać ścieżkę bazową (bez rozszerzenia), a narzędzie samo znajdzie powiązane pliki: .desc, dane binarne, .meta, .shadow, segmenty cykliczne i pliki rotowane.

NOTE: Opisana funkcjonalność ma pokrycie w teście: issue153_storagemap_meta_cases opisanym w załączniku pt. Testy Integracyjne.

Cel i zastosowanie

SytuacjaCo daje xtrdb -s
Diagnoza po awariiWidać od razu, czy plik danych jest spójny z metadanymi — różne liczby rekordów sygnalizują problem
Weryfikacja retencjiSekcja DATA TOTAL pokazuje podział na segmenty i aktualny stopień wypełnienia bufora cyklicznego
Kontrola modyfikacjiSekcja SHADOW ujawnia liczbę niezatwierdzonych zmian — Updates: N to liczba wpisów w .shadow; w normalnej pracy to stan docelowy, bo silnik nie wywołuje merge() samodzielnie
Analiza jakości danychPasek META z symbolami =, -, ~, X pokazuje wzorzec null i przerwy bez parsowania pliku binarnego
Audyt historii rotacjiSekcja ROTATED FILES wymienia stare wersje pliku po kolejnych rotacjach

Polecenie jest tylko do odczytu — nie modyfikuje żadnego pliku. Można je uruchamiać również gdy xretractor nie działa.

Co pokazuje mapa

Cały raport otoczony jest ramką z znaków pseudograficznych. Górna część to trzyelementowa mapa poglądowa:

┌──────────────────────────────────────────────────────────────┐
│   Storage map: <nazwa>                                       │
├──────────────────────────────────────────────────────────────┤
│ [shadow]   │ [binary data] │ [meta index]                    │
├────────────┼───────────────┼─────────────────────────────────┤
│ ...        │ ...           │ ...                             │
├────────────┴───────────────┴─────────────────────────────────┤
│   SEKCJA  ...                                                │
└──────────────────────────────────────────────────────────────┘

Każdy wiersz mapy odpowiada jednemu segmentowi RLE lub segmentowi danych:

KolumnaZawartość
[shadow]Dla artefaktu bez retencji: liczba niezapisanych modyfikacji (N updates). Dla retencji segmentowej: etykieta segmentu sN z liczbą modyfikacji.
[binary data]Zakres indeksów rekordów w pliku binarnym (begin-end) lub etykieta segmentu sN begin-end. Wiersze z przerwą w transmisji (gap) mają puste pole.
[meta index]Opis segmentu RLE z pliku .meta: liczba rekordów i wzorzec null w formie [====].

Poniżej mapy następują kolejne sekcje:

SekcjaOpis
DESCRIPTORŚcieżka i rozmiar pliku .desc, lista pól z typami i rozmiarami, rozmiar rekordu w bajtach.
DATALiczba rekordów, ścieżka do pliku danych. Przy retencji (RETENTION): podział na segmenty, polityka (liczba segmentów i pojemność), maksymalny dopuszczalny rozmiar bufora, lista plików _segment_*.
METALiczba segmentów RLE i rekordów w indeksie, graficzny pasek obrazujący wzorzec null w czasie.
SHADOWŚcieżka i rozmiar pliku cienia oraz liczba niezatwierdzonych modyfikacji.
ROTATED FILESPliki z poprzednich rotacji (.old1, .old2, …) wraz z rozmiarami.

Legenda paska META

[====] — dane bez wartości null
[----] — częściowe null (przynajmniej jedno pole ma wartość null)
[~~~~] — wszystkie pola mają wartość null (nullfill)
[XXXX] — przerwa w transmisji (gap)

Przykład 1 — artefakt prosty

Strumień pomiar z dwoma polami, 100 rekordów, bez modyfikacji, bez przerw:

{
  INTEGER  ts
  FLOAT    value
  TYPE     DEFAULT
}
$ xtrdb -s pomiar
┌──────────────────────────────────────────────────────────────┐
│   Storage map: pomiar                                        │
├──────────────────────────────────────────────────────────────┤
│ [shadow]   │ [binary data] │ [meta index]                    │
├────────────┼───────────────┼─────────────────────────────────┤
│            │ 0-100         │ [====] 100 records, no nulls    │
├────────────┴───────────────┴─────────────────────────────────┤
│   DESCRIPTOR  pomiar.desc                               43 B │
│   INTEGER  ts                                            4 B │
│   FLOAT  value                                           4 B │
│   Record size:                                           8 B │
├──────────────────────────────────────────────────────────────┤
│   DATA        pomiar                                   800 B │
│   Records: 100                                               │
├──────────────────────────────────────────────────────────────┤
│   META        pomiar.meta                               26 B │
│   Segments: 1   Records: 100                                 │
│   [==========================100===========================] │
│   Legend: [====] data  [----] partial null                   │
│           [~~~~] nullfill  [XXXX] gap                        │
├──────────────────────────────────────────────────────────────┤
│   SHADOW      pomiar.shadow (missing)                    0 B │
└──────────────────────────────────────────────────────────────┘

Interpretacja: jeden segment RLE, brak przerw, brak null, plik cienia nieobecny. Plik binarny ma dokładnie 100 × 8 = 800 bajtów.


Przykład 2 — artefakt z przerwą w transmisji i modyfikacją

Strumień czujnik z trzema polami. Po 50 rekordach nastąpiła przerwa (10 jednostek interwału), następnie napłynęło 30 rekordów z częściowymi brakami w polu pressure. Dwa rekordy zostały później zmodyfikowane (plik cienia obecny):

{
  INTEGER  ts
  FLOAT    temp
  FLOAT    pressure
  TYPE     DEFAULT
}
$ xtrdb -s czujnik
┌──────────────────────────────────────────────────────────────┐
│   Storage map: czujnik                                       │
├──────────────────────────────────────────────────────────────┤
│ [shadow]   │ [binary data] │ [meta index]                    │
├────────────┼───────────────┼─────────────────────────────────┤
│            │ 0-50          │ [====] 50 records, no nulls     │
│            │               │ [XXXX] 10 records, gap          │
│ 2 updates  │ 50-80         │ [----] 30 records, some nulls   │
├────────────┴───────────────┴─────────────────────────────────┤
│   DESCRIPTOR  czujnik.desc                              52 B │
│   INTEGER  ts                                            4 B │
│   FLOAT  temp                                            4 B │
│   FLOAT  pressure                                        4 B │
│   Record size:                                          12 B │
├──────────────────────────────────────────────────────────────┤
│   DATA        czujnik                                  960 B │
│   Records: 80                                                │
├──────────────────────────────────────────────────────────────┤
│   META        czujnik.meta                              60 B │
│   Segments: 3   Records: 80                                  │
│   [===========50===========][XXgap:10XX][-------30---------] │
│   Legend: [====] data  [----] partial null                   │
│           [~~~~] nullfill  [XXXX] gap                        │
├──────────────────────────────────────────────────────────────┤
│   SHADOW      czujnik.shadow                            26 B │
│   Updates: 2                                                 │
└──────────────────────────────────────────────────────────────┘

Interpretacja: plik binarny zawiera 80 rekordów (gap nie zajmuje miejsca w pliku danych), przerwa jest zakodowana wyłącznie w .meta. Kolumna [binary data] pokazuje pusty zakres dla segmentu gapowego — danych binarnych nie ma. Pole pressure w rekordach 50–79 ma wartości null w niektórych polach ([----]).


Przykład 3 — artefakt z retencją segmentową

Strumień bufor z retencją cykliczną: maksymalnie 10 segmentów po 100 rekordów (łącznie 1000 rekordów). Aktualnie zapisano 280 rekordów w trzech segmentach:

{
  DOUBLE   value
  TYPE     DEFAULT
  RETENTION 1000 100
}
$ xtrdb -s bufor
┌──────────────────────────────────────────────────────────────┐
│   Storage map: bufor                                         │
├──────────────────────────────────────────────────────────────┤
│ [shadow]   │ [binary data] │ [meta index]                    │
├────────────┼───────────────┼─────────────────────────────────┤
│ s0         │ s0 0-100      │ [====] 100 records, no nulls    │
│ s1         │ s1 100-200    │ [====] 100 records, no nulls    │
│ s2         │ s2 200-280    │ [====] 80 records, no nulls     │
├────────────┴───────────────┴─────────────────────────────────┤
│   DESCRIPTOR  bufor.desc                                48 B │
│   DOUBLE  value                                          8 B │
│   Record size:                                           8 B │
├──────────────────────────────────────────────────────────────┤
│   DATA TOTAL  rec=280 src=0 seg=280                   2240 B │
│   Records: 280                                               │
│   Source: bufor   Segments: bufor_segment_*                  │
│   Segmented data (RETENTION): 3                              │
│   Policy: segments=10 capacity=100                           │
│   Retention cap records: 1000                                │
│   Retention cap bytes: 8000                                  │
│   Total records: 280                                         │
│     current=0  segments=280                                  │
│   Total bytes: 2240                                          │
│     current=0  segments=2240                                 │
│     [0] bufor_segment_0 rec:100 range:0-100                  │
│     [1] bufor_segment_1 rec:100 range:100-200                │
│     [2] bufor_segment_2 rec:80 range:200-280                 │
├──────────────────────────────────────────────────────────────┤
│   META        bufor.meta                                26 B │
│   Segments: 1   Records: 280                                 │
│   [=========================280===========================]  │
│   Legend: [====] data  [----] partial null                   │
│           [~~~~] nullfill  [XXXX] gap                        │
├──────────────────────────────────────────────────────────────┤
│   SHADOW      bufor.shadow (missing)                    0 B │
└──────────────────────────────────────────────────────────────┘

Interpretacja: kolumna [binary data] pokazuje każdy segment z etykietą sN i zakresem indeksów globalnych. Sekcja DATA TOTAL zawiera pełne zestawienie: src=0 (brak rekordów poza segmentami), seg=280 (wszystkie rekordy w segmentach). Przy wypełnieniu bufora (10 segmentów × 100 = 1000 rekordów) najstarszy segment zostanie usunięty, a nowy dopisany.

Podsumowanie: uzasadnienie przyjętej struktury

Rozdział zbiera wnioski z wszystkich części dokumentacji formatu zapisu danych i wyjaśnia, dlaczego przyjęta struktura plików jest minimalna i wystarczająca dla systemu rejestracji serii czasowych działającego w czasie rzeczywistym.

Zestaw plików i typy akcesorów

Każdy artefakt lub substrat składa się z maksymalnie pięciu plików — plik danych binarnych, deskryptor .desc, indeks .meta, plik cienia danych .shadow i plik cienia indeksu .meta.shadow. Dwa ostatnie tworzą parę: cień danych zachowuje oryginalną zarejestrowaną treść, a cień indeksu — odpowiadające jej wzorce null, dzięki czemu korekta rekordu nie rozspójnia danych z metadanymi. Pole TYPE w deskryptorze wybiera implementację FileInterface: DEFAULT (dane + cień + retencja), MEMORY (wyłącznie RAM, efemerydy), DEVICE / TEXTSOURCE (zewnętrzne źródła tylko do odczytu) i warianty pośrednie. Wybór akcesora następuje raz przy inicjalizacji storage — logika zapytań RQL nie zna szczegółów składowania.

Pliki artefaktu

Deskryptor (.desc) definiuje schemat rekordu w gramatyce ANTLR4: nazwy pól, typy (BYTE, INTEGER, FLOAT, DOUBLE, RATIONAL, STRING), rozmiary tablic, politykę retencji (RETENTION, RETMEMORY) i typ akcesora (TYPE). Rozmiar rekordu R to suma bajtów wszystkich pól danych — pola metadeskryptora nie zajmują miejsca w rekordzie. Deskryptor przy danych oznacza samoopisywalność: narzędzie xtrdb lub dowolny kod może zinterpretować artefakt bez dostępu do kodu źródłowego.

Plik danych binarnych to płaska sekwencja rekordów stałej długości R bez nagłówka. Rekord i leży zawsze na offsecie i × R. Operacja append dopisuje na koniec; operacja update — przy obecnym .shadow — trafia do pliku cienia, nie nadpisuje pliku głównego.

Plik metadanych (.meta) przechowuje kompresowany RLE indeks wartości null i przerw w transmisji. Każdy wpis RLE opisuje ciąg kolejnych rekordów z identycznym wzorcem null: flagę isGap, liczbę rekordów recordCount, rozmiar bitset i sam bitset. Przerwa w transmisji (gap) istnieje wyłącznie w .meta — plik binarny jej nie rejestruje i pozostaje gęsty. Klasą zarządzającą jest rdb::metaData: buforuje bieżący segment w currentEntry_, zapisuje segment na dysk tylko przy zmianie wzorca, a stan DiskTailState (leniwe nadpisanie ostatniego wpisu na dysku) zapewnia, że rozmiar pliku nie rośnie przy ciągłym napływie jednorodnych danych. Po restarcie loadIndex() odtwarza stan i przenosi ostatni niegapowy segment z powrotem do pamięci, umożliwiając kontynuację RLE. Samo I/O pliku realizuje MetaIndexStore, a wykrywanie przerw — GapDetector.

Plik cienia (.shadow) gromadzi modyfikacje rekordów jako sekwencję wpisów (position, data). Odczyt rekordu sprawdza .shadow od końca (najnowsza modyfikacja wygrywa), przy braku wpisu czyta z pliku głównego. Usunięcie .shadow w pełni przywraca stan wyjściowy. Operacja merge() przepisuje poprawki do pliku głównego i zeruje plik cienia; jest dostępna wyłącznie przez API — ani silnik, ani xtrdb nie wywołują jej samodzielnie, więc w normalnej pracy poprawki pozostają w .shadow.

Plik cienia indeksu (.meta.shadow) jest odpowiednikiem .shadow na poziomie wzorców null. Powstaje dla magazynów utrzymujących cień danych: fabryka makeMetaIndex() wstrzykuje wtedy do storage obiekt storageShadow zamiast bazowego metaData, a ten kieruje aktualizacje do pola typu metaShadow. Bez tego pliku korekta rekordu zmieniałaby jego wzorzec null w .meta mimo że oryginalna treść nadal leży nietknięta w pliku głównym — para „dane ↔ metadane“ rozjechałaby się przy pierwszym merge() lub odrzuceniu cienia.

Mechanizm rotacji

Dyrektywa ROTATION rdb_counter włącza tryb zachowania historii sesji. PersistentCounter przechowuje monotonicznie rosnący numer sesji N. Rotacja jest procesem rozłożonym w czasie: przy starcie sesji N funkcja detectStartupState() wykrywa niezgodność (plik danych pusty, .meta niepusty) i przemianowuje .meta na .meta.oldN; przy zamknięciu sesji destruktor posixBinaryFile przemianowuje plik danych na .oldN i plik cienia na .shadow.oldN. Konsekwencją tej kolejności jest przesunięcie o 1: .meta.oldN zawiera metadane sesji N−1, a .oldN — dane sesji N. Bez dyrektywy ROTATION pliki artefaktów są usuwane przy każdym starcie.

Narzędzie inspekcji xtrdb -s

Polecenie xtrdb -s <ścieżka> jest jedynym narzędziem do inspekcji stanu składowania bez uruchamiania xretractor. Raport składa się z mapy poglądowej (kolumny: shadow, binary data, meta index) i sekcji szczegółowych: DESCRIPTOR, DATA (lub DATA TOTAL przy retencji segmentowej), META z paskiem RLE, SHADOW z liczbą niezatwierdzonych modyfikacji oraz ROTATED FILES z historią rotacji. Pasek META używa czterech symboli: = (dane bez null), - (częściowe null), ~ (nullfill), X (gap). Narzędzie jest tylko do odczytu i działa gdy proces xretractor nie działa.


Porównanie podejść

WłaściwośćSurowy plik binarnyStruktura RetractorDB
Samoopisywalnośćbrak — wymaga zewnętrznej dokumentacjitak — deskryptor .desc przy danych
Obsługa przerw w transmisjibrak — przerwy niewidoczne lub fikcyjne rekordytak — .meta rejestruje przerwy bez rozszerzania pliku danych
Wartości null per polebrak — zero = null nierozróżnialnetak — bitset null w .meta
Korekta danych historycznychdestruktywnaniedestruktywna — .shadow
Przywrócenie oryginału po korekcieniemożliwetak — usunięcie .shadow
Wielokrotność strategii składowaniabraktak — pole TYPE w deskryptorze
Koszt przy danych bez przerw i nullminimalny: .meta ≈ 17 B nagłówek + 1 wpis RLE

Kompilacja i budowa planu

Proces kompilacji odbywa się przed każdym uruchomieniem procesu xretractor, o ile podano plik z sekwencją poleceń i zapytań. Argument ten jest wymagany w trybie -c (tylko kompilacja) — bez niego nie ma czego kompilować; w trybie przetwarzania jego pominięcie uruchamia tryb bezczynny, w którym etap kompilacji jest w całości pomijany. W oparciu o przepływ przedstawiony na Rys. 14 przygotowałem opis procesu Rys. 26 realizujący proces kompilacji w trybie rozwojowym. Samą kompilację można wywołać niezależnie od działających instancji. W trybie wykonania ponowne użycie tej samej nazwy instancji jest odrzucane, ale inna nazwa uruchamia osobny plan, o ile magistrala nie wykryje kolizji jego strumieni, plików magazynu lub licznika rotacji.

Rys. 26. Proces kompilacji

Jako przykładowy plik przeznaczony do kompilacji przyjmiemy plik query.rql o następującej zawartości:

DECLARE a INTEGER \
STREAM core0, 0.1 \
FILE 'datafile1.dat'

SELECT str1[0]+1 \
STREAM str1 \
FROM core0>2

Jest to bardzo prosty przykład pliku zawierającego dwie dyrektywy. Pierwsza deklaruje istnieje efemerydu w postaci źródła danych binarnych zawierającego 4-bajtowe liczby typu INTEGER. Dane z tego pliku będą czytane z szybkością 10 razy na sekundę. A nazwa tego obiektu to core0.

Drugie polecenie tworzy artefakt o nazwie str1 pobierający przesunięte w czasie od dwa odczyty czyli 0.2 sekundy dane efemeryczne. W trakcie tworzenia kolejnych elementów strumienia wynikowego dochodzi do przetwarzania danych odczytanych z core0 i do każdej odczytanej wartości dodawana jest wartość 1.

Aby przeprowadzić kompilację tego pliku należy wywołać następujące polecenie:

$ xretractor -c query.rql

Na ekranie wyświetli się następująca odpowiedź systemu:

core0(1/10)	datafile1.dat
	a: INTEGER
str1(1/10)	origin=2
	:- PUSH_STREAM(core0)
	:- STREAM_TIMEMOVE(2)
	str1_0: INTEGER
		PUSH_ID(str1[0])
		PUSH_VAL(1)
		ADD

Pominięcie parametru -c spowoduje podjęcie próby kompilacji i natychmiastowego wysłania skompilowanego planu realizacji zapytania do wykonania. Taka akcja spowoduje wystąpienie błędu. Bowiem pliku z danymi datafile1.dat zapewne jeszcze nie przygotowaliśmy.

Oprócz przeglądu tekstowego możemy obejrzeć również pliki kompilacji w postaci graficznej. Do tego celu należy wywołać następujący ciąg poleceń:

$ xretractor -c -d -f -t -s query.rql > out.dot && dot -Tsvg out.dot -o out.svg

Zakładając że w środowisku uruchomieniowym masz zainstalowany program dot z pakietu graphivz wygenerujesz tym poleceniem plik graficzny przedstawiający odpowiedź systemu w postaci grafu.

Rys. 27. Graficzna reprezentacja planu zapytania

System RetractorDB potrafi wygenerować rysunek jako odpowiedź na jeden ze zleconych ciągów przetwarzania danych. Prezentacja graficzna jest najbardziej odpowiednia w przypadku tworzenia i przedstawiania grafów przetwarzania danych. Niestety czytelność ucierpi w przypadku bardzo skomplikowanych schematów.

Na Rys. 27 widać trywialny plan realizacji zapytania jaki powstał w wyniku kompilacji dwulinijkowego pliku query.rql. U samej góry widać obiekt str1 tworzący artefakty z częstotliwością 10 rekordów na sekundę. Informacja o szybkości tworzenia artefaktów nie występuje w zapytaniu, jest wyznaczana w oparciu wyrażenie algebraiczne z klauzuli FROM w zapytaniu SELECT. Widać też w jaki sposób wytwarzane są kolejne rekordy strumienia str1. Tutaj mamy do czynienia z typowym algorytmem przetwarzania danych na stosie. Najpierw na stos odkładana jest wartość efemeryczna powstałego z wyrażenia algebraicznego a następnie umieszczana jest na stosie wartość 1. Polecenie ADD zdejmuje obie wartości ze stosu pozostawiając na stosie wynik dodawania. To co zostało na stosie – czyli wynik dodawania umieszczane jest w polu tworzonego rekordu.

Z drugiej strony widać operacje na strumieniach. Operacje na strumieniach realizowane są w innej domenie. Tam występuje przetwarzanie obiektów dwu lub jednowartościowych. Operacjom poddawane są albo dwa strumienie albo tylko jeden z argumentem. Klasyczny stos w przypadku Algebraicznych operacji strumieniowych nie ma zastosowania. Dla uproszczenia zapis przypomina trochę operacje na stosie. Widzimy w załączonym przykładzie że operacje na danych bieżących realizujemy poprzez przesunięcie danych w czasie o 2. Celowo nie mówię że to 2 sekundy – tutaj 2 oznacza wartość relatywną względem szybkości napływu. W przypadku szybkości napływu 10 próbek na sekundę – wartość 2 oznacza przesunięcie w czasie o 0.2 sekundy.

Skomplikowane wyrażenia algebraiczne w których biorą udział co najmniej dwa operatory strumieniowe powodują powstanie wspominanych w poprzednich rozdziałach substratów. Każde zapytanie, które zawiera wyrażenia algebraiczne w klauzuli from z więcej niż jednym operatorem są rozbijane na operacje dwuargumentowe, zależne od siebie. Lista argumentów substratu to domyślnie pełne rozwinięcie schematu.

Dostępne flagi xretractor

W trybie kompilacji (-c) i w trybie wykonania dostępne są różne zestawy flag. Poniżej flagi trybu kompilacji używane przy generowaniu grafów:

FlagaPełna nazwaZnaczenie
-c--onlycompiletylko kompilacja — nie uruchamia przetwarzania
-d--dotgeneruj wyjście w formacie DOT (graphviz)
-f--fieldspokaż pola strumieni w grafie DOT
-t--tagspokaż programy poszczególnych pól (-f)
-s--streamprogspokaż programy strumieni w grafie DOT
-u--rulespokaż reguły RULE w grafie DOT
-p--transparentprzezroczyste tło grafu DOT
-i--hideruleprogukryj program warunku reguły (z -u)
-m--csvwyjście w formacie CSV

Flagi trybu wykonania (bez -c):

FlagaPełna nazwaZnaczenie
-m N--llimitqry Nuruchom N cykli przetwarzania, potem zakończ
-k--noanykeynie czekaj na klawisz — tryb daemon/skrypt
-t--realtimetryb czasu rzeczywistego (SCHED_FIFO, mlockall)
-x--xqrywaitczekaj na pierwsze połączenie xqry przed startem
-s--statussprawdź czy instancja xretractor już działa
-v--verbosewyświetl parametry strumieni przy starcie
-j--servicetryb usługowy — dziennik na stderr (journald)
-g F--config Fplik konfiguracyjny TOML zamiast wyszukiwania
-b--build-infowypisz konfigurację optymalizatora i zakończ

ℹ Info

Parametr -m N liczy iteracje pętli głównej, nie sekundy. Dla strumieni z interwałem 0.1 s (10 Hz), -m 10 oznacza ~1 sekundę przetwarzania.

⚠️ Ostrzeżenie

Przy użyciu -m N w skryptach i testach zawsze dodawaj -x (--xqrywait). Bez tej flagi serwer może przetworzyć wszystkie N cykli zanim klient (xqry) zdąży się podłączyć — klient nie otrzyma żadnych danych i będzie czekał do przekroczenia limitu czasowego. Flaga -x wstrzymuje przetwarzanie do nadejścia pierwszej komendy od xqry.

Pełna lista wszystkich opcji z opisem każdej z nich — w tym opcja --realtime wymagająca uprawnień systemowych — znajduje się w Załączniku A.

Przetwarzanie i dystrybucja danych

W przypadku rozpoczęcia procesu przetwarzania danych analizując przedstawiony na Rys. 14 można wydzielić następujący schemat przepływu - Rys. 28:

Rys. 28. Schemat przepływu sterowania w procesie przetwarzania

Do przeprowadzania procesu przetwarzania potrzebne będzie przygotowanie danych i zbudowanie ciągu przetwarzającego dane. W ramach tego ciągu na wejściu użyjemy przygotowanego pliku z planem realizacji zapytania, przygotujemy plik binarny z danymi. Zbudujemy proces przetwarzający dane i prezentujący wyniki.

Źródłowy plik danych query.rql zmienimy na następujący:

DECLARE a INTEGER \
STREAM core0, 0.1 \
FILE 'datafile1.txt'

DECLARE a BYTE \
STREAM core1, 0.2 \
FILE '/dev/urandom'

SELECT str1[0], str1[0] + str1[1]/20 \
STREAM str1 \
FROM core0 + core1

W tym przykładzie deklarujemy istnienie pliku tekstowego zawierającego dane tekstowe. Proponuję wypełnić plik datafile1.txt następującą zawartością:

$ seq 20 28 > datafile1.txt
20
21
22
23
24
25
26
27
28

Plik będzie zawierać kolejne liczby od 20 do 28.

Rzut okna na plan realizacji zapytania przedstawi obraz na Rys. 29:

Rys. 29. Graficzna reprezentacja planu realizacji zapytania 2

Jeśli przygotowaliśmy plik z danymi możemy uruchomić proces kompilacji i przetwarzania danych. Realizujemy to wydając następujące polecenie:

$ xretractor query.rql

I tu pojawia się istotna właściwość opracowanego systemu. System powinien rozpocząć natychmiast realizację procesu. Dowolny klawisz naciśnięty w terminalu przerwie ten proces.

Proponuję uruchomić drugie okno terminala i tam kontynuować sesję. W drugim oknie terminala możemy wydać następujące polecenie:

$ xqry -d
name  | duration | size | count | location      | cap
------+----------+------+-------+---------------+----
str1  | 1/10     | 6912 | 864   |               | 0
core0 | 1/10     | -1   | 49    | datafile1.txt | 1
core1 | 1/5      | -1   | 25    | /dev/urandom  | 1

Powinno się pojawić coś podobnego. Oczywiście liczniki danych przy str1 powinny się różnić. Logicznym jest że za każdym odczytem otrzymamy większe wartości dotyczące rozmiaru zgromadzonego strumienia str1.

Jeśli chcemy na ekranie zobaczyć co tam się właśnie dzieje wewnątrz procesu przetwarzania danych proponuję wydać poniższe polecenie i po kilku wierszach na ekranie nacisnąć dowolny klawisz aby przerwać ten proces:

$ xqry -s str1
20 26
21 33
22 34
23 27
24 28
25 35
26 36
27 28

Pierwsza kolumna zawiera sekwencję liczb – taką jaką wpisaliśmy do pliku datafile1.txt. Druga kolumna zawiera efekt przetwarzania. Dodawana jest wartość pobrana z generatora liczb pseudolosowych podzielona przez 20 – druga kolumna opływa dane poniżej kolumny pierwszej.

Jak można to zobaczyć w formie graficznej? Proponuję wydać następujące polecenie:

$ xqry -s str1 -p 50,50 | gnuplot

Na ekranie pojawi się następujące okno z płynącymi na bieżąco danymi:

Rys. 30. Zrzut zawartości okna gnuplot przedstawiający dane napływające

Na Rys. 30 widzimy to co dane przedstawiały w postaci numerycznej. Kształt piły to pierwsza kolumna, nieregularny kształt opływający kształt piły to druga kolumna. Rysunek przedstawia dane statyczne – w oknie jednak dane te napływają i rysunek jest aktualizowany na bieżąco.

Typowym pomysłem na wysłanie danych poza system na którym funkcjonuje xretractor i xqry jest użycie polecenia:

$ xqry -s str1 | nc -l 8888

na drugim komputerze trzeba napisać:

$ nc nazwa_serwera_lub_jego_ip 8888

ℹ Info

Flaga -p w netcat (składnia BSD) nie jest obsługiwana przez GNU netcat dostępny na współczesnych systemach Ubuntu/Debian. Poprawna składnia to nc -l 8888 (bez -p).

Transmisja danych odbędzie się przez sieć.

Jeśli chcemy zakończyć proces xretractor za pomocą polecenia xqry możemy wydać następujące polecenie:

$ xqry -k

Po wydaniu tego polecenia proces xretractor zakończy swoje działanie i przerwie przetwarzane planów realizacji zapytań.

Zapis procesu prezentowany na ekranie (Rys. 31) przedstawia się następująco:

Rys. 31. Zapis procesu przetwarzania danych w czasie rzeczywistym

Analiza artefaktów

Analizując szerzej potencjalne ścieżki danych na Rys. 14 ostatnią nieopisaną ścieżką jest ścieżka w której bierze udział narzędzie xtrdb.

W trakcie tworzenia systemu potrzebowałem narzędzia umożliwiającego dostęp do artefaktów w celu przeprowadzenia testów integracyjnych. W celu weryfikacji poprawności musiałem porównać wyniki przetwarzania na różnych etapach. Na Rys. 32 przedstawiono kompletny przepływ danych uwzględniający rolę narzędzia xtrdb.

Rys. 32. Przepływ danych w analizie artefaktów

W celu przedstawienia procesu analizy artefaktów konieczne jest uwzględnienie całego ciągu przetwarzania. Użyjemy tego samego zapytania co poprzednio. Uruchomimy jednak nasz proces przetwarzania danych w trochę inny sposób.

$ xretractor -m 10 query.rql

Tak wywołany proces przetwarzania zapytań zakończy swoją pracę po 10 cyklach przetwarzania. Parametr -m określa liczbę iteracji pętli głównej, nie liczbę sekund — czas działania zależy od interwału strumieni źródłowych. Dla strumieni z interwałem 0.1 s (10 Hz) oznacza to ~1 sekundę działania. Po zakończeniu działania i przejrzeniu katalogu w którym realizowaliśmy zapytanie powinniśmy zobaczyć następujące pliki:

$ ls -al
total 32
drwxr-xr-x  2 michal michal 4096 Oct  4 18:01 .
drwxr-xr-x 10 michal michal 4096 Oct  4 17:59 ..
-rw-r--r--  1 michal michal   51 Oct  4 18:01 core0.desc
-rw-r--r--  1 michal michal   43 Oct  4 18:01 core1.desc
-rw-r--r--  1 michal michal   27 Oct  4 17:59 datafile1.txt
-rw-r--r--  1 michal michal  180 Oct  4 18:00 query.rql
-rw-r--r--  1 michal michal   72 Oct  4 18:01 str1
-rw-r--r--  1 michal michal   34 Oct  4 18:01 str1.desc

Jak widać powstały trzy pliki .desc i jeden plik z artefaktami. Jeśli zajrzymy do pliku str1 to zobaczymy bardzo skromną zawartość:

$ hexdump str1
0000000 0014 0000 0015 0000 0015 0000 0016 0000
0000010 0016 0000 0017 0000 0017 0000 0018 0000
0000020 0018 0000 0019 0000 0019 0000 001a 0000
0000030 001a 0000 001b 0000 001b 0000 001c 0000
0000040 001c 0000 001d 0000
0000048

Wraz z plikiem artefaktu powstają pliki metadanych. Ich zawartość informuje o strukturze pliku.

$ cat str1.desc
{       INTEGER str1_0
        INTEGER str1_1
}

O wiele ciekawsze są opisy plików efemerydów. Pliki opisu danych efemerycznych wskazują na pliki w systemie Linux.

$ cat core0.desc
{       INTEGER a
        REF "datafile1.txt"
        TYPE TEXTSOURCE
}
$ cat core1.desc
{       BYTE a
        REF "/dev/urandom"
        TYPE DEVICE
}

Pliki opisu metadanych są tworzone automatycznie w momencie zarejestrowania w systemie RetractorDB obiektu. Należy pamiętać aby usunąć te deskryptory w przypadku zmodyfikowania pliku query.rql

Po uruchomieniu programu xtrdb w terminalu narzędzie wyświetli znak zachęty w postaci kropki (.). Znak ten to wyłącznie prompt — nie jest częścią polecenia. Można od razu rozpocząć komunikację z tym narzędziem. Przykład sesji:

$ xtrdb
.open str1
ok
.desc
{       INTEGER str1_0
        INTEGER str1_1
}
.list 1
{ str1_0:20 str1_1:21 }
.quit

Praca z tym narzędziem przypomina pracę z klasyczną, starą bazą danych dbase. Nie mamy tu jednak maszyny stanów, pętli czy warunków. Tylko odczyt i modyfikacje plików binarnych opisanych metadanymi.

Głównym celem tego narzędzia było wsparcie przy tworzeniu skryptów testowych. RetractorDB jest deterministyczny. W systemie nie występuje zjawisko wyścigu – dane, które trafią na wejście – zawsze powinny dać te same wyniki na wyjściu. Chyba że zmieszamy wyniki z danymi przypadkowymi jak w przedstawionym przykładzie.

NOTE: Opisana funkcjonalność ma pokrycie w testach: issue113_meta_xtrdb, issue113_meta, issue113_null_txtsrc, Pattern5 opisanych w załączniku pt. Testy Integracyjne.

Bardzo użyteczną funkcją w tym narzędziu jest funkcja list oraz rlist. Listująca początkowe elementy pliku lub końcowe elementy pliku — uwzględniając strukturę opisaną w metadanych.

.list 4
{ str1_0:20 str1_1:21 }
{ str1_0:21 str1_1:22 }
{ str1_0:22 str1_1:23 }
{ str1_0:23 str1_1:24 }
.rlist 4
{ str1_0:28 str1_1:29 }
{ str1_0:27 str1_1:28 }
{ str1_0:26 str1_1:27 }
{ str1_0:25 str1_1:26 }

Zachęcam do eksperymentów i przejrzenia źródeł tego narzędzia. Jest to jeden z mniej skomplikowanych a bardzo użytecznych elementów systemu RetractorDB.

Inspekcja metadanych null/gap

Każdy artefakt ma skojarzony plik indeksu .meta opisany szczegółowo w rozdziale dotyczącym formatu zapisu. Zawartość tego pliku można obejrzeć bezpośrednio w xtrdb poleceniem meta:

.open str1
ok
.meta
record 0: count=9 gap=false nullBitset=00

Wpis gap=false oznacza brak przerwy w danych, nullBitset informuje które pola zawierają wartości null (po jednym bicie na pole). Dane bez żadnych braków tworzą jeden wpis count=N gdzie N to łączna liczba rekordów.

Podsumowanie

W podsumowaniu należy wskazać na przekazy w rozdziale zakres wiedzy. Tutaj chciałem przedstawić jak poszczególne elementy systemu możemy uruchamiać, jak wyglądają ciągi poleceń w oparciu o które budujemy dalsze funkcjonalności z wykorzystaniem systemu RetractorDB.

Starałem się zredukować ilość potencjalnych poleceń do minimum. Na chwile obecną efektywnie zredukowałem zbiór do 3 poleceń. Wydaje mi się tak zaprojektowany system będzie maksymalnie użyteczny i w miarę efektywny. Osobną kwestią jest komplikacja. Samo tłumaczenie procesu przetwarzania, nowej algebry i dlaczego znak plus nie oznacza plus – jest problematyczne. Mam jednak nadzieję że po przeskoczeniu pewnej bariery poznania – reszta będzie oczywista. Podjęte decyzje były efektem przemyśleń, prób i błędów. Chcę podkreślić że zwyczajnie po ludzku nie znalazłem lepszej metody.

Kompilacja zapytań

Uważny czytelnik zauważy zapewne, że w przedstawionych w poprzednim rozdziale skompilowanych planach realizacji zapytań pewne wartości nie odpowiadają temu, co zostało napisane w zapytaniu.

Kompilator prowadząc proces budowania planu zapytania prowadzi proces autonomicznie. Wydaje się czasem, że prosząc o jedno - dostaje się coś innego – na pierwszy rzut oka jest to zachowanie zupełnie nieoczywiste. I jako użytkownik nie mam zasadniczo na to wpływu. Co ciekawe efekt zapytania odpowiada temu o co prosiłem w zapytaniu. Być może poprawny tytuł tego rozdziału powinien brzmieć: Dlaczego kompilator robi po swojemu i do tego wie lepiej?

W tym rozdziale chcę wyjaśnić, jak rozwiązałem problemy syntaktyczne, które napotkałem w trakcie tworzenia języka zapytań.

Wejście i wyjście kompilatora

Plik .rql

Wejście kompilatora — tekst w języku RQL zawierający instrukcje DECLARE, SELECT i RULE oraz dyrektywy konfiguracyjne (np. :STORAGE). Parser ANTLR4 czyta plik instrukcja po instrukcji.

Kolejność DECLARE i SELECT w pliku nie ma znaczenia: zapytanie może odwoływać się do strumienia zdefiniowanego niżej, bo zależności między strumieniami rozwiązuje dopiero kompilator. Wyjątkiem jest RULE — parser przypina regułę do strumienia już wczytanego, więc reguła musi stać po definicji swojego strumienia; w przeciwnym razie parser zgłasza błąd Rule '…' refers to stream '…', but no such stream is defined. Odwołanie do strumienia, którego w pliku nie ma wcale, przerywa kompilację błędem Referenced Stream in QUERY _not found_ in CORE TREE.

Parser ANTLR4 → qTree

Parser buduje wewnętrzną reprezentację qTreestd::vector<query> — dopisując po jednym elemencie dla każdej instrukcji DECLARE, SELECT i dyrektywy konfiguracyjnej, w kolejności z pliku i bez sortowania. Element SELECT niesie schemat pól z programami stosowymi oraz program klauzuli FROM wskazujący strumienie źródłowe. Interwał czasowy (delta) jest na tym etapie znany tylko dla deklaracji DECLARE; dla zapytań SELECT wyznacza go kompilator. Szablon generatora STREAM nazwa[N] jest jeszcze jednym elementem, a reguła RULE nie tworzy własnego elementu — trafia na listę reguł swojego strumienia.

Kolejność wektora zmienia się w trakcie kompilacji: rozwiązanie interwałów sortuje go według delty, a porządek topologiczny (producent przed konsumentem) przywraca dopiero ostatni etap.

23 etapy kompilacji

qTree przechodzi przez łańcuch przekształceń: od rozbicia wyrażeń FROM na operacje dwuargumentowe, przez wyznaczenie delt i offsetów bajtowych, aż po weryfikację semantyczną i obliczenie rozmiarów buforów. Każdy etap zakłada sukces poprzedniego.

Plan wykonania → dataModel

Na wyjściu kompilacji każde zapytanie w qTree ma wyznaczone: schemat pól z typami i offsetami, deltę, rozmiary buforów oraz gotową sekwencję instrukcji. Ten plan przejmuje dataModel i realizuje go cyklicznie w czasie rzeczywistym.

Flaga -c zatrzymuje xretractor po tym kroku i drukuje plan na standardowe wyjście — bez uruchamiania przetwarzania.

Przegląd poruszonych w rozdziale tematów

Rozdział zbudowany jest zgodnie z kolejnością etapów kompilatora — od opisu struktury danych i łańcucha etapów, przez poszczególne przekształcenia, aż po obsługę błędów.

  • Przebiegi kompilacji

    Opisuje cały łańcuch etapów funkcji compiler::compile(). Kompilacja to nie jeden krok — to uporządkowana sekwencja dwudziestu trzech etapów wewnętrznej reprezentacji qTree, od rozwinięcia generatorów i sprowadzenia wyrażeń FROM do postaci dwuargumentowej, przez wyznaczanie interwałów, kontrolę nazw substratów, uproszczenia wyrażeń i lokalizację pól, aż po weryfikację semantyczną, alokację buforów i końcowe sortowanie topologiczne. Każdy etap zakłada sukces poprzedniego, a błąd na dowolnym etapie zatrzymuje kompilację.

  • Budowa drzewa zależności

    Opisuje strukturę DAG powstającego w trakcie kompilacji — fundament, na którym opierają się wszystkie etapy. Korzeniami są deklaracje efemerydów (źródła zewnętrzne), wewnątrz grafu leżą substraty pośrednie, a liśćmi są artefakty. Flaga -d generuje wyjście w formacie DOT, które graphviz zamienia w wizualny graf zależności. Kolejność DECLARE i SELECT w pliku .rql nie ma znaczenia — graf zależności buduje kompilator; tylko RULE musi stać po definicji strumienia, do którego się odnosi.

  • Substraty

    Wyjaśnia etap extractIntermediateStreams — pierwszy krok po rozwinięciu generatorów. Gdy wyrażenie FROM zawiera więcej niż dwa argumenty (np. (core0#core1)+core2, core0+core1+core2), kompilator rozbija je na operacje dwuargumentowe i tworzy nazwane substraty. Późniejszy etap deduplicateSubstrats wykrywa, gdy substrat jest strukturalnie identyczny z zapytaniem użytkownika, i zastępuje odwołania — unikając powielania obliczeń.

  • Rozwijanie symbolu *

    Wyjaśnia etap expandSchemaWildcards. Symbol * w klauzuli SELECT zostaje zastąpiony pełną listą pól wynikających ze schematu strumienia źródłowego — w tym polami pochodzącymi z operacji sumy strumieni. Przykład pokazuje, jak typy pól decydują o tym, które pole trafia na które miejsce w schemacie wynikowym.

  • Rozwiązywanie interwałów

    Opisuje etap resolveStreamIntervals. Kompilator wyznacza deltę każdego strumienia wynikowego z równań algebry strumieniowej: dla operatora + delta to minimum wejść, dla # — średnia harmoniczna, dla @(step, window) — pochodna rozmiaru okna. Algorytm działa iteracyjnie — każda runda rozwiązuje co najmniej jeden strumień, aż wszystkie delty są znane.

  • Wykrywanie pętli

    Opisuje mechanizm wbudowany w etap resolveStreamIntervals. Jeśli liczba nierozwiązanych strumieni przestaje maleć, żaden strumień nie może uzyskać delty — znak, że graf zależności zawiera cykl. Kompilacja kończy się błędem "Circular dependency in stream definitions". Rozdział zawiera przykład cyklicznego zapytania i sposób jego naprawy.

  • Aliasowanie

    Opisuje etapy resolveFieldReferences i localizeFieldOffsets. Po sumie + do pola wynikowego można odwołać się zarówno przez indeks w schemacie sumarycznym (str1[1]), jak i przez nazwę strumienia źródłowego z lokalnym indeksem (core1[0]). Po przeplocie # składowe dzielą jeden schemat, dlatego nazwane odwołania do składowych są odrzucane; należy użyć nazwy strumienia wynikowego albo rozplotu &/%.

  • Przetwarzanie symbolu _

    Opisuje etap expandIndexWildcards — cukier syntaktyczny do równoległych operacji na parach pól. Symbol _ w indeksie powoduje powielenie formuły dla wszystkich zgodnych slotów, które wskazany strumień wnosi do rekordu całej klauzuli FROM. Dlatego src[_] * coef[_] przy FROM src@(1,5)+coef generuje pięć iloczynów, mimo że sam src jest jednopolowy. Zastosowanie: budowa zapytań filtrów sygnałowych.

  • Równanie typów w górę

    Definiuje reguły promocji typów obowiązujące przez cały łańcuch kompilacji. Wynik działania BYTE * INTEGER ma typ INTEGER — kompilator wyznacza typ pola wyjściowego statycznie, zanim dane zostaną przetworzone. Opisano też kompletną hierarchię typów obsługiwanych przez RetractorDB.

  • Debugowanie kompilacji

    Zbiera w jednym miejscu narzędzia diagnostyczne: flaga -c do inspekcji planu, pipeline -c -d -f -s do wizualizacji grafu przez graphviz, tablicę znaczeń instrukcji planu (PUSH_ID, PUSH_STREAM, STREAM_ADD, …) oraz katalog typowych błędów kompilacji z ich przyczynami i sposobem naprawy.

Przebiegi kompilacji

Kompilacja zapytań w RetractorDB przebiega w wielu etapach. Każdy etap transformuje wewnętrzną reprezentację zapytań — drzewo qTree — i przekazuje wynik do następnego. Kolejność jest ściśle ustalona: każdy etap zakłada, że poprzedni zakończył się sukcesem.

qTree to std::vector<query> — centralna struktura danych kompilatora i executora. Każdy element wektora odpowiada jednemu zapytaniu (SELECT lub DECLARE) i przechowuje jego schemat pól, sekwencję instrukcji stosu, interwał czasowy, ogon startowy oraz referencje do strumieni źródłowych. Nie każdy etap utrzymuje kolejność wektora: rozwiązanie interwałów sortuje go według rInterval. Dlatego kompilacja kończy się bezwarunkowym sortowaniem topologicznym, które gwarantuje, że podczas wykonania producent poprzedza konsumenta.

Przykład śledzący

Przez cały rozdział śledzimy jedno zapytanie — query.rql — przez kolejne etapy:

DECLARE a BYTE, b INTEGER \
STREAM core0, 0.1 \
FILE 'sensor_a.txt'

DECLARE c INTEGER, d FLOAT \
STREAM core1, 0.2 \
FILE 'sensor_b.txt'

DECLARE e INTEGER \
STREAM core2, 0.3 \
FILE 'sensor_c.txt'

SELECT * \
STREAM merged \
FROM core0 + core1

SELECT merged[0], merged[2] \
STREAM result \
FROM merged

Po przejściu przez wszystkie etapy xretractor -c query.rql drukuje:

core0(1/10)	sensor_a.txt
	a: BYTE
	b: INTEGER
core1(1/5)	sensor_b.txt
	c: INTEGER
	d: FLOAT
merged(1/10)	tail=1
	:- PUSH_STREAM(core0)
	:- PUSH_STREAM(core1)
	:- STREAM_ADD
	core0_0: BYTE
		PUSH_ID(merged[0])
	core0_1: INTEGER
		PUSH_ID(merged[1])
	core1_2: INTEGER
		PUSH_ID(merged[2])
	core1_3: FLOAT
		PUSH_ID(merged[3])
result(1/10)	tail=1
	:- PUSH_STREAM(merged)
	result_0: BYTE
		PUSH_ID(result[0])
	result_1: INTEGER
		PUSH_ID(result[2])
core2(3/10)	sensor_c.txt
	e: INTEGER

Plan jest wydrukowany w końcowym porządku topologicznym: deklaracje core0 i core1 poprzedzają swojego konsumenta merged, a ten — zapytanie result. Nieużywana deklaracja core2 trafia na koniec. tail=1 to ogon startowy wyznaczony przez computeStartupLatency. Odwołania PUSH_ID wskazują pozycje w rekordzie wejściowym zapytania, zapisane pod jego własną nazwą: result[2] to trzecie pole rekordu merged, czyli core1.c.

Lista pól result odwołuje się wyłącznie do strumienia z własnej klauzuli FROM. Zapis core1[0] w tym miejscu kończy się błędem kompilacji Stream 'result' refers to 'core1', which is not in its FROM clause: core1 jest źródłem merged, a nie result — patrz Aliasowanie.

Podrozdziały o substratach i symbolu _ używają rozszerzonych wariantów tego samego zestawu deklaracji. Jak interpretować każdy element tego planu — patrz Debugowanie kompilacji.

Łańcuch etapów

Łańcuch dwudziestu trzech etapów definiuje funkcja compiler::compile():

  • checkFunctionCalls — nazwy i arność funkcji skalarnych
  • checkStreamReducerFieldRefs — reduktor strumieniowy poza klauzulą FROM
  • expandStreamGenerators — rozwinięcie rodzin strumieni nazwa[N]
  • snapshotNamedSourceRefs — migawka odwołań zapisanych przez użytkownika
  • extractIntermediateStreams — wyrażenia FROM dwuargumentowe, substraty
  • expandSchemaWildcards — rozwinięcie * oraz [_]
  • resolveStreamIntervals — interwały strumieni, wykrywanie pętli
  • factorMatchedHashTimeMoves — wyniesienie wspólnego przesunięcia przed przeplot
  • deduplicateSubstrats — eliminacja powtórzonych substratów
  • validateSubstratNameUniqueness — jednoznaczność nazw substratów
  • resolveFieldReferences — odwołania do pól jako indeksy płaskie
  • resolveWindowAggregates — grupy agregatów okna rekordowego
  • inferFieldShapes — typ, długość i krotność każdego pola
  • checkRuleConditionShapes — obliczalność warunków RULE
  • simplifyFieldExpressions — uproszczenie programów pól i reguł
  • shareEquivalentSelectComputations — współdzielenie równoważnych SELECT
  • localizeFieldOffsets — przesunięcia pól w buforze wejściowym
  • computeLogicalOrigin — początek logiczny strumienia
  • computeStartupLatency — ogon startowy
  • computeRequiredCapacities — wymagana historia buforów
  • validateConstraints — kontrola semantyczna planu
  • applyCapacitiesToStreams — zastosowanie pojemności
  • topologicalSort — końcowy porządek producent–konsument

Etapy factorMatchedHashTimeMoves, deduplicateSubstrats, simplifyFieldExpressions i shareEquivalentSelectComputations są optymalizacjami, które przełączniki RDB_OPT_* mogą wyłączyć. Wyłączenie nie zmienia wartości wyniku; może najwyżej wydłużyć ogon startowy (patrz factorMatchedHashTimeMoves). Pozostałe etapy wykonują się zawsze.

checkFunctionCalls

Sprawdza nazwy i arność funkcji skalarnych względem jednej tabeli rqlFunctions.hpp. Dopasowanie ignoruje wielkość liter, a do tokena trafia postać kanoniczna nazwy. Nieznana funkcja lub niedozwolona szerokość kończy kompilację komunikatem Check result: jeszcze przed rozwinięciem generatorów, więc jeden błąd szablonu nie jest powielany N razy.

checkStreamReducerFieldRefs

Odrzuca reduktor strumieniowy (MIN, MAX, AVG, SUMC bez szerokości okna) użyty w programie pola SELECT albo w warunku RULE. Gramatyka dopuszcza go w wyrażeniu skalarnym, ale tam żaden mechanizm wykonawczy go nie obliczy: zapytanie SELECT avg STREAM o FROM AVG(src) przechodziło kompilację i nie emitowało potem ani jednego rekordu. Miejscem reduktora strumieniowego jest klauzula FROM; działające SELECT * FROM AVG(src) tej kontroli nie podlega. Etap stoi obok checkFunctionCalls z tego samego powodu — przed rozwinięciem generatorów.

expandStreamGenerators

Rozwija każdy szablon SELECT ... STREAM nazwa[N] ... na N zwykłych zapytań o nazwach nazwa$0nazwa$(N-1) i podstawia numer instancji pod $ w polach, wartościach oraz odwołaniach klauzuli FROM. Jest pierwszym przebiegiem przepisującym plan (poprzedzają go tylko kontrole checkFunctionCalls i checkStreamReducerFieldRefs): po nim pozostała część kompilatora otrzymuje plan nieodróżnialny od ręcznie rozpisanych zapytań. Składnię i ograniczenia opisuje Polecenie SELECT.

snapshotNamedSourceRefs

Zapamiętuje referencje źródłowe zapisane przez użytkownika przed tworzeniem substratów. Późniejsza lokalizacja pól używa tej migawki, aby odróżnić legalne tokeny syntetyczne od odwołań do składowej przeplotu #, której tożsamości wynik już nie zachowuje.

extractIntermediateStreams

Sprowadza każde wyrażenie FROM do postaci co najwyżej dwuargumentowej. Złożone wyrażenia jak (core0#core1)+core2 oraz zapisy łańcuchowe bez nawiasów (core0+core1+core2, core0#core1#core2) wymagają pośrednich strumieni. Każde zapytanie jest redukowane do punktu stałego, więc etap obsługuje również sąsiadujące podwyrażenia jednoargumentowe, np. (core0>2)#(core1>1). Etap tworzy automatycznie substraty — patrz Substraty.

expandSchemaWildcards

Rozwija symbol * w klauzuli SELECT oraz indeks [_]. Gwiazdkę zastępuje listą pól wynikających ze schematu strumienia źródłowego. Formułę z x[_] powiela zgodnie z liczbą slotów, które x wnosi do rekordu całej klauzuli FROM, a nie według własnej szerokości strumienia x. Dzięki temu jednopolowy x pod oknem x@(1,5) daje pięć elementów. Jeżeli wkład wskazanej nazwy nie tworzy w FROM spójnego bloku pól, kompilacja kończy się błędem zamiast przyjąć przypadkową szerokość.

Na tym etapie schematy pochodne rozwijają liczbowy wpis T[N] do N pól skalarnych. Deskryptor deklaracji nadal zachowuje jeden wpis tablicowy, a układ bajtów i kolejność płaskich slotów nie zmieniają się. STRING[N] pozostaje jednym polem tekstowym. Dzięki temu operatory pochodne, reduktory, AGSE i payload używają tej samej jednostki indeksowania. Patrz Rozwijanie symbolu * i Przetwarzanie symbolu _.

resolveStreamIntervals (← tu wykrywane są pętle)

Wyznacza interwał czasowy (delta) każdego strumienia na podstawie operatorów algebraicznych i interwałów strumieni wejściowych. Algorytm iteracyjny — w każdej rundzie rozwiązuje tyle strumieni, ile jest możliwe. Agregat okna rekordowego w liście SELECT nie zmienia interwału i wymaga pojedynczego odwołania do strumienia w FROM; złożona klauzula jest tutaj odrzucana. Etap wykrywa cykliczne zależności zatrzymując się, gdy liczba nierozwiązanych strumieni przestaje maleć — patrz Rozwiązywanie interwałów i Wykrywanie pętli.

factorMatchedHashTimeMoves

Rozpoznaje dopasowane przesunięcia argumentów przeplotu. Gdy i·ΔA=k·ΔB, przepisuje (A>i)#(B>k) do (A#B)>(i+k), redukując dwa substraty przesunięcia do jednego substratu przeplotu. Przypadki niedopasowane oraz substraty współdzielone z innymi konsumentami pozostają bez zmian — patrz Substraty.

Przesunięcie przenosi milczenie do początku logicznego, a nie wstawia rekordy prefiksu. Równość fizycznych przesunięć sprawia, że obie strony reguły mają ten sam emitowany ciąg i ten sam początek logiczny. Ogony równe nie są: strona sfaktoryzowana czyta treść wprost z przeplotu, więc jest gotowa nie później, a zwykle wcześniej niż strona czytająca składowe po ich własnym przesunięciu. Reguła jest zatem optymalizacją opóźnienia, nie przepisaniem neutralnym — zakres twierdzenia R1 i kontrprzykład: Formalne podstawy i dowody.

deduplicateSubstrats

Optymalizacja: jeśli dwa zapytania korzystają z tej samej operacji pośredniej (np. core0#core1), etap wskazuje drugie zapytanie na substrat utworzony przez pierwsze. Unika powielania obliczeń — patrz przykład w Substraty.

validateSubstratNameUniqueness

Sprawdza, czy dwa substraty o tej samej nazwie opisują ten sam program. Nazwy dłuższe niż 200 bajtów są stabilnie skracane przez composeStreamName(), więc ta kontrola zamienia skrajnie mało prawdopodobną kolizję 64-bitowego skrótu w głośny błąd zamiast niejednoznacznego planu. Przebieg działa niezależnie od przełączników optymalizatora i następuje po deduplikacji, ponieważ przed nią identyczne duplikaty nazw są stanem przejściowym.

resolveFieldReferences

Przekształca odwołania do pól ze schematów źródłowych na indeksy płaskie w schemacie wynikowym. Obsługuje aliasowanie po sumie — core0[0] zamienia na str1[0] itp. — oraz zapamiętuje, do którego źródła została rozwiązana goła nazwa pola. Nazwane odwołania zapisane przez użytkownika są śledzone osobno, aby późniejszy przebieg nie pomylił ich z tokenami syntetyzowanymi przez kompilator. Goła nazwa liczbowej tablicy jest odrzucana: a nie znaczy a[0]; trzeba podać element. Patrz Aliasowanie.

resolveWindowAggregates

Wyodrębnia program argumentu każdego MIN/MAX/AVG/SUMC(wyrażenie : W) z listy SELECT do tabeli query::windowGroups. Sprawdza dodatnią szerokość, typ liczbowy, jedno źródło historii, obecność odwołania do pola oraz zakazy zagnieżdżania i użycia w RULE. Identyczne trójki źródło–wyrażenie–szerokość współdzielą grupę i jedno przejście po historii. Token agregatu staje się bezargumentowym operandem wskazującym obliczony wynik grupy.

inferFieldShapes

Jedyny etap ustalający publiczny kształt pola SELECT: typ, długość i krotność. Kształt wynika z całego programu pola — przebieg odtwarza arytmetykę wykonawczą na stosie typów, łącznie z promocją BYTE, jawnymi konwersjami w środku wyrażenia, wynikiem agregatu okna oraz szerokością STRING. Etap zastąpił wcześniejsze reguły lokalne (propagateCopiedFieldShapes, inferStringFieldTypes), które rozstrzygały kształt tylko w wybranych przypadkach — patrz Równanie typów w górę.

Przebieg działa do punktu stałego, bo drzewo jest jeszcze posortowane według interwału i konsument może stać przed producentem. Obejmuje wyłącznie węzły kopiujące schemat operandu; reduktory i okno @ zachowują schemat zbudowany przez swój operator, a deklaracje DECLARE pozostają nietknięte. Etap poprzedza upraszczanie wyrażeń: po zwinięciu stałych szerokość pola zależałaby od przełącznika optymalizacji.

checkRuleConditionShapes

Stosuje do warunków RULE tę samą kontrolę obliczalności, którą inferFieldShapes stosuje do pól. Warunek reguły wykonuje ten sam ewaluator wyrażeń, więc bez tego etapu niepoprawny warunek omijałby kontrolę i dawał po cichu złą wartość. Przebieg niczego nie zapisuje w planie — jedynie odrzuca warunki, których nie da się obliczyć.

simplifyFieldExpressions

Upraszcza programy pól SELECT, argumenty agregatów okna rekordowego oraz warunki RULE po rozwiązaniu referencji, ale przed współdzieleniem równoważnych obliczeń. Przebieg zwija wyrażenia stałe, łączy ogony stałych w arytmetyce całkowitej i wymiernej oraz usuwa zgodne typowo elementy neutralne (E+0, E-0, E*1, E/1). Powtórzone dokładne czynniki zapisuje jako potęgę, np. E*E*E jako E^3.

Przebieg zachowuje semantykę wartości NULL i promocję typów. Dlatego nie upraszcza E*0, nie reasocjuje FLOAT ani DOUBLE i pozostawia bez zmian programy, których typu lub działania nie potrafi bezpiecznie ustalić. Redukcja powtórzonego czynnika dotyczy tylko typów o dokładnym mnożeniu (BYTE, INTEGER, UINT, RATIONAL); dla FLOAT i DOUBLE pojedyncze mnożenie nie jest zastępowane wywołaniem pow.

shareEquivalentSelectComputations

Wykrywa jawne zapytania SELECT o równoważnych programach pól i drzewach FROM zawierających STREAM_ADD. Porządkuje tylko dwoje dzieci pojedynczego węzła STREAM_ADD, bez zmiany grupowania całego drzewa. Dla każdej klasy równoważności tworzy jeden substrat STREAM_SELECT_*, a publiczne zapytania pozostawia jako lekkie projekcje zachowujące własne nazwy, deskryptory, reguły i storage. Przebieg wykonuje się przed lokalizacją offsetów — patrz Substraty.

localizeFieldOffsets

Przelicza odwołania do pól (b[x], c[y]) na pozycje w spłaszczonym rekordzie wejściowym zapytania, zapisywane pod jego własną nazwą (result[z]). Dla sumy + offset wynika z liczby pól wcześniejszych składowych. Dla przeplotu # oba argumenty dzielą te same pozycje wspólnego schematu; tożsamość składowej nie jest już dostępna przez jej nazwę.

Pozycję da się wyznaczyć tylko dla strumieni z klauzuli FROM i dla źródeł osiąganych przez substraty wygenerowane przez kompilator. Odwołanie do źródła strumienia pośredniego, który jest zapytaniem użytkownika — np. core1[0] przy FROM merged — kończy kompilację błędem Stream '…' refers to '…', which is not in its FROM clause. Taki strumień ma własny interwał i bufor, więc pozycji jego źródeł w rekordzie wejściowym konsumenta nie ma czym wyznaczyć.

Na tym etapie kompilator odrzuca napisane przez użytkownika A[0], A.pole, A[_], A.* i gołe nazwy pól, jeżeli wskazują składową osiąganą przez #. Kontrola obejmuje także warunki RULE oraz źródła ukryte w automatycznych substratach. Legalne pozostają odwołania przez nazwę strumienia wynikowego, niekwalifikowane * oraz jawne odzyskanie składowej przez & lub %.

computeLogicalOrigin

Oblicza query::logicalOrigin, czyli indeks pierwszego rekordu, który w ogóle istnieje. Różnica wobec ogona jest jakościowa: ogon mówi „jeszcze nie teraz“, origin mówi „ten rekord nie ma definicji“. Źródłem początku logicznego jest okno @(k,L) stemplowane końcem przedziału — jego wczesne rekordy sięgałyby przed początek źródła — agregat okna rekordowego, który dodaje W-1, oraz przesunięcie >N, którego rekord n niesie rekord n-N. Pozostałe operatory origin wyłącznie przenoszą, tym samym odwzorowaniem indeksu, którym czytają dane.

Dla @ i >N postać jest zamknięta; dla +, #, -, Theta i ~Theta przebieg szuka najmniejszego indeksu osiągającego próg składowej, połowiąc po niemalejącym odwzorowaniu. Listing planu pokazuje wartość jako origin=.

computeStartupLatency

Oblicza query::startupLatency, czyli liczbę początkowych slotów własnego interwału strumienia, w których istniejący wynik nie jest jeszcze gotowy. Źródła mają ogon 0; >N daje max(0, W_src − N), bo czyta rekord starszy od bieżącego; przeplot uwzględnia ogony obu wejść i fazę rzeczywiście wybieranej składowej; suma bierze maksimum granic dostępności obu wejść. Różnica oraz oba rozploty używają dokładnych granic fazowych — lewy rozplot nie dodaje bezwarunkowo jednego slotu. AGSE używa granicy wynikającej z najnowszego pola okna, a redukcje i agregaty okna rekordowego nie dodają własnego ogona. Listing planu pokazuje wartość jako tail=; runtime nie emituje podczas ogona żadnego rekordu. Liczba slotów milczenia wynosi origin + tail.

Ten przebieg biegnie po computeLogicalOrigin i przed obliczeniem pojemności: ogon zależy od tego, które sloty są rekordami, a wymagana historia — od chwili pierwszej emisji konsumenta.

computeRequiredCapacities

Oblicza wymagane pojemności buforów dla każdego strumienia na podstawie odległości między czołem producenta a indeksem czytanym przez konsumenta. Dla przesunięcia >N odległość wsteczna wynosi W_out-W_src+N, więc podstawowa pojemność to W_out-W_src+N+1. Jeśli źródłem jest deklaracja, dochodzą dwa rekordy wyprzedzenia: rekord uzbrojony przy otwarciu storage i zerowy prefetch. Wynik jest ograniczany od dołu do jednego rekordu. Pojemność historii jest wymaganiem wykonawczym, a nie prefiksem wyniku.

Agregat okna rekordowego wymaga zachowania co najmniej W kolejnych rekordów wskazanego źródła. Pojemność uwzględnia także jego początek logiczny, ogon i różnicę interwałów tak samo jak pozostałe odczyty historii; nie jest dobierana wyłącznie jako lokalne W.

validateConstraints

Weryfikuje poprawność semantyczną skompilowanego planu: zgodność typów i płaskich szerokości, rozmiary okien, dostępność źródeł danych oraz ograniczenia operatorów. Przeplot # wymaga równych płaskich schematów, niezależnie od tego, czy wejście zapisano jako T[N], czy jako N pól skalarnych.

applyCapacitiesToStreams

Aplikuje obliczone pojemności do obiektów strumieni.

Dla przeplotu kompilator redukuje stosunek \(\Delta_a/\Delta_b=p/q\) do względnie pierwszych dodatnich \(p,q\) i przegląda jeden pełny okres fazowy \(p+q\). Dla każdego slotu \(i\) tego okresu ustala, którą składową wybiera przeplot i pod jakim indeksem \(j(i)\), po czym bierze maksimum wymaganego opóźnienia:

\[ W_{\#} =\max_{0\le i<p+q}\left( \left\lceil\frac{\bigl(j(i)+1+W_{s(i)}\bigr)\Delta_{s(i)}}{\Delta_c}\right\rceil -1-i \right) \]

Wynik jest dokładny — ani nie zaniża, ani nie zawyża granicy przyczynowej. Rachunek prowadzony jest w arytmetyce 64-bitowej, bo iloczyn \((j+1+W)\cdot\text{licznik}\cdot\text{mianownik}\) przekracza zakres int już dla umiarkowanych interwałów. Powyżej progu kHashPhaseScanLimit (SOperations.hpp) koszt przeglądu przestaje być akceptowalny i wraca poprzednia postać zamknięta \(\lceil(p+q-1)/p\rceil\), która zawyża ogon o slot — wybór bezpieczny, bo zaniżenie oznaczałoby emisję rekordu przed określeniem jego zależności.

Regresje obejmują między innymi stosunki \(3/5\), \(3/2\), \(7/11\) i \(160/147\), w tym okresowe rekordy w całości NULL w nieprzepisanej lewej stronie tożsamości R1; wzór operatorowy pilnuje test ut_h10aGate.

topologicalSort

Bezwarunkowo przywraca końcowy porządek producent–konsument. Jest to część poprawności wykonania, nie kosmetyka prezentacji planu: interwał wyniku # jest mniejszy od interwałów wejść, więc wcześniejsze sortowanie po interwale może przesunąć konsumenta przed producentów.

Przebiegi przepisujące plan są dodatkowo otoczone kontrolą verifyUserFieldNamesPreserved(). Optymalizacja może zmieniać i usuwać substraty wewnętrzne, ale nie może zmienić nazw pól żadnego publicznego strumienia, ponieważ trafiają one do obserwowalnego deskryptora .desc.

Etapy kontrolne i przepisujące zwracają "OK" lub komunikat błędu — wówczas kompilacja się zatrzymuje. Wyniku tego rodzaju nie zwracają snapshotNamedSourceRefs, computeRequiredCapacities (zwraca mapę pojemności) ani topologicalSort. Część niespójności planu, np. odwołanie do nieistniejącego strumienia, przerywa kompilację wyjątkiem zamiast komunikatu.

Budowa drzewa zależności

Drzewo zależności to plan realizacji zapytań w postaci grafu skierowanego. Jest to struktura danych, która budowana jest w trakcie kompilacji oraz modyfikowana w trakcie dodawania zapytań AdHoc. Korzeniami tego grafu są deklaracje efemerydów. Wszelkiej postaci deklaracje tworzące obiekty zewnętrzne – tzw. Źródła danych. Wewnątrz grafu występują artefakty i substraty. Na końcu łańcucha przetwarzania znajdują się artefakty – jako wyniki końcowe łańcucha.

Taka konstrukcja to graf skierowany. Graf, który posiada wiele korzeni i wiele wierzchołków końcowych. Wewnątrz grafu znajdują się węzły łączące. Każdy węzeł znajduje się na drodze od korzenia do wierzchołka końcowego. Najlepiej to zwizualizuje przykład.

Na początku rozważmy następujące trywialne zapytanie:

DECLARE a UINT STREAM core0, 0.1 FILE 'datafile1.txt'
SELECT str1[0] STREAM str1 FROM core0

Graf, w którym uwypuklone zostaną dependencje pomiędzy poszczególnymi obiektami uzyskamy w następujący sposób (Rys. 33):

$ xretractor -c query5.rql -d > out.dot && dot -Tsvg out.dot -o out.svg

Pełny opis flag -d -f -s i interpretacja wyjścia — patrz Debugowanie kompilacji.

Rys. 33. Dependencja efemeryd-artefakt

Skomplikujmy trochę ten graf dodając dwie deklaracje efemerydów i dodatkowy artefakt.

DECLARE a UINT STREAM core0, 0.1 FILE 'datafile1.txt'
DECLARE a UINT STREAM core1, 0.1 FILE 'datafile2.txt'
SELECT str1[0] STREAM str1 FROM core0
SELECT str2[0] STREAM str2 FROM core0 + core1

Graf zależności dla powyższego zestawu zapytań prezentuje się następująco (Rys. 34):

Rys. 34. Dependencja efemerydy-artefakty

Zbudujmy dodatkowy węzeł zależny od artefaktów. Najprościej dodać następujące zapytanie na końcu:

SELECT str3[0] STREAM str3 FROM str1#str2

Graf zmieni swoją postać:

Rys. 35. Dependencja efemerydy-artefakty-artefakty

Jak widać na Rys. 35 strumień str3 nie jest zależny bezpośrednio od danych dostarczanych przez strumienie core0 i core1. Zapytania tworzą graf zależności a kolejności ich wywoływania jest uporządkowana. Wartość interwału w strumieniach rośnie w kierunku korzeni. Wzrost w kierunku korzenia wynika z równań wyznaczających interwały opracowanej algebry.

Proszę zwrócić uwagę, że zapytania w pliku rql przetwarzane są sekwencyjnie. Próba odwołania się w zapytaniu do obiektu, który nie jest jeszcze zdefiniowany, skończy się błędem kompilacji.

W przypadku dołączenia do drzewa zależności następującego zapytania wytworzymy dodatkowy substrat.

SELECT str4[0] STREAM str4 FROM (core1+core0)>2

Tak dołączone zapytanie spowoduje modyfikację drzewa zależności w sposób przedstawiony na Rys. 36.

Rys. 36. Dependencja z substratem

Substrat został oznaczony innym kolorem oraz oznaczeniem Auto znajdującym się obok interwału czasowego.

Graf zależności musi być acyklicznym grafem skierowanym (DAG). Próba zdefiniowania strumienia odwołującego się do własnych wyników tworzy cykl i kończy się błędem kompilacji. Mechanizm wykrywania opisany jest w rozdziale Wykrywanie pętli w kompilacji.

NOTE: Opisana funkcjonalność ma pokrycie w teście: subquery opisanym w załączniku pt. Testy Integracyjne.

Substraty

O substratach, efemerydach i artefaktach wspomniałem w rozdziale dotyczącym architektury systemu. W tym przypadku przedstawię przykład.

Na początek chciałbym zwrócić uwagę na pewną własność wprowadzonych wyrażeń algebraicznych. W praktyce możemy zapisać dowolne wyrażenie, skompilować i przedstawić wzór na operacje na poszczególnych elementach serii czasowych umożliwiających uzyskanie pożądanego wyniku.

W praktyce w systemie realizuję wyłącznie operacje jedno lub dwuargumentowe. Przykładem operacji jednoargumentowych to przesunięcie w czasie lub operacja Agse. Tam argumentem jest tylko jeden strumień danych. Reszta operacji to operacje na dwóch strumieniach danych. W trakcie kompilacji wszystkie wyrażenia algebraiczne rozbijane są na takie, które mają dwa argumenty.

Parser akceptuje zarówno formę z nawiasami, jak i łańcuchy bez nawiasów, np. s1+s2+s3, s1#s2#s3 oraz s1+s2+s3+s4. Taki zapis jest następnie redukowany do sekwencji operacji dwuargumentowych z automatycznymi substratami pośrednimi.

Przykład używa kanonicznych deklaracji z całego rozdziału — trzy strumienie o różnych typach i interwałach:

DECLARE a BYTE, b INTEGER \
STREAM core0, 0.1 \
FILE 'sensor_a.txt'

DECLARE c INTEGER, d FLOAT \
STREAM core1, 0.2 \
FILE 'sensor_b.txt'

DECLARE e INTEGER \
STREAM core2, 0.3 \
FILE 'sensor_c.txt'

SELECT merged[0] \
STREAM merged \
FROM (core0 # core1) + core2

Kompilacja:

$ xretractor -c query.rql
core0(1/10)	sensor_a.txt
	a: BYTE
	b: INTEGER
core1(1/5)	sensor_b.txt
	c: INTEGER
	d: FLOAT
STREAM_HASH_core0_core1(1/15)	tail=2
	:- PUSH_STREAM(core0)
	:- PUSH_STREAM(core1)
	:- STREAM_HASH
	a: INTEGER
		PUSH_ID(STREAM_HASH_core0_core1[0])
	b: FLOAT
		PUSH_ID(STREAM_HASH_core0_core1[1])
core2(3/10)	sensor_c.txt
	e: INTEGER
merged(1/15)	tail=4
	:- PUSH_STREAM(STREAM_HASH_core0_core1)
	:- PUSH_STREAM(core2)
	:- STREAM_ADD
	merged_0: INTEGER
		PUSH_ID(merged[0])

Pojawił się niezapowiedziany strumień STREAM_HASH_core0_core1 — to właśnie substrat. Kompilator rozbił (core0 # core1) + core2 na dwie operacje dwuargumentowe i wstawił pośredni strumień. Delta substratu: Δ = (1/10 · 1/5) / (1/10 + 1/5) = 1/15.

Co się stanie po dołączeniu zapytania:

SELECT merged2[0] STREAM merged2 FROM (core0 # core1) > 2

Pełny plan po dołączeniu zapytania pokazuje niżej, że dochodzi jeden blok merged2, korzystający z już utworzonego substratu STREAM_HASH_core0_core1:

core0(1/10)	sensor_a.txt
	a: BYTE
	b: INTEGER
core1(1/5)	sensor_b.txt
	c: INTEGER
	d: FLOAT
STREAM_HASH_core0_core1(1/15)	tail=2
	:- PUSH_STREAM(core0)
	:- PUSH_STREAM(core1)
	:- STREAM_HASH
	a: INTEGER
		PUSH_ID(STREAM_HASH_core0_core1[0])
	b: FLOAT
		PUSH_ID(STREAM_HASH_core0_core1[1])
core2(3/10)	sensor_c.txt
	e: INTEGER
merged(1/15)	tail=4
	:- PUSH_STREAM(STREAM_HASH_core0_core1)
	:- PUSH_STREAM(core2)
	:- STREAM_ADD
	merged_0: INTEGER
		PUSH_ID(merged[0])
merged2(1/15)	origin=2
	:- PUSH_STREAM(STREAM_HASH_core0_core1)
	:- STREAM_TIMEMOVE(2)
	merged2_0: INTEGER
		PUSH_ID(merged2[0])

Zastanawiasz się pewnie dlaczego tylko jedno a nie ponownie dwa? Odpowiedź to optymalizacja. Korzystamy z pośrednich wyników poprzedniego. To jedna z nieoczekiwanych korzyści zastosowania RetractorDB.

Jest jeszcze jedna istotna rzecz o której należy wspomnieć w tym punkcie. Istnieje dyrektywa SUBSTRAT, której argumentem jest ciąg znaków ujęty w apostrofy. Można użyć następujących typów ‘memory’, ‘default’, ‘direct’, ‘posix’, ‘posixshd’, ‘generic’, ‘device’, ‘textsource’. Pełny opis każdego typu znajdziesz w rozdziale Typy STORAGE. Domyślny typ ‘default’ spowoduje, że substraty będą materializować się w całości na dysku. To nie jest oczekiwana wartość w systemie produkcyjnym, ale oczekiwana w trakcie rozwoju i debugowania. Typ użyteczny to ‘memory’. Substraty tego typu lądują tylko w pamięci. Ich dane nigdy nie lądują na dysku – wszystko odbywa się w pamięci, danych jest tylko tyle ile jest wymaganych do realizacji zapytań. Reszta typów na chwilę obecną jest nieprzetestowana i znajduje się w fazie rozwojowej.

Dodanie zapytania o tych samych operacjach, ale innej nazwie może spowodować deduplikację substratów. Jeśli program, delta i schemat są równoważne, kompilator przepnie odwołania PUSH_STREAM na istniejący strumień i usunie duplikat.

NOTE: Opisana funkcjonalność ma pokrycie w testach: issue96_no_substrat_reduction, issue96_substrat_reference opisanych w załączniku pt. Testy Integracyjne.

Redukcja substratów

Kompilator realizuje optymalizację zwaną redukcją substratów (funkcja deduplicateSubstrats). Polega ona na tym, że jeśli użytkownik zdefiniował zapytanie strukturalnie identyczne z wygenerowanym substratem, substrat jest usuwany z planu, a jego odwołania zastępowane są nazwą zapytania użytkownika.

Warunki redukcji

Redukcja substratu do zapytania użytkownika następuje wtedy i tylko wtedy, gdy spełnione są jednocześnie trzy warunki:

  1. Ten sam kształt schematu — liczba pól oraz ich typy, rozmiary w bajtach i liczności są identyczne. Nazwy pól nie są porównywane.
  2. Ta sama delta — częstotliwość próbkowania strumieni jest taka sama.
  3. Te same operacje przetwarzania — sekwencja instrukcji PUSH_STREAM / STREAM_TIMEMOVE / STREAM_HASH itp. jest identyczna.

Przykład redukcji

Rozważmy zapytanie z kanonicznymi deklaracjami:

DECLARE a BYTE, b INTEGER   STREAM core0, 0.1 FILE 'sensor_a.txt'
DECLARE c INTEGER, d FLOAT  STREAM core1, 0.2 FILE 'sensor_b.txt'

SELECT merged[0] STREAM merged FROM (core0 > 2) + core1
SELECT shifted[0] STREAM shifted FROM core0 > 2

Bez redukcji kompilator wygenerowałby trzy strumienie: substrat STREAM_TIMEMOVE_2_core0, merged i shifted. Substrat i shifted mają identyczną strukturę — ten sam strumień źródłowy core0 i tę samą operację >2. Po redukcji substrat jest usuwany, a odwołanie PUSH_STREAM(STREAM_TIMEMOVE_2_core0) w merged zostaje zastąpione przez PUSH_STREAM(shifted):

core0(1/10)	sensor_a.txt
	a: BYTE
	b: INTEGER
STREAM_TIMEMOVE_2_core0(1/10)	origin=2
	:- PUSH_STREAM(core0)
	:- STREAM_TIMEMOVE(2)
	a: BYTE
		PUSH_ID(STREAM_TIMEMOVE_2_core0[0])
	b: INTEGER
		PUSH_ID(STREAM_TIMEMOVE_2_core0[1])
core1(1/5)	sensor_b.txt
	c: INTEGER
	d: FLOAT
merged(1/10)	tail=1	origin=2
	:- PUSH_STREAM(STREAM_TIMEMOVE_2_core0)
	:- PUSH_STREAM(core1)
	:- STREAM_ADD
	merged_0: BYTE
		PUSH_ID(merged[0])
shifted(1/10)	origin=2
	:- PUSH_STREAM(core0)
	:- STREAM_TIMEMOVE(2)
	shifted_0: BYTE
		PUSH_ID(shifted[0])

Ważne ograniczenie: tylko substraty są redukowane

Redukcja dotyczy wyłącznie substratów wygenerowanych przez kompilator (isSubstrat = true). Zapytania zdefiniowane jawnie przez użytkownika nigdy nie są redukowane, nawet jeśli dwa z nich mają identyczną strukturę.

Przykład — dwa zapytania użytkownika o tej samej operacji:

DECLARE a BYTE, b INTEGER   STREAM core0, 0.1 FILE 'sensor_a.txt'

SELECT shifted1[0] STREAM shifted1 FROM core0 > 2
SELECT shifted2[0] STREAM shifted2 FROM core0 > 2

Wynik kompilacji zachowa oba strumienie bez żadnej redukcji:

shifted1(1/10)
        :- PUSH_STREAM(core0)
        :- STREAM_TIMEMOVE(2)
        shifted1_0: BYTE
                PUSH_ID(shifted1[0])
shifted2(1/10)
        :- PUSH_STREAM(core0)
        :- STREAM_TIMEMOVE(2)
        shifted2_0: BYTE
                PUSH_ID(shifted2[0])
core0(1/10)     sensor_a.txt
        a: BYTE
        b: INTEGER

Semantyczna decyzja jest tu celowa: użytkownik zadeklarował dwa odrębne strumienie wynikowe i oba mają prawo istnieć niezależnie w planie wykonania. Przebieg deduplicateSubstrats() nie usuwa żadnego z nich. Kompilator może natomiast współdzielić ich wewnętrzne obliczenie, pozostawiając oba publiczne strumienie — opisuje to następny podrozdział.

Współdzielenie równoważnych obliczeń SELECT

Przebieg shareEquivalentSelectComputations() wykrywa jawne zapytania SELECT, które wykonują ten sam program pól nad równoważnymi drzewami FROM zawierającymi STREAM_ADD. Zamiast wykonywać kosztowny program osobno dla każdego zapytania, kompilator tworzy jeden substrat STREAM_SELECT_*. Publiczne strumienie pozostają w planie jako lekkie projekcje tego substratu.

Przykład:

SELECT a[_] * b[_] STREAM c1 FROM a+b
SELECT a[_] * b[_] STREAM c2 FROM b+a

Po rozwiązaniu referencji oba programy pól pobierają te same wartości z a i b, mimo że operator strumieniowy + buduje wejście w innej kolejności. Plan zawiera jedno wspólne obliczenie, przykładowo STREAM_SELECT_c1, oraz dwa publiczne strumienie c1 i c2 wskazujące na jego pola.

Warunki równoważności

Dwa zapytania mogą współdzielić obliczenie tylko wtedy, gdy:

  1. mają ten sam interwał wynikowy;
  2. mają tę samą liczbę i kolejność pól wynikowych;
  3. odpowiadające pola mają ten sam typ, rozmiar i liczność;
  4. po rozwiązaniu referencji i rozwinięciu [_] programy pól pobierają te same pola źródłowe oraz wykonują te same operacje w tej samej kolejności;
  5. drzewa FROM są równoważne z zachowaniem ich grupowania.

Podpis drzewa porządkuje kanonicznie dwoje dzieci każdego pojedynczego węzła STREAM_ADD, dlatego a+b może być równoważne b+a. Nie spłaszcza jednak drzewa i nie korzysta z łączności operatora. Jest to istotne dla trzech źródeł o różnych interwałach:

SELECT p[_] * q[_] + r[_] STREAM x1 FROM (p+q)+r
SELECT p[_] * q[_] + r[_] STREAM x2 FROM (q+p)+r
SELECT p[_] * q[_] + r[_] STREAM x3 FROM (r+q)+p

Zapytania x1 i x2 mogą współdzielić obliczenie, ponieważ zamieniają tylko dzieci tego samego wewnętrznego węzła. Zapytanie x3 ma inne grupowanie i inny substrat pośredni; jego rytm uruchomienia może być inny, dlatego pozostaje niezależne.

Kolejność wejścia jest również widoczna dla pełnego skanu i kolejności projekcji. Następujących par nie wolno scalać:

SELECT * STREAM d1 FROM a+b
SELECT * STREAM d2 FROM b+a

SELECT a[0], b[1] STREAM n1 FROM a+b
SELECT b[1], a[0] STREAM n2 FROM b+a

Zachowanie publicznego kontraktu

Optymalizacja współdzieli wyłącznie wewnętrzne obliczenie. Każdy jawny strumień zachowuje własną nazwę, schemat i deskryptor, reguły, retencję, politykę storage oraz artefakty. Automatyczne nazwy pól mogą zatem nadal różnić się między c1 i c2, mimo że ich dane są identyczne. Nie jest to pełny alias: żaden publiczny strumień nie znika z planu, IPC ani storage.

Po utworzeniu wspólnego STREAM_SELECT_* kompilator usuwa osierocone substraty do punktu stałego. Podczas importu ad-hoc analiza jest ograniczona do nowych identyfikatorów, aby ponowna kompilacja nie przepisała strumieni już utworzonych w aktywnym planie.

Przebieg działa po resolveFieldReferences() i expandIndexWildcards(), ale przed localizeFieldOffsets(). Dzięki temu podpis pola porównuje tożsamość źródła i jego indeks, a nie lokalny offset zależny od kolejności argumentów a+b lub b+a.

Test select_cse_commutative_add sprawdza kształt planu oraz wykonanie. Obejmuje równoważne projekcje indeksowane i jawne listy pól, wartości NULL i ich metadane, osobne deskryptory publiczne, SELECT *, zmianę kolejności pól oraz dodatni i ujemny przypadek trzech źródeł. Test porównuje bajtowo dane równoważnych par i potwierdza różnicę wyników dla kontrprzykładów — patrz Testy Integracyjne.

Eliminacja duplikatów substratów

Gdy kilka zapytań korzysta z tej samej operacji strumieniowej – np. core0 + core1 – faza ekstrakcji substratów (extractIntermediateStreams) tworzy dla każdego z nich osobny substrat. Bez kolejnej fazy naprawczej w grafie powstawałyby równoległe, identyczne węzły pośrednie obliczające dokładnie tę samą wartość.

Kiedy substrat jest tworzony

Substrat generowany jest dla każdego zapytania, którego program zawiera więcej niż jeden operator strumieniowy. Dotyczy to operatorów: STREAM_ADD, STREAM_SUBTRACT, STREAM_HASH, STREAM_DEHASH_DIV, STREAM_DEHASH_MOD, STREAM_TIMEMOVE, STREAM_AGSE. Warunek sprawdza funkcja query::isReductionRequired().

Nowo powstałemu substratowi nadawana jest nazwa zbudowana z symbolu operacji, jej parametru oraz nazw operandów, np. STREAM_ADD_core1_core0, STREAM_TIMEMOVE_2_core0 albo STREAM_AGSE_1_10_core0 (funkcja composeStreamName w compiler.cpp). Parametr jest częścią tożsamości: dwa różne okna nad tym samym źródłem nie mogą otrzymać tej samej nazwy. Znaki niedozwolone w identyfikatorze są kodowane, np. liczba ujemna używa N, a kreska ułamkowa _.

Czytelna nazwa rośnie wraz ze złożonością klauzuli FROM i jest jednocześnie nazwą pliku substratu. Po przekroczeniu 200 bajtów kompilator zachowuje nazwę operatora, a resztę zastępuje stabilnym, 16-znakowym skrótem FNV-1a, np. STREAM_ADD_x92741c15f69ba93f. Dzięki temu szerokie wyrażenie nie przekracza systemowego NAME_MAX; ta sama struktura planu zawsze dostaje tę samą nazwę, a inna kolejność operandów daje inny skrót. validateSubstratNameUniqueness() zatrzymuje kompilację, gdyby jeden skrót kiedykolwiek oznaczał dwa różne programy.

W programie zapytania macierzystego token operatora zastępowany jest tokenem PUSH_STREAM wskazującym na substrat.

Prawo wynoszenia wspólnego przesunięcia czasu przed przeplot

ℹ Info Polska nazwa jest świadomie opisowa i nie stanowi dosłownego tłumaczenia angielskiego terminu matched interleave-shift factorization, pozostawionego w angielskiej wersji dokumentacji.

Po wyodrębnieniu substratów i rozwiązaniu ich interwałów kompilator stosuje regułę algebraiczną:

\[ (A > i) \mathbin{\#} (B > k) \longrightarrow (A \mathbin{\#} B) > (i+k), \qquad i\Delta_{a}=k\Delta_{b} \]

Warunek \(i\Delta_{a}=k\Delta_{b}\) oznacza, że oba argumenty przeplotu są przesunięte o ten sam czas fizyczny. Bez tego warunku przekształcenie nie jest równoważne i kompilator pozostawia pierwotny plan.

Niech zredukowany stosunek \(\Delta_a/\Delta_b\) będzie równy \(p/q\). Ogon przeplotu chroni wszystkie fazy okresu \(p+q\), bo kompilator przegląda ten okres slot po slocie i bierze maksimum wymaganego opóźnienia — wzór i uzasadnienie w rozdziale Formalne podstawy i dowody.

Przesunięcie jest opóźnieniem realizacji przyczynowej: przesuwa początek logiczny O o N, a swój ogon ustawia na \(\max(0,W_S-N)\) — nie zmienia ciągu rekordów i nie wstawia prefiksu. Dla \(\Delta_c=\Delta_a\Delta_b/(\Delta_a+\Delta_b)\) warunek dopasowania daje dokładnie:

\[ \frac{i\Delta_a}{\Delta_c} =\frac{k\Delta_b}{\Delta_c} =i+k \]

Dlatego przesunięcie każdego wejścia odpowiada tej samej liczbie i+k slotów wyjścia i początek logiczny obu stron jest identyczny. Ogony identyczne nie są. Strona sfaktoryzowana czyta treść wprost z przeplotu, a strona niesfaktoryzowana — dopiero po własnym przesunięciu składowych, więc czeka dłużej:

\[ W_{\mathrm{RHS}}=\max\left(0,;W_{\varphi(A,B)}-(i+k)\right)\le W_{\mathrm{LHS}} \]

Reguła zachowuje więc emitowany ciąg, interwał i origin=, a tail= może zmniejszyć. Jest optymalizacją opóźnienia, nie przepisaniem neutralnym; pełny dowód i kontrprzykład: Formalne podstawy i dowody.

Przed optymalizacją plan zawiera dwa substraty:

STREAM_TIMEMOVE_i_A = A > i
STREAM_TIMEMOVE_k_B = B > k
result = STREAM_TIMEMOVE_i_A # STREAM_TIMEMOVE_k_B

Po optymalizacji pozostaje jeden:

STREAM_HASH_A_B = A # B
result = STREAM_HASH_A_B > (i + k)

Przebieg factorMatchedHashTimeMoves() nie usuwa jawnych strumieni użytkownika ani substratów używanych przez innych konsumentów. Wykonuje się przed deduplikacją, dzięki czemu ujawniony substrat A # B może zostać następnie współdzielony z innym równoważnym planem.

Test issue202_hash_shift_e2e wykonuje obie strony tożsamości na niezależnych kopiach plikowych źródeł danych. Obie strony są tu sfaktoryzowane do tej samej postaci, więc porównanie jest pełne: bajtowo artefakty matched i CC, ich metadane z pominięciem zarezerwowanego nagłówka, pełna sekwencja wobec wzorca wyprowadzonego z okresu przeplotu B,A,A oraz równość deklaracji (origin=3 przy zerowym ogonie — \(\tau_3\) nad przeplotem o ogonie 2 pochłania go w całości). Żadna strona nie emituje rekordów zastępczych. Osobno computeRequiredCapacities() przydziela źródłu deklarowanemu N+1+2 rekordów historii: N+1 na sam zakres odczytu oraz dwa na wyprzedzenie czoła deklaracji, którego adresowanie indeksem logicznym nie skraca.

Test r1_identity_nulls sprawdza tę samą tożsamość dla stosunku \(\Delta_a/\Delta_b=3/2\), który wymaga maksimum fazowego \(H_{a,b}=2\), chociaż pierwsza faza wymaga tylko jednego slotu. Porównuje przepisany plan, zablokowaną przed przepisaniem lewą stronę i jawną prawą stronę. Plan przepisany i jawna prawa strona są równe w pełni. Lewa strona zablokowana przed przepisaniem ma ten sam początek logiczny i tę samą treść, ale ogon ściśle większy — porównanie obejmuje wspólny prefiks payloadu i mapy NULL, a osobna asercja wymaga, żeby strona sfaktoryzowana była ściśle dłuższa. Niepusty, okresowy rekord w całości NULL chroni przed ukryciem błędnego ogona przez brak danych. Testy jednostkowe kompilatora obejmują również stosunki \(3/5\), \(7/11\) i \(160/147\).

Algorytm deduplikacji

Po ekstrakcji substratów i wyznaczeniu interwałów czasowych kompilator uruchamia krok deduplicateSubstrats(). Algorytm działa iteracyjnie – pętla while(changed) powtarza przeszukiwanie aż do momentu, gdy żadna para duplikatów nie zostanie już znaleziona.

W każdym przebiegu dla każdej pary substratów (it, it2) sprawdzane są kolejno pięć warunków równoważności:

  1. Interwał czasowyit->rInterval == it2->rInterval
  2. Długość programu – liczba tokenów w lProgram musi być identyczna
  3. Długość schematu – liczba pól w lSchema musi być identyczna
  4. Zawartość programu – każdy token porównywany jest według typu polecenia (getCommandID()) i wartości parametru (getVT())
  5. Zawartość schematu – każde pole porównywane jest według typu (rtype), rozmiaru w bajtach (rlen) i liczności (rarray)

Jeśli wszystkie warunki są spełnione, substrat it uznawany jest za duplikat substratu it2. Kompilator przechodzi przez cały coreInstance i we wszystkich tokenach PUSH_STREAM odnoszących się do starej nazwy (it->id) podstawia nową nazwę (it2->id). Następnie duplikat jest usuwany z listy zapytań (coreInstance.erase(it)), a pętla startuje od początku.

Miejsce w potoku kompilacji

Deduplikacja jest dziewiątym z dwudziestu trzech etapów potoku (funkcja compiler::compile()):

  • checkFunctionCalls — nazwy i arność funkcji skalarnych
  • checkStreamReducerFieldRefs — reduktor strumieniowy poza klauzulą FROM
  • expandStreamGenerators — rozwinięcie rodzin strumieni
  • snapshotNamedSourceRefs — migawka odwołań użytkownika
  • extractIntermediateStreams — wyodrębnienie substratów
  • expandSchemaWildcards — rozwinięcie * oraz [_]
  • resolveStreamIntervals — obliczenie interwałów czasowych
  • factorMatchedHashTimeMoves — prawo wynoszenia wspólnego przesunięcia czasu przed przeplot
  • deduplicateSubstrats — eliminacja duplikatów ← ten krok
  • validateSubstratNameUniqueness — kontrola jednoznaczności nazw
  • resolveFieldReferences — rozwiązanie referencji do pól
  • resolveWindowAggregates — grupy agregatów okna rekordowego
  • inferFieldShapes — kształt pól wynikowych
  • checkRuleConditionShapes — obliczalność warunków reguł
  • simplifyFieldExpressions — uproszczenie programów pól i reguł
  • shareEquivalentSelectComputations — współdzielenie równoważnych obliczeń SELECT
  • localizeFieldOffsets — wyznaczenie przesunięć pól
  • computeLogicalOrigin — wyznaczenie początku logicznego
  • computeStartupLatency — obliczenie ogonów startowych
  • computeRequiredCapacities — obliczenie wymaganej historii
  • validateConstraints — kontrola ograniczeń operatorów
  • applyCapacitiesToStreams — zastosowanie pojemności
  • topologicalSort — końcowy porządek producent–konsument

Wyniesienie wspólnego przesunięcia czasu przed przeplot i deduplikacja muszą nastąpić po rozwiązaniu interwałów, ponieważ obie operacje je porównują. Deduplikacja następuje po przepisaniu algebraicznym, aby mogła scalać ujawnione przez nie substraty przeplotu.

Współdzielenie obliczeń SELECT następuje dopiero po rozwiązaniu referencji i rozwinięciu [_], ponieważ porównuje gotowe programy pól. Musi jednak poprzedzać lokalizację offsetów, aby równoważne źródła nie wyglądały na różne wyłącznie z powodu kolejności w lokalnym buforze wejściowym.

Każdy przebieg przepisujący (factorMatchedHashTimeMoves, deduplicateSubstrats, shareEquivalentSelectComputations) jest otoczony kontrolą verifyUserFieldNamesPreserved(). Nazwy pól publicznych strumieni są częścią deskryptora .desc i nie mogą zmienić się wskutek optymalizacji. Ogon jest liczony dopiero dla ostatecznego planu. Końcowe sortowanie topologiczne jest bezwarunkowe, ponieważ wcześniejsze sortowanie po interwale może umieścić szybszego konsumenta # przed jego producentami.

Efekt w grafie zależności

Rozważmy zapytania:

DECLARE a UINT STREAM core0, 0.1 FILE 'datafile1.txt'
DECLARE a UINT STREAM core1, 0.1 FILE 'datafile2.txt'
SELECT str4[0] STREAM str4 FROM (core0+core1)>2
SELECT str5[0] STREAM str5 FROM (core0+core1)>3

Oba zapytania wymagają uprzedniego obliczenia sumy core0+core1.

Faza extractIntermediateStreams tworzy osobny substrat dla każdego zapytania, co daje dwa identyczne węzły pośrednie w grafie (Rys. 37):

Rys. 37. Graf przed deduplikacją — dwa identyczne substraty STREAM_ADD_core0_core1

Po uruchomieniu deduplicateSubstrats() jeden z duplikatów jest usuwany, a wszystkie odwołania PUSH_STREAM przepinane są do ocalałego węzła. W grafie pozostaje jeden wspólny substrat (Rys. 38):

Rys. 38. Graf po deduplikacji — jeden wspólny substrat, wygenerowany poleceniem: xretractor dedup_after.rql -c -d

Graf po deduplikacji to dokładnie to, co zwraca xretractor -c -d — kompilator zawsze prezentuje wynik po wszystkich fazach optymalizacji.

Wchłonięcie substratu przez jawny strumień

Pętla wewnętrzna w deduplicateSubstrats() nie sprawdza flagi isSubstrat dla kandydata it2 — sprawdzenie to istnieje tylko w pętli zewnętrznej. Oznacza to, że substrat automatyczny może zostać wchłonięty nie tylko przez inny substrat, ale przez dowolny strumień o identycznym programie i schemacie — w tym przez strumień zdefiniowany jawnie przez użytkownika.

Rozważmy zapytanie zawierające wyłącznie złożone wyrażenie:

DECLARE a UINT STREAM core0, 0.1 FILE 'datafile1.txt'
DECLARE a UINT STREAM core1, 0.1 FILE 'datafile2.txt'
SELECT str4[0] STREAM str4 FROM (core0+core1)>2

extractIntermediateStreams wyodrębnia tutaj substrat STREAM_ADD_core0_core1 dla wyrażenia core0+core1. Artefakt str4 zależy od niego (Rys. 39):

Rys. 39. Graf z automatycznym substratem STREAM_ADD_core0_core1

Gdy użytkownik doda jawną deklarację strumienia będącego dokładnie tą samą sumą:

SELECT * STREAM mysum FROM core0+core1

substrat STREAM_ADD_core0_core1 spełnia wszystkie warunki równoważności względem mysum — identyczny interwał, identyczny program tokenów, identyczny schemat pól. Faza deduplicateSubstrats() usuwa substrat i przepina wszystkie odwołania PUSH_STREAM na mysum. Substrat znika z grafu w zupełności (Rys. 40):

Rys. 40. Graf po dodaniu SELECT * STREAM mysum FROM core0+core1 — substrat zastąpiony przez jawny strumień

Efekt uboczny: mysum staje się węzłem wspólnym — obsługuje zarówno własnych konsumentów, jak i tych, którzy wcześniej korzystali z automatycznego substratu. Użytkownik zyskuje przy tym jawną nazwę dla wyników pośrednich i może odpytywać je przez xqry.

Aktualizacja schematu po wchłonięciu

Samo przepięcie tokenów PUSH_STREAM to za mało. Każdy strumień przechowuje w lSchema sekwencję instrukcji opisujących, jak zbudować wartość wyjściową każdego pola — w tym tokeny PUSH_ID(nazwa_strumienia, N), które mówią: „weź N-te pole z bufora wejściowego o nazwie nazwa_strumienia“. Gdy substrat zostaje wchłonięty, te tokeny wciąż odnoszą się do starej, usuniętej nazwy substratu. Krok localizeFieldOffsets() buduje mapę offsetów na podstawie tokenów PUSH_STREAM w programie — jeśli klucz z PUSH_ID nie pasuje do żadnego wpisu w mapie, domyślnie przyjmuje offset 0.

Scenariusz błędu przy niezerowym offsecie

Rozważmy zapytanie:

DECLARE a INTEGER STREAM s1, 1 FILE 'data1.dat'
DECLARE b INTEGER STREAM s2, 1 FILE 'data2.dat'
DECLARE c INTEGER STREAM s3, 1 FILE 'data3.dat'

SELECT * STREAM mysum  FROM s1+s2
SELECT * STREAM merged FROM s3+(s1+s2)

Kompilator tworzy substrat STREAM_ADD_s1_s2. Strumień merged ma dwa źródła: s3 (offset 0) i substrat STREAM_ADD_s1_s2 (offset 1, bo s3 zajmuje pozycję 0). Funkcja buildOutputSchema zapisuje w merged.lSchema tokeny:

PUSH_ID(STREAM_ADD_s1_s2, 0)   ← pole a ze źródła na offsecie 1
PUSH_ID(STREAM_ADD_s1_s2, 1)   ← pole b ze źródła na offsecie 1

Po wchłonięciu deduplicateSubstrats() przepina PUSH_STREAM z STREAM_ADD_s1_s2 na mysum. Jednak bez aktualizacji lSchema tokeny PUSH_ID wciąż noszą starą nazwę. Gdy localizeFieldOffsets() nie znajdzie STREAM_ADD_s1_s2 w mapie offsetów, przyjmuje offset 0 — kolizję z polami s3. Efekt: pola a i b z mysum były odczytywane z offsetu 0 (pozycja s3) zamiast z offsetu 1 (pozycja mysum).

Poprawka: aktualizacja lSchema w deduplicateSubstrats

Aby uniknąć tej rozbieżności, deduplicateSubstrats() po zaktualizowaniu tokenów PUSH_STREAM wykonuje dodatkowy przebieg przez lSchema wszystkich zapytań i przepisuje:

  • tokeny PUSH_ID(stara_nazwa, N) na PUSH_ID(nowa_nazwa, N) — to przypadek pól z buildOutputSchema dla STREAM_ADD,
  • tokeny PUSH_ID2("stara_nazwa[N]") na PUSH_ID2("nowa_nazwa[N]") — to przypadek symbolicznych nazw tworzonych przez buildOutputSchema dla STREAM_TIMEMOVE, STREAM_HASH, STREAM_SUBTRACT.

Po poprawce wyjście kompilatora dla powyższego przykładu wygląda poprawnie:

merged(1/1)
        :- PUSH_STREAM(mysum)
        :- PUSH_STREAM(s3)
        :- STREAM_ADD
        a: INTEGER
                PUSH_ID(merged[1])
        b: INTEGER
                PUSH_ID(merged[2])

Pola a i b z mysum mają offset 1 (merged[1], merged[2]), co odpowiada faktycznej pozycji mysum w buforze merged — po polu c ze strumienia s3.

Kaskadowe wchłonięcie

NOTE: Opisana funkcjonalność ma pokrycie w testach: issue167_dedup_cascaded, issue167_dedup_field_names, issue167_dedup_nonzero_offset, issue167_dedup_positive, issue167_triarg opisanych w załączniku pt. Testy Integracyjne.

deduplicateSubstrats() działa iteracyjnie (while(changed)), co pozwala na wielokrokowe wchłonięcia. W przykładzie:

SELECT * STREAM mysum   FROM s1+s2
SELECT * STREAM shifted FROM (s1+s2)>1
SELECT * STREAM merged  FROM s3+((s1+s2)>1)

w pierwszej rundzie mysum wchłania STREAM_ADD_s1_s2 i przepisuje jego nazwy — również w schemacie pośredniego substratu STREAM_TIMEMOVE_STREAM_ADD_s1_s2. Dzięki temu w drugiej rundzie shifted może wchłonąć ten substrat (warunek programowy jest teraz spełniony, bo oba wskazują na mysum). Po dwóch rundach w planie nie pozostaje żaden substrat automatyczny, a merged korzysta bezpośrednio z s3 i shifted.

Rozwijanie symbolu *

Każdy, który pisał w języku SQL poznał magiczny znak * w tym języku. Wywołanie polecenia SELECT z tym argumentem rozwinie listę argumentów w oparciu o schematy tabel powstałych w wyniku złączeń relacyjnych. Coś podobnego chciałem osiągnąć w języku RQL.

Przykład używa kanonicznych deklaracji z całego rozdziału:

DECLARE a BYTE, b INTEGER \
STREAM core0, 0.1 \
FILE 'sensor_a.txt'

DECLARE c INTEGER, d FLOAT \
STREAM core1, 0.2 \
FILE 'sensor_b.txt'

SELECT * \
STREAM merged \
FROM core0 + core1

SELECT merged[2] \
STREAM result \
FROM merged

Skompilujmy i zobaczmy efekt:

$ xretractor -c query.rql
core0(1/10)	sensor_a.txt
	a: BYTE
	b: INTEGER
core1(1/5)	sensor_b.txt
	c: INTEGER
	d: FLOAT
merged(1/10)	tail=1
	:- PUSH_STREAM(core0)
	:- PUSH_STREAM(core1)
	:- STREAM_ADD
	core0_0: BYTE
		PUSH_ID(merged[0])
	core0_1: INTEGER
		PUSH_ID(merged[1])
	core1_2: INTEGER
		PUSH_ID(merged[2])
	core1_3: FLOAT
		PUSH_ID(merged[3])
result(1/10)	tail=1
	:- PUSH_STREAM(merged)
	result_0: INTEGER
		PUSH_ID(result[2])

Symbol * zamienił się w cztery pola: core0_0, core0_1, core1_2, core1_3. Konwencja nazewnictwa: nazwa strumienia źródłowego + absolutna pozycja w schemacie wynikowym. Typy pól decydują o kolejności — core0 wnosi BYTE i INTEGER na pozycje 0 i 1, core1 wnosi INTEGER i FLOAT na pozycje 2 i 3. Odwołując się przez merged[2] w zapytaniu result dostajemy pole typu INTEGER — trzecie w kolejności, pierwsze z core1.

NOTE: Opisana funkcjonalność ma pokrycie w teście: Pattern3 opisanym w załączniku pt. Testy Integracyjne.

Rozwiązywanie interwałów

Każdy strumień w RetractorDB ma przypisany interwał czasowy — delta (Δ). Interwał określa, jak często produkowane są nowe wartości. Dla strumieni deklarowanych (DECLARE) interwał podaje użytkownik. Dla strumieni wynikowych (SELECT) interwał wyznacza kompilator z równań algebry strumieni.

Przykłady w tym rozdziale używają kanonicznych deklaracji z całego rozdziału: core0 (Δ=1/10), core1 (Δ=1/5), core2 (Δ=3/10).

Algorytm

Etap resolveStreamIntervals działa iteracyjnie:

prevUnresolved = ∞
pętla:
    unresolvedCount = 0
    posortuj qTree topologicznie
    dla każdego zapytania:
        jeśli delta strumieni źródłowych znana:
            wyznacz deltę wynikową z równania operatora
        w przeciwnym razie:
            unresolvedCount++
    jeśli unresolvedCount == 0: koniec (sukces)
    jeśli unresolvedCount >= prevUnresolved: błąd (pętla w grafie)
    prevUnresolved = unresolvedCount

Każda runda rozwiązuje co najmniej jeden strumień — bo graf jest acykliczny i sortowanie topologiczne gwarantuje, że źródła są przetwarzane przed wynikami. Jeśli liczba nierozwiązanych strumieni nie maleje, oznacza to cykl — patrz Wykrywanie pętli.

Równania operatorów

Suma strumieni (+, STREAM_ADD)

SELECT ... STREAM c FROM a + b

\[\Delta_c = \min(\Delta_a, \Delta_b)\]

Strumień wynikowy produkuje wartości tak często, jak szybszy ze strumieni wejściowych.

Przykład: core0(Δ=1/10) + core1(Δ=1/5) → str1(Δ=1/10)

Synchronizacja strumieni (#, STREAM_HASH)

SELECT ... STREAM c FROM a # b

\[\Delta_c = \frac{\Delta_a \cdot \Delta_b}{\Delta_a + \Delta_b}\]

Wynik odpowiada średniej harmonicznej interwałów — strumień produkuje wartości tylko wtedy, gdy oba wejścia są dostępne jednocześnie.

Przykład: core0(Δ=1/10) # core1(Δ=1/5) → str1(Δ=1/15)

Przesunięcie w czasie (>n, STREAM_TIMEMOVE)

SELECT ... STREAM c FROM a > n

\[\Delta_c = \Delta_a\]

Przesunięcie nie zmienia częstotliwości ani ciągu emitowanych rekordów, zmienia natomiast indeks, pod którym ten ciąg się pojawia: rekord m niesie treść rekordu m-n. Jest przyczynowym opóźnieniem, ale jego nośnikiem jest początek logiczny, a nie ogon — rekordy o indeksie mniejszym od n nie mają definicji. Własny ogon operatora jest niedodatni: wynosi max(0, W_src − n), bo rekord m-n jest starszy od bieżącego i tym bardziej dostępny. Listing planu pokazuje obie wielkości jako origin= i tail=; sloty milczenia to ich suma i runtime nic w nich nie emituje.

Historia źródła wymaga n+1 rekordów na sam zakres odczytu, a dla źródła deklarowanego dodatkowo dwóch na wyprzedzenie czoła (rekord uzbrojony przy otwarciu storage i zerowy prefetch), którego adresowanie indeksem logicznym nie skraca.

Reduktory strumieniowe (MAX, MIN, AVG, SUMC)

\[\Delta_c = \Delta_a\]

Reduktory działają na całym wyrażeniu strumieniowym, np. AVG(a@(1,10)). Redukują wartości w rekordzie lub oknie, ale interwał strumienia wyjściowego pozostaje taki sam jak źródłowego. Postać przyrostkowa .max, .min, .avg, .sumc jest zgodna wstecz, lecz wygaszana.

Algorytm AGSE (@(step, window), STREAM_AGSE)

SELECT ... STREAM c FROM a @ (step, window)

\[\Delta_c = \frac{\Delta_a \cdot \text{step}}{\text{windowSize}}\]

AGSE (Algorytm Generowania Serii Epizodów) generuje okna przesuwne. Interwał wynikowy zależy od kroku i rozmiaru okna względem źródła.

Operatory de-hash (STREAM_DEHASH_DIV, STREAM_DEHASH_MOD)

Operacje odwrotne do # — wyznaczają, jaki interwał miał jeden ze strumieni wejściowych, znając interwał wyniku i drugiego argumentu:

\[\Delta_a = \frac{\Delta_c \cdot \Delta_b}{\left|\Delta_c - \Delta_b\right|}\]

Dlaczego iteracja?

W zapytaniu z wieloma strumieniami wynikowymi jeden strumień może zależeć od drugiego:

DECLARE a INTEGER STREAM core0, 0.1 FILE 'data.dat'
SELECT str1[0] STREAM str1 FROM core0
SELECT str2[0] STREAM str2 FROM str1

W pierwszej rundzie iteracji kompilator wyznacza Δ_str1 = 1/10 (bo Δ_core0 jest znana). W drugiej rundzie — Δ_str2 = 1/10 (bo Δ_str1 jest już znana). Gdyby nie iteracja, str2 musiałoby być zadeklarowane przed str1, co ograniczałoby ekspresywność języka.

Wykrywanie pętli w kompilacji

Graf zależności zapytań musi być acyklicznym grafem skierowanym (DAG). Jeśli zapytanie odwołuje się — bezpośrednio lub pośrednio — do własnych wyników, powstaje cykl. Kompilator wykrywa taką sytuację i kończy kompilację z błędem.

NOTE: Opisana funkcjonalność ma pokrycie w teście: issue95_loopInCompile opisanym w załączniku pt. Testy Integracyjne.

Przykład pętli

DECLARE a BYTE, b INTEGER \
STREAM core0, 0.1 \
FILE 'sensor_a.txt'

DECLARE c INTEGER, d FLOAT \
STREAM core1, 0.2 \
FILE 'sensor_b.txt'

SELECT merged[0]*10, merged[2]+10 STREAM merged FROM core0 + core1
SELECT * STREAM agg FROM MAX(merged)
SELECT * STREAM broken FROM merged + broken

Ostatnie zapytanie definiuje broken jako wynik operacji merged + broken — strumień zależy od samego siebie. Graf zależności zawiera cykl (Rys. 41):

%% pdf-width: 85%
graph LR
    core0 --> merged
    core1 --> merged
    merged --> agg
    merged --> broken
    broken -->|cykl| broken
    style broken fill:#f66,color:#fff

Rys. 41. Cykl w grafie zależności zapytań

Efekt kompilacji

Próba kompilacji takiego pliku kończy się błędem:

$ xretractor brokenQuery.rql -c 2>out.txt
$ echo $?
1
$ cat out.txt
[error] Circular dependency: stream interval resolution stalled with 1 
>> unresolved streams

Komunikat "Circular dependency in stream definitions" pojawia się, gdy etap resolveStreamIntervals wykryje, że liczba nierozwiązanych strumieni przestała maleć. Jak uruchomić kompilację i czytać komunikaty błędów — patrz Debugowanie kompilacji.

Mechanizm wykrywania

Etap resolveStreamIntervals w każdej rundzie iteracji liczy strumienie, dla których nie udało się jeszcze wyznaczyć interwału (unresolvedCount). W poprawnym grafie acyklicznym liczba ta maleje co rundę — zawsze co najmniej jeden strumień uzyskuje wyznaczoną deltę. W grafie z cyklem strumienie wzajemnie od siebie zależą i żaden nie może uzyskać wartości — unresolvedCount zatrzymuje się.

if (unresolvedCount >= prevUnresolved) {
    SPDLOG_ERROR("Circular dependency: stream interval resolution stalled with
>> {} unresolved streams",
                 unresolvedCount);
    return std::string("Circular dependency in stream definitions");
}
prevUnresolved = unresolvedCount;

Warunek >= (a nie >) chroni przed fałszywymi pozytywami: jeśli liczba nie maleje nawet o jeden, postęp jest niemożliwy.

Jak naprawić

Usunąć odwołanie strumienia do samego siebie lub do strumienia, który od niego zależy. W powyższym przykładzie zapytanie:

SELECT * STREAM broken FROM merged + broken

należy zastąpić odwołaniem do strumienia, który istnieje niezależnie od broken:

SELECT * STREAM broken FROM merged + core0

Aliasowanie

W przypadku, w którym złączymy dwa strumienie danych operatorem sumy. Pojawi się nowy schemat danych. Do kolejnych wartości tego schematu możemy odwoływać się poprzez nazwę strumienia danych indeksowanych kolejno względem początku schematu.

Możemy jednak użyć też nazw z jakich strumień powstał. Na wartość wskazywać będzie nazwa strumienia wynikowego indeksowana względem początku schematu, jak również nazwa strumienia źródłowego przesunięta względem pozycji złączenia.

Przykład używa kanonicznych deklaracji z całego rozdziału:

DECLARE a BYTE, b INTEGER \
STREAM core0, 0.1 \
FILE 'sensor_a.txt'

DECLARE c INTEGER, d FLOAT \
STREAM core1, 0.2 \
FILE 'sensor_b.txt'

SELECT merged[0], merged[2], core0[0], core1[0] \
STREAM merged \
FROM core0 + core1

Po kompilacji otrzymamy:

$ xretractor -c query.rql
core0(1/10)	sensor_a.txt
	a: BYTE
	b: INTEGER
core1(1/5)	sensor_b.txt
	c: INTEGER
	d: FLOAT
merged(1/10)	tail=1
	:- PUSH_STREAM(core0)
	:- PUSH_STREAM(core1)
	:- STREAM_ADD
	merged_0: BYTE
		PUSH_ID(merged[0])
	merged_1: INTEGER
		PUSH_ID(merged[2])
	merged_2: BYTE
		PUSH_ID(merged[0])
	merged_3: INTEGER
		PUSH_ID(merged[2])

merged[0] i core0[0] oba trafiają na PUSH_ID(merged[0]) — to to samo pole. Natomiast core1[0] — pierwsze pole schematu core1 — trafia na PUSH_ID(merged[2]), nie merged[0]. Kompilator przetłumaczył lokalny indeks core1[0] na absolutną pozycję w schemacie złączonym: core0 zajmuje pozycje 0 i 1, więc core1 zaczyna się na pozycji 2.

Odwołanie spoza klauzuli FROM

Alias źródłowy działa tylko wtedy, gdy suma stoi bezpośrednio w klauzuli FROM zapytania. Jeżeli suma została nazwana osobnym zapytaniem, lista pól konsumenta widzi wyłącznie ten nazwany strumień:

SELECT * STREAM merged FROM core0 + core1
SELECT merged[0], core1[0] STREAM result FROM merged

Kompilacja kończy się błędem:

Check result:Stream 'result' refers to 'core1', which is not in its FROM clause. A field list reads only the streams named in FROM: refer to the field by its position in the record of a stream in FROM, or move the reference to a query whose FROM names 'core1'.

merged jest zapytaniem użytkownika z własnym interwałem i buforem, więc kompilator nie wyznacza pozycji jego źródeł w rekordzie result. Poprawny zapis wskazuje pole przez pozycję w rekordzie mergedcore1 zaczyna się tam od pozycji 2:

SELECT merged[0], merged[2] STREAM result FROM merged

Ograniczenie nie dotyczy substratów tworzonych automatycznie dla złożonej klauzuli FROM, np. FROM (core0 + core1) > 1: przez nie alias źródłowy nadal działa.

Indeks poza zakresem

Indeks w zapisie strumien[k] musi wskazywać slot, który zapytanie rzeczywiście czyta. Kompilator odrzuca indeks poza tym zakresem, zamiast wygenerować plan czytający za końcem rekordu wejściowego. Granica zależy od tego, do czego odnosi się nazwa:

OdwołanieGranicaPrzykład poprawnyPrzykład odrzucony
strumień z klauzuli FROMliczba slotów, które ten strumień wnosi do FROMcore1[1] przy FROM core0 + core1core1[2]
strumień za oknem albo reduktoremliczba slotów po operatorze, nie szerokość strumieniacore0[2] przy FROM core0@(1,3)core0[3]; acc[1] przy FROM SUMC(acc)
własna nazwa na liście SELECTszerokość rekordu wejściowego FROMmerged[3] w STREAM merged FROM core0 + core1merged[4]
własna nazwa w warunku RULEszerokość rekordu wyjściowego strumieniamerged[3] przy SELECT * STREAM mergedmerged[4]

Okno i reduktor zmieniają liczbę slotów: core0@(1,3) wnosi trzy sloty, choć core0 ma dwa pola, a SUMC(acc) wnosi jeden. Ta sama liczba wyznacza rozwinięcie core0[_], więc zapis ręczny i zapis z _ mają ten sam zakres.

Przykładowe komunikaty:

Check result:Stream 'merged': stream 'core1' has 2 element(s) in its FROM clause, so 'core1[2]' is out of range
Check result:Stream 'merged': the FROM record of 'merged' has 4 element(s), so 'merged[4]' is out of range
Check result:Stream 'merged': rule 'alarm' reads the record of 'merged', which has 4 element(s), so 'merged[4]' is out of range

Indeks zwinięty z $ w generatorze strumieni podlega tej samej kontroli i daje ten sam komunikat co indeks napisany ręcznie.

Opisane wyżej aliasy źródłowe dotyczą operatora sumy +. Suma konkatenizuje schematy, dlatego zachowuje pozycję i tożsamość każdej składowej: core0[0] i core1[0] wskazują różne miejsca w rekordzie wynikowym.

Operator przeplotu # działa inaczej. Oba argumenty muszą mieć równoliczne schematy, a wynik ma jeden wspólny schemat. W danym slocie przeplot wybiera rekord jednej składowej, więc pozycja k lewego i prawego argumentu staje się tą samą pozycją k wyniku. Po wykonaniu A#B nazwa A albo B nie identyfikuje już źródła bieżącego rekordu.

Porównanie kompilacji dla deklaracji core0 i core1 z przykładu pokazuje różnicę bez uruchamiania zapytania:

Wyrażenie FROMOdwołania na liście SELECTWynik kompilacji
core0 + core1core0[0], core1[0]PUSH_ID(merged[0]), PUSH_ID(merged[2]) — schematy są skonkatenowane, więc składowe pozostają rozróżnialne
core0 # core1core0[0], core1[0]błąd kompilacji — oba argumenty dzielą pozycję 0 jednego schematu wyniku

Drugi wiersz odpowiada zapytaniu:

SELECT core0[0], core1[0] STREAM interleaved FROM core0#core1

Kompilator zatrzymuje je komunikatem, że core0 jest składową przeplotu i takiego odwołania nie można odróżnić od odwołania do drugiej składowej. Nie powstaje plan, który po cichu mapowałby oba pola na interleaved[0].

Z tego powodu kompilator odrzuca nazwane odwołania użytkownika, które przez # próbują sięgnąć do jego składowej. Zakaz obejmuje wszystkie formy:

  • indeks liczbowy: A[0];
  • nazwę pola: A.pole oraz gołą nazwę pola rozwiązaną do A;
  • indeks wieloznaczny: A[_];
  • kwalifikowany pełny skan: A.*;
  • te same odwołania w warunku RULE oraz przez substraty wygenerowane dla złożonej klauzuli FROM.

Poprawny zapis odwołuje się do jedynego schematu wyniku:

SELECT wynik[0], wynik[1] STREAM wynik FROM A#B
SELECT wynik2.* STREAM wynik2 FROM A#B

Niekwalifikowane * również oznacza cały schemat wynikowy i pozostaje legalne. Jeżeli dalsze obliczenie wymaga [_], najpierw należy nazwać przeplot, a następnie użyć jego wyniku:

SELECT * STREAM przeplot FROM A#B
SELECT przeplot[_] * 2 STREAM przeskalowany FROM przeplot

Gdy potrzebna jest ponownie konkretna składowa, należy odzyskać ją operatorem rozplotu & albo %, zamiast używać nazwy źródła przez węzeł #.

NOTE: Aliasowanie po + ma pokrycie w teście integracyjnym Pattern7, a odrzucenie odwołania spoza FROM — w teście field_ref_outside_from. Odrzucanie nazwanych składowych # i kontrole pozytywne dla nazwy wyniku są pokryte testami jednostkowymi ut_compiler.

Przetwarzanie symbolu _

Indeks [_] jest cukrem syntaktycznym powielającym wyrażenie pola. Jedno wyrażenie zapisane w SELECT rozwija się podczas kompilacji do wielu pól wynikowych, po jednym dla każdego zgodnego slotu wskazanych strumieni.

Liczba kopii nie wynika wyłącznie z własnego schematu strumienia. x[_] oznacza wszystkie sloty, które x wnosi do rekordu czytanego przez dane zapytanie z całej klauzuli FROM. Ma to znaczenie, gdy operator strumieniowy zmienia szerokość schematu, na przykład tworząc okno.

Przykład używa kanonicznych deklaracji z całego rozdziału — core0 ma dwa pola (BYTE, INTEGER), core1 ma dwa pola (INTEGER, FLOAT), schematy są równoliczne:

DECLARE a BYTE, b INTEGER   STREAM core0, 0.1 FILE ‘sensor_a.txt’
DECLARE c INTEGER, d FLOAT  STREAM core1, 0.2 FILE ‘sensor_b.txt’

SELECT core0[_] * core1[_] \
STREAM scaled \
FROM core0 + core1

Po przeprowadzeniu kompilacji:

$ xretractor -c query.rql
core0(1/10)	sensor_a.txt
	a: BYTE
	b: INTEGER
core1(1/5)	sensor_b.txt
	c: INTEGER
	d: FLOAT
scaled(1/10)	tail=1
	:- PUSH_STREAM(core0)
	:- PUSH_STREAM(core1)
	:- STREAM_ADD
	scaled_0: INTEGER
		PUSH_ID(scaled[0])
		PUSH_ID(scaled[2])
		MULTIPLY
	scaled_1: FLOAT
		PUSH_ID(scaled[1])
		PUSH_ID(scaled[3])
		MULTIPLY

Symbol _ rozwinął się w dwa pola: scaled[0] * scaled[2] (czyli a * c) i scaled[1] * scaled[3] (czyli b * d). Odwołania do core0 i core1 zostały przetłumaczone przez aliasowanie na absolutne pozycje w schemacie złączonym. Typy wynikowe to INTEGER (BYTE * INTEGER) i FLOAT (INTEGER * FLOAT) — wynik równania typów w górę, opisanego w osobnym podrozdziale.

Szerokość liczona w klauzuli FROM

Jednopolowy strumień src wnosi pięć slotów, gdy w FROM znajduje się jego okno src@(1,5). Dzięki temu splot FIR można zapisać bez osobnego, nazwanego strumienia okna:

DECLARE value INTEGER STREAM src, 1/500 FILE 'data.txt'
DECLARE coef INTEGER[5] STREAM filter, 1 FILE 'coef.txt'

SELECT src[_] * filter[_] STREAM products FROM src@(1,5)+filter
SELECT products[0] STREAM output FROM SUMC(products)

Pierwsze zapytanie rozwija się do pięciu iloczynów. Jest równoważne dłuższej postaci:

SELECT * STREAM window FROM src@(1,5)
SELECT window[_] * filter[_] STREAM products FROM window+filter
SELECT products[0] STREAM output FROM SUMC(products)

W krótszej postaci kompilator sam wydziela okno z FROM jako substrat. Taki substrat jest przezroczysty podczas ustalania szerokości wkładu src. Nazwany przez użytkownika strumień window stanowi natomiast granicę schematu, dlatego dłuższa postać odwołuje się do window[_], a nie do src[_].

Operator stojący w FROM może także zmniejszyć szerokość. Reduktor zwija okno do jednego slotu, więc poniższe src[_] rozwija się tylko raz:

SELECT src[_] STREAM total FROM SUMC(src@(1,5))

Jeżeli jedno wyrażenie zawiera kilka indeksów [_], kompilator tworzy tyle kopii, ile wynosi najmniejsza z ustalonych szerokości ich wkładów. W typowym splocie okno i strumień współczynników mają tę samą szerokość.

Kompilator odrzuca odwołanie, gdy wskazany strumień nie występuje w FROM albo jego pola nie tworzą tam spójnego bloku. Przykładem drugiego przypadku jest src[_] pod oknem zbudowanym nad konkatenacją (src+other)@(1,5): pola obu źródeł są powtarzane wspólnie i nie da się przypisać src jednej szerokości bez zgadywania. Należy wtedy nadać podwyrażeniu własną nazwę i użyć [_] na tym nazwanym wyniku.

Symbol _ a przeplot

Aliasowanie składowych przez A[_] jest poprawne dla sumy +, ponieważ suma zachowuje osobne fragmenty schematów obu argumentów. Nie wolno stosować tej postaci do składowej osiąganej przez przeplot #:

SELECT A[_] - B[_] STREAM roznica FROM A#B

Po przeplocie pozycje A[k] i B[k] są tą samą pozycją wspólnego schematu, więc powyższe wyrażenie nie identyfikuje dwóch różnych wartości. Kompilator kończy taki plan błędem zamiast po cichu obliczyć wynik[k]-wynik[k].

Jeżeli _ ma przetwarzać rekord przeplotu, należy najpierw nadać wynikowi nazwę, a potem odwołać się do tego wyniku:

SELECT * STREAM przeplot FROM A#B
SELECT przeplot[_] * 2 STREAM przeskalowany FROM przeplot

Odzyskanie konkretnej składowej wymaga operatora rozplotu & albo %.

Ta funkcjonalność ma główne zastosowanie w algorytmach filtrów sygnałowych, w których wykonuje się wiele takich samych operacji na odpowiadających sobie elementach okna i wektora współczynników. Nie jest konieczna do osiągnięcia pełnej funkcjonalności RetractorDB, ale znacząco skraca takie zapytania. Kompletny przykład przedstawia rozdział Implementacja filtru sygnałowego.

Równanie typów w górę

Co się dzieje w przypadku, kiedy mnożymy dane typu BYTE z danymi typu INTEGER ? W systemie RetractorDB obowiązują ścisłe zasady równania typów w górę. Pomnożenie pola typu BYTE z wartością pola, które jest typu INTEGER spowoduje powstanie w schemacie typu pola INTEGER. To dzieje się na etapie kompilacji.

Na chwilę obecną system RetractorDB wspiera następujące typy danych:

TypOpis
BYTEwartości 0–255
INTEGER4 bajtowe wartości dla liczb ze znakiem
UINTpodobnie jak INTEGER dla liczb bez znaku
RATIONALliczby wymierne
FLOATliczby zmiennoprzecinkowe
DOUBLEliczby zmiennoprzecinkowe podwójnej precyzji
STRINGciągi znaków

STRING i RATIONAL są używane przez deskryptory, konwersje i wyrażenia; ich reprezentację i zachowanie sprawdzają testy ut_payload, ut_convertTypes oraz scenariusze integracyjne. Liczby zespolone i wymierne liczby zespolone Eisensteina pozostają poza aktualnym zestawem typów.

Przykład równania typów w praktyce — zapytanie scaled z rozdziału Przetwarzanie symbolu _:

SELECT core0[_] * core1[_] \
STREAM scaled \
FROM core0 + core1

core0 ma pola BYTE i INTEGER, core1 ma pola INTEGER i FLOAT. Po rozwinięciu _ kompilator wyznacza typy pól wynikowych:

WyrażenieLewy typPrawy typTyp wynikowy
scaled[0] * scaled[2]BYTEINTEGERINTEGER
scaled[1] * scaled[3]INTEGERFLOATFLOAT

Skąd bierze się typ pola wynikowego

Typ, długość i krotność pola wyznacza jeden przebieg kompilatoracompiler::inferFieldShapes() — który wykonuje program pola w odwrotnej notacji polskiej na stosie typów, dokładnie tak, jak expressionEvaluator wykonuje go na stosie wartości. Przebieg stoi po rozwiązaniu odwołań do pól i agregatów okiennych, a przed upraszczaniem wyrażeń, więc deskryptor nie zależy od żadnego przełącznika optymalizacji.

Do września 2026 na to samo pytanie odpowiadały cztery reguły lokalne, z których żadna nie widziała całego wyrażenia. Parser zaczynał od INTEGER i rozpoznawał FLOAT albo DOUBLE tylko wtedy, gdy rzutowanie było ostatnim tokenem programu; osobny przebieg wnioskował STRING; typ redukcji okiennej ustalał się jeszcze gdzie indziej. Stąd brały się trzy wyniki niezgodne z wartością, którą silnik do pola zapisywał: SELECT source[0] nad polem DOUBLE dawało INTEGER, to_float('2.5') * 2 dawało INTEGER, a to_integer(AVG(x : 10)) + 1 wracało do RATIONAL.

Reguły kontraktu

Czysty odczyt pola zachowuje jego typ i długość. Odczyt jednego elementu tablicy liczbowej daje pojedynczą wartość, więc krotność spada do jednego; STRING[N] jest jednym slotem i zachowuje swoją szerokość.

Operator dwuargumentowy (+, -, *, /, ^) daje typ o wyższym miejscu w porządku BYTE < INTEGER < UINT < RATIONAL < FLOAT < DOUBLE — z jednym wyjątkiem: BYTE z BYTE daje INTEGER. Nie jest to decyzja projektowa, tylko odwzorowanie języka: uint8_t + uint8_t promuje się w C++ do int i właśnie int ląduje w wyniku. Ta sama promocja obowiązuje potęgę typu dokładnego, bo a^k jest liczone tym samym mnożeniem, co zapisany wprost iloczyn.

Operator jednoargumentowy (-x, NOT x) zachowuje typ argumentu — tu promocji nie ma.

Porównania dają typ operandów po zrównaniu, bez promocji BYTE. Nie sięgają one listy SELECT: żyją w warunku RULE.

Funkcje mają jedną wspólną politykę:

FunkcjeTyp wyniku
isnull, IsZero, IsNonZero, Lengthzawsze INTEGER
sin, cos, expzawsze DOUBLE; nad RATIONAL odrzucane
Sqrt, tan, log, log2typ argumentu; nad RATIONAL odrzucane
Ceil, Floor, round, trunctyp argumentu
Abs, null2zerotyp argumentu
to_integer, to_float, to_double, to_stringtyp docelowy

sin, cos i exp liczą w double i zwracają DOUBLE również dla argumentów całkowitych. Pozostałe funkcje matematyczne liczą przez double i rzutują wynik z powrotem na typ argumentu: Ceil nad polem DOUBLE daje DOUBLE, a Sqrt nad INTEGER daje INTEGER. Jawne konwersje wyznaczają typ swojego wyniku także wtedy, gdy stoją w środku wyrażenia: to_float('2.5') * 2 jest FLOAT, a to_integer(AVG(x : 10)) + 1 jest INTEGER.

Siedem funkcji o niewymiernej przeciwdziedzinie — Sqrt, sin, cos, exp, tan, log i log2nie kompiluje się nad argumentem typu RATIONAL: kompilator odrzuca plan i wymaga jawnego to_double. Ma to znaczenie praktyczne, bo reduktory MIN, MAX, AVG i SUMC nad wejściem całkowitym lub wymiernym dają RATIONAL. Powód, komunikat błędu, zasięg bramki (obejmuje też warunek RULE ... WHEN) i wyjątek dla funkcji zaokrąglających opisuje rozdział Wyrażenia pól i funkcje skalarne; tutaj nie jest to powtarzane, żeby obie strony nie rozjechały się przy następnej zmianie.

Agregat okna rekordowego bierze typ z całego programu swojego argumentu, przepuszczonego przez tę samą regułę, co reduktory strumieniowe: źródło arytmetyczne (BYTE, INTEGER, UINT, RATIONAL) redukuje się do RATIONAL, żeby średnia nie traciła dokładności, a FLOAT i DOUBLE zostają sobą. Dlatego MIN(k : 4) nad polem INTEGER daje RATIONAL, ale MIN(to_double(k) : 4) daje DOUBLE.

NULL nie jest typem. Pole ma typ, a brak wartości jest znacznikiem w metadanych rekordu. Wyrażenie, które na danym rekordzie policzy się na NULL, nie zmienia przez to typu swojego pola. null2zero(x) przepuszcza typ argumentu, a zero zapisuje się w tym właśnie typie.

Propagacja przez plan

Operatory, które kopiują schemat operandu — SELECT *, przesunięcie >N, decymacja -r, przeplot #, rozploty & i % oraz suma strumieni + — niosą kształt pola producenta slot po slocie. Typ przechodzi przez dowolnie długi łańcuch strumieni pośrednich.

Operatory, które schemat syntetyzują, zachowują własny: reduktor MIN/MAX/AVG/SUMC w klauzuli FROM daje jedno pole: RATIONAL dla źródła całkowitego lub wymiernego, FLOAT dla FLOAT i DOUBLE dla DOUBLE. Okno @(krok, szerokość) daje pola typu najszerszego z rekordu źródła.

Deklaracja DECLARE jest umową z plikiem źródłowym i nie podlega wnioskowaniu — żaden przebieg kompilatora jej nie zmienia.

Zmiana formatu artefaktu

Poprawne typowanie zmienia .desc i układ rekordu tam, gdzie dotąd wychodził INTEGER: DOUBLE zajmuje 8 bajtów zamiast 4, więc przesuwa offsety kolejnych pól. Strumień policzony starszą wersją silnika ma artefakt o innym układzie i przy starcie zostanie odrzucony jako niezgodny schemat — tak samo jak po każdej innej zmianie listy pól. Okresu zgodności nie ma: deskryptor opisuje teraz to, co silnik naprawdę zapisuje, a poprzednio opisywał co innego.

Debugowanie kompilacji

Kompilator transformuje plik .rql w plan wykonania przez kilka etapów. Efekt każdego etapu jest widoczny przez flagi diagnostyczne xretractor. Opisane tutaj narzędzia pozwalają odpowiedzieć na pytania: dlaczego schemat wygląda inaczej niż napisałem? skąd ta delta? dlaczego pojawił się substrat?

Podstawowe narzędzie: flaga -c

Flaga -c (--onlycompile) zatrzymuje xretractor po kompilacji i drukuje skompilowany plan na standardowe wyjście — bez uruchamiania przetwarzania:

xretractor -c query.rql

Kod wyjścia 0 oznacza sukces. Kod 1 — błąd kompilacji. Komunikaty błędów trafiają na stderr:

xretractor -c query.rql 2>errors.txt
echo $?

Kompilację można wywołać nawet gdy inny proces xretractor już działa — flaga -c nie próbuje przejąć blokady wykonania.

Jak czytać plan kompilacji

Dla kanonicznego query.rql z tego rozdziału plan wygląda następująco:

core0(1/10)	sensor_a.txt
	a: BYTE
	b: INTEGER
core1(1/5)	sensor_b.txt
	c: INTEGER
	d: FLOAT
merged(1/10)	tail=1
	:- PUSH_STREAM(core0)
	:- PUSH_STREAM(core1)
	:- STREAM_ADD
	core0_0: BYTE
		PUSH_ID(merged[0])
	core0_1: INTEGER
		PUSH_ID(merged[1])
	core1_2: INTEGER
		PUSH_ID(merged[2])
	core1_3: FLOAT
		PUSH_ID(merged[3])
result(1/10)	tail=1
	:- PUSH_STREAM(merged)
	result_0: BYTE
		PUSH_ID(result[0])
	result_1: INTEGER
		PUSH_ID(result[2])
	result_2: BYTE
		PUSH_ID(result[0])
	result_3: INTEGER
		PUSH_ID(result[2])
core2(3/10)	sensor_c.txt
	e: INTEGER

Każdy blok ma ustalony format:

nazwaStrumienia(delta)
        :- operacjaStrumieniowa(arg)
        nazwaPolaWyjściowego: TYP
                instrukcja
                ...
ElementZnaczenie
nazwaStrumienia(delta)Nazwa strumienia i jego interwał jako ułamek: 1/10 = 0.1 s = 10 Hz
:- PUSH_STREAM(x)Pcha strumień x na stos strumieniowy; pojawia się raz na każdy argument FROM
:- STREAM_ADDOperator sumy strumieni (+ w FROM)
:- STREAM_HASHOperator synchronizacji strumieni (# w FROM)
:- STREAM_TIMEMOVE(n)Przesunięcie w czasie (>n w FROM)
pole: TYPPole schematu wynikowego po równaniu typów w górę
PUSH_ID(s[n])Odkłada na stos wartość pola n ze strumienia s — tu widoczny efekt aliasowania
PUSH_VAL(x)Odkłada stałą x na stos
ADD, MULTIPLY, …Operacja arytmetyczna: zdejmuje dwa argumenty ze stosu, odkłada wynik

Bloki efemerydów (DECLARE) pojawiają się na końcu planu — zawierają listę pól i ścieżkę do pliku danych.

Aliasowanie w planie: jeśli dwa pola wyjściowe wskazują na ten sam PUSH_ID, są aliasami. W przykładzie result_0 i result_2 oba to PUSH_ID(merged[0]) — potwierdzenie, że merged[0] i core0[0] to ta sama pozycja. Patrz Aliasowanie.

Substraty w planie: automatycznie wygenerowany substrat pojawia się jako blok z nazwą w stylu STREAM_HASH_core0_core1 — bez odpowiadającego SELECT w pliku źródłowym. Patrz Substraty.

Wizualizacja grafu zależności

Zamiast tekstu można wygenerować graf w formacie DOT i przetworzyć przez graphviz:

xretractor -c -d -f -t -s query.rql > out.dot && dot -Tsvg out.dot -o out.svg

Dostępne flagi modyfikujące wyjście DOT:

FlagaPełna nazwaZnaczenie
-d--dotgeneruj wyjście DOT zamiast tekstowego planu
-f--fieldspokaż pola strumieni w węzłach grafu
-t--tagspokaż programy poszczególnych pól (-f)
-s--streamprogspokaż sekwencje instrukcji stosu w węzłach
-u--rulespokaż reguły RULE
-p--transparentprzezroczyste tło — do osadzania w dokumentach

Graf pokazuje zależności między strumieniami jako krawędzie skierowane od źródeł do wyników. Substraty mają inny kolor niż strumienie jawnie zdefiniowane przez użytkownika. Patrz Budowa drzewa zależności.

NOTE: Opisana funkcjonalność ma pokrycie w teście: issue31_doc opisanym w załączniku pt. Testy Integracyjne.

Weryfikacja interwałów

Jeśli delta strumienia wynikowego jest niespodziewana:

  1. Sprawdź delty strumieni źródłowych — widoczne w blokach DECLARE na końcu planu.
  2. Sprawdź operator w klauzuli FROM — każdy operator ma inne równanie na deltę.

Przykład: core0(1/10) # core1(1/5) daje deltę 1/15 (średnia harmoniczna), nie 1/10. Jeśli spodziewałeś się 1/10, użyj + zamiast #. Pełne równania — patrz Rozwiązywanie interwałów.

Typowe błędy kompilacji

Cykl w grafie zależności

[error] Circular dependency: stream interval resolution stalled with N
>> unresolved streams

Strumień odwołuje się pośrednio lub bezpośrednio do samego siebie. Wygeneruj graf przez -d — cykl będzie widoczny jako pętla. Patrz Wykrywanie pętli.

Nieznany strumień

Odwołanie do strumienia, który nie został jeszcze zadeklarowany. Pliki .rql przetwarzane są sekwencyjnie — SELECT nie może odwoływać się do strumienia zdefiniowanego niżej w pliku. Przesuń DECLARE lub SELECT wyżej.

Niezgodność krotności schematów przy _

Oba strumienie w wyrażeniu core0[_] * core1[_] muszą mieć schematy tej samej liczności. Sprawdź ile pól ma każdy z argumentów w blokach DECLARE planu. Patrz Przetwarzanie symbolu _.

Plik danych niedostępny

Błąd ten nie pojawia się przy -c — flaga weryfikuje poprawność zapytania, nie sprawdza czy pliki danych istnieją. Błąd dostępu do pliku pojawi się dopiero przy uruchamianiu przetwarzania bez -c.

Realizacja zapytań

Proces realizacji opiera się na ciągłym przeglądzie drzewa zapytań i sekwencyjnym i hierarchicznym wywoływaniu procedur budujących kolejne krotki strumieni i kolejne schematy danych.

Opis algorytmu należy zacząć od przedstawienia procedury sekwencjonowania. W systemie, w którym realizowane są zapytania z różnorodnymi wartościami definiującymi czasokres pomiędzy tworzonymi i napływającymi kolejnymi danymi potrzebny jest sposób wyznaczenia kolejnych interwałów.

Przeanalizujmy na początek następujący przykład. Załóżmy że w systemie występują dwa strumienie danych. Jeden napływa co sekundę drugi co dwie sekundy. Algorytm sekwencjonowania powinien zaproponować sekundowy interwał czasu pomiędzy wywoływaniem procedury przeznaczonej dla strumienia pierwszego i dwusekundowy dla strumienia drugiego. W praktyce przedstawiona zostanie sekundowa siatka czasowa – w której wszystkie sekundowe węzły zostaną wypełnione procedurą budowy krotek ze strumienia pierwszego i w tej samej siatce czasu – drugi strumień dołączy swoje procedury co drugą sekundę.

Wyznaczona w trakcie kompilacji siatka czasu jest bardzo istotna – to ona definiuje jak często i w jakich odstępach będą przetwarzane strumienie danych, które węzły zostaną pokryte przez wygenerowane procedury przetwarzania strumieni danych.

Zastanówmy się nad bardziej skomplikowanym przykładem. Załóżmy istnienie trzech strumieni danych. Pierwszy z częstotliwością napływu ⅓ drugi z szybkością ½ oraz trzeci napływający z szybkością ⅔. Wyznaczenie siatki i umieszczenie kolejnych procedur przetwarzania wymaga bardziej skomplikowanego rozwiązania. Wartość ⅔ może zostać uproszczona do ⅓. Bowiem istnieje naturalny podzielnik tych wartości. Nie istnieje natomiast naturalny podzielnik wartości ½ oraz ⅓. Wartość siatki jaka zostanie wyznaczona to ⅕. Jest to największa możliwa liczba wymierna, która jeśli zbuduje się na osi liczb wymiernych siatkę pomieści regularne serie czasowe o częstotliwości napływa ½ oraz ⅓.

Na wyznaczoną siatkę nakładane są wszystkie strumienie i wyznaczany jest zbiór minimalnych odstępów czasu dla zapytań w których istnieją mnożniki naturalne. W naszym przypadku minimalny zbiór odstępów czasu dla zapytań o mnożnikach (⅓, ½, ⅔) to zbiór (⅓, ½). Odstępy ⅓ oraz ⅔ będą współdzielić slot czasowy na siatce.

Analizując poniższy wywód może bardziej oczywiste staną się wspomniane w rozdziale komentarze generowane dla programu swirly przedstawiające generowane schematy kulkowe.

Algorytm przeglądu drzewa zapytań

Przegląd ogólny

Algorytm przeglądu drzewa zapytań realizowany jest przez dwa współpracujące komponenty: dataModel (logika przetwarzania) oraz executorsm (pętla czasowa i IPC). Przed wejściem w główną pętlę system wykonuje krok zerowy, po czym cyklicznie iteruje po minimalnym zbiorze interwałów czasowych (Rys. 42).

%%{init: {"markdownAutoWrap": false}}%%
flowchart TD
    A([Inicjalizacja]) --> B
    B["processZeroStep()<br/>Tylko DECLARE: revRead(0) → fire()"] --> C
    C["TimeLine::getNextTimeSlot()<br/>Wyznacz następny slot czasowy"] --> D
    D["getAwaitedStreamsSet()<br/>Filtruj: rInterval dzieli bieżący slot"] --> E
    E["dataModel::processRows(inSet)<br/>Przebieg 1: nie-deklaracje → input → okna SELECT → output → zapis<br/>Przebieg 2: deklaracje → odblokowanie"] --> F
    F["broadcast(inSet)<br/>Kolejki Boost IPC → klienci xqry"] --> C

Rys. 42. Algorytm przeglądu drzewa zapytań – przegląd ogólny


Struktura danych: qTree

qTree (src/retractor/lib/qTree.cpp) rozszerza std::vector<query> i jest wektorem topologicznie posortowanych zapytań. Sortowanie odbywa się przez DFS po grafie zależności budowanym z query.getDepStream() (Rys. 43).

%%{init: {"markdownAutoWrap": false}}%%
graph TD
    A["A (DECLARE)<br/>rInterval=1/3"] --> B["B<br/>SELECT FROM A<br/>rInterval=1/3"]
    A --> D["D<br/>SELECT FROM A,B<br/>rInterval=1"]
    B --> C["C<br/>SELECT FROM B<br/>rInterval=1/2"]
    B --> D

Rys. 43. Przykładowy graf zależności dla qTree

Po sortowaniu topologicznym kolejność w wektorze: [A, B, C, D]. Zapytanie C zależne od B zawsze trafi po B w iteracji — gwarantuje poprawność obliczeń.

Metoda getAvailableTimeIntervals() wyodrębnia ze wszystkich zapytań unikalne wartości rInterval (z pominięciem dyrektyw kompilatora i wartości zerowych) — wynik to wejście do konstruktora TimeLine.


Minimalna siatka czasowa: TimeLine / CRSMath

TimeLine (src/retractor/lib/CRSMath.cpp) zarządza racjonalnymi interwałami czasowymi. Konstruktor redukuje zbiór interwałów — usuwa wielokrotności, zachowując tylko koprimalne:

Wejście: {1/2, 1, 4}  →  Wyjście: {1/2}
(1 = 2 × 1/2, więc redundantne; 4 = 8 × 1/2, więc redundantne)

Wejście: {1/2, 1/3}  →  Wyjście: {1/2, 1/3}
(żadne nie jest wielokrotnością drugiego)

getNextTimeSlot() wyznacza kolejny slot jako min(delta × counter[delta]) po wszystkich deltach. Poniższy diagram ilustruje sloty dla delt {1/2, 1/3} i aktywne zapytania w każdym z nich (Rys. 44):

%% pdf-width: 100%
timeline
    title Sloty czasowe dla delt {1/2, 1/3}
    section t = 1/3
        B (rInterval=1/3)
    section t = 1/2
        C (rInterval=1/2)
    section t = 2/3
        B (rInterval=1/3)
    section t = 1
        B (rInterval=1/3) : C (rInterval=1/2) : D (rInterval=1)
    section t = 4/3
        B (rInterval=1/3)
    section t = 3/2
        C (rInterval=1/2)

Rys. 44. Minimalna siatka czasowa dla delt {1/2, 1/3}

Sprawdzenie isThisDeltaAwaitCurrentTimeSlot(inDelta) zwraca true, gdy ctSlot_ / inDelta ma mianownik równy 1 (slot jest całkowitą wielokrotnością delty zapytania).


Krok zerowy: processZeroStep()

Przed wejściem w pętlę executorsm::run() wywołuje dataModel::processZeroStep(). Metoda przetwarza wyłącznie deklaracje (strumienie wejściowe DECLARE):

for (auto &q : coreInstance_) {
    if (!q.isDeclaration()) continue;
    qSet[q.id]->bufferState = flux;   // odblokuj odczyt fizyczny
    qSet[q.id]->revRead(0);           // wczytaj z indeksu 0
    qSet[q.id]->fire();               // przepisz chamber_ → outputPayload
    assert(qSet[q.id]->bufferState == armed);
}

Po tym kroku każda deklaracja ma bufferState = armed — dane z fizycznego źródła są w outputPayload.


Główna pętla: filtrowanie i przetwarzanie

Filtrowanie zapytań: getAwaitedStreamsSet()

Dla bieżącego slotu tl (executorsm.cpp, linia ~88):

std::set<std::string> retVal;
for (auto &q : *coreInstancePtr)
    if (TimeLine::isThisDeltaAwaitCurrentTimeSlot(q.rInterval))
        retVal.insert(q.id);
return retVal;

Wynik inSet to identyfikatory zapytań aktywnych w tym slocie — podzbiór wszystkich zapytań.

Przetwarzanie: processRows(inSet)

Funkcja wykonuje dwa przejścia przez inSet (dataModel.cpp, linia ~98), co ilustruje Rys. 45:

%%{init: {"markdownAutoWrap": false}}%%
flowchart LR
    S([processRows - inSet]) --> P1

    subgraph P1["Przebieg 1 — nie-deklaracje (kolejność topologiczna)"]
        direction TB
        X1["constructInputPayload()<br/>buduje dane wejściowe z FROM"] --> XW
        XW["computeWindowAggregates()<br/>redukuje historię dla okien SELECT"] --> X2
        X2["constructOutputPayload()<br/>ewaluuje wyrażenia SELECT"] --> X3
        X3["write()<br/>zapis na dysk / pamięć"] --> X4
        X4["constructRulesAndUpdate()<br/>ewaluuje klauzule RULE"]
    end

    P1 --> P2

    subgraph P2["Przebieg 2 — deklaracje (odblokowanie na następny slot)"]
        direction TB
        Y1{"bufferState<br/>== armed?"} -->|tak| Y2
        Y2["bufferState = flux<br/>odblokuj odczyt"] --> Y3
        Y3["revRead(0)<br/>odczytaj nowe dane"] --> Y4
        Y4["fire()<br/>przypisz do outputPayload"]
        Y1 -->|nie| Y5([pomiń])
    end

    P2 --> E([koniec])

Rys. 45. Algorytm processRows – dwa przejścia przetwarzania

Deklaracje są odblokowywane dopiero po tym, jak wszystkie zależne zapytania skonsumowały ich outputPayload w przejściu 1.

Okna rekordowe listy SELECT

Jeżeli zapytanie zawiera MIN/MAX/AVG/SUMC(wyrażenie : W), etap computeWindowAggregates() działa po zbudowaniu payloadu FROM, ale przed ewaluacją pól wynikowych. Dla indeksu logicznego n czyta rekordy wskazanego źródła od n-(W-1) do n. Gołe pole korzysta z bezpośredniego odczytu płaskiego slotu; ogólne wyrażenie jest obliczane osobno na payloadzie każdego rekordu historii.

Wartości NULL są pomijane, a okno bez wartości obecnych zapisuje NULL dla wszystkich czterech statystyk. Grupy o tym samym źródle, programie wyrażenia i szerokości współdzielą jedno przejście po historii. Wyniki trafiają do streamInstance::windowValues i stają się zwykłymi operandami constructOutputPayload(), dlatego można pisać na przykład 2*MIN(a : 5)+1 albo null2zero(AVG(a+b : 5)).


Rozgłaszanie wyników: broadcast()

Po każdym processRows() wywoływane jest broadcast(inSet) (executorsm.cpp, linia ~449) — algorytm przedstawia Rys. 46:

%% pdf-width: 50%
%%{init: {"markdownAutoWrap": false}}%%
flowchart TB
    A([inSet]) --> B["printRowValue()<br/>serializuj do Boost property_tree"]
    B --> C{klienci<br/>subskrybujący<br/>strumień?}
    C -->|tak| D["kolejka brcdbr&lt;id&gt;<br/>try_send(dane)"]
    D --> E{kolejka<br/>pełna?}
    E -->|nie| F([wysłano])
    E -->|tak - brak odbiorcy| G["usuń kolejkę<br/>usuń id2StreamName_"]
    C -->|brak| H([pomiń])

Rys. 46. Algorytm broadcast – rozsyłanie wyników przez Boost IPC

printRowValue() buduje strukturę z nazwą strumienia, liczbą pól, wartościami i bitmapą null, zapisuje jako Boost info format i wysyła przez boost::interprocess::message_queue.


Pełny przykład: zapytania A, B, C, D dla delt

Rys. 47 przedstawia kompletną sekwencję wywołań dla czterech zapytań A, B, C, D rozłożonych na siatce czasowej z deltami {1/2, 1/3}.

sequenceDiagram
    participant TL as TimeLine
    participant ES as executorsm
    participant DM as dataModel
    participant IPC as Boost IPC

    ES->>DM: processZeroStep()
    DM->>DM: A: revRead(0) → fire() [armed]
    ES->>IPC: broadcast({A})

    TL-->>ES: nextSlot = 1/3
    ES->>DM: processRows({B})
    DM->>DM: Przebieg 1: B → input(A) → windows → output → write()
    DM->>DM: Przebieg 2: A → flux → revRead(0) → fire()
    ES->>IPC: broadcast({B})

    TL-->>ES: nextSlot = 1/2
    ES->>DM: processRows({C})
    DM->>DM: Przebieg 1: C → input(B) → windows → output → write()
    DM->>DM: Przebieg 2: A → flux → revRead(0) → fire()
    ES->>IPC: broadcast({C})

    TL-->>ES: nextSlot = 2/3
    ES->>DM: processRows({B})
    DM->>DM: Przebieg 1: B → input(A) → output → write()
    DM->>DM: Przebieg 2: A → flux → revRead(0) → fire()
    ES->>IPC: broadcast({B})

    TL-->>ES: nextSlot = 1
    ES->>DM: processRows({B, C, D})
    DM->>DM: Przebieg 1 (topologicznie): B → C → D
    DM->>DM: Przebieg 2: A → flux → revRead(0) → fire()
    ES->>IPC: broadcast({B, C, D})

Rys. 47. Pełny przykład wykonania dla zapytań A, B, C, D przy deltach {1/2, 1/3}

Drzewo zależności determinuje kolejność przejścia 1. Interwały czasowe z algebry Beatty’ego wyznaczają, które węzły drzewa są aktywne w danym slocie.


Realizacja algebraiczna — powiązanie kodu z równaniami

Każdy kluczowy fragment algorytmu opisanego na tej stronie jest bezpośrednią realizacją równań z algebry regularnych serii czasowych i formalnych dowodów.

Operatory algebraiczne w SOperations.hpp

Plik src/include/SOperations.hpp koduje operatory algebry wprost jako funkcje na liczbach wymiernych:

OperatorSymbolFunkcja w kodzie
PrzeplotφHash(Δa, Δb, i, retPos)
Rozplątanie lewostronneΘDiv(Δa, Δb, i)
Rozplątanie prawostronne∼ΘMod(Δa, Δb, i)
RóżnicaδSubtract(Δa, Δb, i)
Agregacja i serializacjaΨagse(offset, step)

Każda z tych funkcji jest dosłownym przekładem wzoru z algebry. Div realizuje rozplątanie lewostronne:

return i + ceilR((i + 1) * deltaA / deltaB);

\[ a_{n} = c_{n+\left\lceil \frac{(n+1)\Delta_{a}}{\Delta_{b}} \right\rceil} \]

Mod realizuje rozplątanie prawostronne:

return i + floorR(i * deltaB / deltaA);

\[ b_{n} = c_{n+\left\lfloor \frac{n\Delta_{b}}{\Delta_{a}} \right\rfloor} \]

Hash implementuje test z definicji przeplotu — warunek \(\left\lfloor iz \right\rfloor = \left\lfloor (i+1)z \right\rfloor\) przy \(z = \Delta_{b}/(\Delta_{a}+\Delta_{b})\) — i zwraca odpowiedni offset do strumienia A albo B.

Pomocnicze funkcje floorR() i ceilR() operują wyłącznie na boost::rational<int>, nigdy nie przechodząc przez double. Jest to bezpośrednia realizacja wymagania z Twierdzenia 2: niejawne rzutowanie na float łamie założenia twierdzenia Fraenkela — materializacja do postaci zmiennoprzecinkowej musi być odłożona do momentu jawnego zastosowania podłogi lub sufitu.

TimeLine jako minimalna baza układu pokrywającego

Konstruktor TimeLine wyznacza pierwotny zbiór interwałów — usuwa wszystkie delty będące całkowitą wielokrotnością innej delty ze zbioru. Interwał jest pierwotny, gdy żaden mniejszy interwał z zestawu nie jest jego dzielnikiem z ilorazem naturalnym. Jest to wyznaczanie minimalnego układu pokrywającego (covering system) w rozumieniu twierdzenia Fraenkela: tylko pierwotne delty generują niezależne sekwencje Beatty’ego i tylko one są potrzebne do wyznaczenia pełnej siatki czasowej.

Metoda getNextTimeSlot() — opatrzona komentarzem // MAGIC Warning w źródle — generuje kolejne punkty siatki jako:

\[ t_{k} = \min_{\delta \in \mathrm{sr}} \left(\delta \cdot \mathrm{counter}[\delta]\right) \]

gdzie sr to pierwotny zbiór interwałów, a \(\mathrm{counter}[\delta]\) zlicza dotychczasowe „trafienia“ każdej delty. Pętla dwufazowa — osobno wyznaczenie minimum, osobno inkrementacja liczników — gwarantuje poprawną obsługę kolizji: kilka delt może wyznaczać ten sam slot jednocześnie.

ℹ Info

Komentarz // MAGIC Warning w źródle CRSMath.cpp oznacza, że algorytm jest poprawny z nieoczywistego powodu. Nie wystarczy intuicja — poprawność gwarantuje twierdzenie Fraenkela. Ponieważ sr zawiera wyłącznie pierwotne interwały (żaden nie jest wielokrotnością innego), liczniki poszczególnych delt nigdy nie „wychodzą przed siebie“ w sposób, który pominąłby lub zdublował slot. Kolizja — gdy dwie delty wskazują na ten sam slot — jest przypadkiem legalnym i jest obsługiwana przez drugą pętlę. „Magia“ polega na tym, że prosta formuła min(δ·counter[δ]) z automatyczną inkrementacją jest równoważna pełnemu generatorowi sekwencji Beatty’ego dla całego układu pokrywającego.

isThisDeltaAwaitCurrentTimeSlot() jako test przynależności do sekwencji Beatty’ego

boost::rational<int> value = ctSlot_ / inDelta;
return (value.denominator() == 1);

Test sprawdza, czy \(t_{\mathrm{slot}} / \Delta \in \mathbb{N}\) — czy bieżący slot jest całkowitą wielokrotnością delty zapytania. W języku teorii sekwencji Beatty’ego: punkt \(t\) należy do sekwencji o gęstości \(\Delta\) wtedy i tylko wtedy, gdy \(t/\Delta\) jest liczbą naturalną. Warunek na mianownik równy 1 wynika z arytmetyki boost::rational — ułamek jest zawsze w postaci zredukowanej, więc mianownik 1 oznacza dokładnie liczbę całkowitą bez żadnych zaokrągleń.

Zapytania Ad hoc

Przez zapytania Ad hoc rozumiemy zapytania kierowane do działającego systemu. W typowym scenariuszu jaki zakładano w przypadku rozwoju systemu, założono we wstępnie rozpatrywanych scenariuszach, że użytkownik systemu będzie znał wszystkie zapytania i źródła danych wymagane do uzyskania przetworzonych serii czasowych.

W trakcie rozwoju systemu pojawiły się jednak dodatkowe scenariusze, zakładające, że praca systemu nie powinna być przerywana a dodatkowe zapytania powinny zostać dołączone do planu realizacji zapytań. Tego typu funkcjonalność będziemy nazywać zapytaniami Ad hoc, dołączanymi do systemu w trakcie jego działania bez przerywania jego pracy.

Rys. 48. Przepływ sterowania dla zapytań Ad Hoc

Na Rys. 48 przedstawiono opisany powyżej przepływ sterowania. Plik z zapytaniami i dyrektywami najpierw jest kierowany do procesu xretractor. Następnie poprzez pamięć współdzieloną proces xqry pobiera dane z xretractor. Tym samym procesem możemy wysłać do procesu xretractor polecenie. W tym poleceniu zawieramy tekst dodatkowego zapytania, które xretractor powinien dołączyć do przetwarzanego drzewa.

Co można dołączyć w locie

Kanałem ad hoc można dołączyć dokładnie jedno polecenie SELECT, DECLARE albo RULE. Dyrektywy kompilatora oraz program zawierający kilka poleceń są odrzucane bez zmiany aktywnego planu.

Nowe źródło można zadeklarować bez zatrzymywania pracującego silnika:

$ xqry -a "DECLARE a BYTE STREAM C, 1 FILE 'data3.txt'"

Kod wyjścia 0 bez komunikatu oznacza przyjęcie deklaracji. Deklaracja otrzymuje bazę indeksu logicznego w pierwszym należnym jej slocie. Jeżeli dołączone później zapytanie wymaga okna albo przesunięcia, emisja czeka, aż źródło zgromadzi pełną wymaganą historię. HOLD nie jest do tego potrzebny; pozostaje opcjonalną dyrektywą opóźniającą fizyczny odczyt. Ponowne DECLARE istniejącej nazwy jest odrzucane, a nie traktowane jako zmiana konfiguracji.

Przy kilku działających instancjach samo DECLARE nie wskazuje właściciela, ponieważ nie ma klauzuli FROM. Trzeba wtedy podać cel jawnie:

$ xqry --server pomiary -a "DECLARE a BYTE STREAM C, 1 FILE 'data3.txt'"

Dołączanie pierwszej deklaracji do serwera uruchomionego z pustym planem nie jest jeszcze obsługiwane; kanał ad hoc wymaga aktywnego modelu danych.

Reguła dołączana w locie może wykonywać wyłącznie DO DUMP. DO SYSTEM pozostaje dostępne w pliku pełnego planu, ponieważ udostępnienie go przez IPC pozwalałoby klientowi wykonywać dowolne polecenia powłoki na koncie serwera. Cel ON musi być istniejącym strumieniem utworzonym przez SELECT. Reguła zaczyna działać dopiero po zgromadzeniu od chwili dołączenia całej wymaganej historii; jeżeli pamięciowy strumień przechowuje jej za mało, żądanie jest odrzucane.

xqry --server pomiary -a \
  "RULE alarm ON temperatura WHEN temperatura[0] > 80 DO DUMP -10 TO 5"

Przy wielu instancjach klient kieruje SELECT według właścicieli strumieni z FROM, a RULE według strumienia z ON. Zapytanie łączące źródła z kilku serwerów jest odrzucane. Nowe nazwy strumieni i pliki magazynu są zgłaszane w magistrali przed modyfikacją aktywnego planu, więc ad hoc nie może nadpisać zasobu innej instancji.

Ad hoc powiększa istniejący plan. Do jego pełnego, atomowego zastąpienia — także w instancji bezczynnej — służy xqry --reset plik.rql.

Kiedy zaczyna się strumień dołożony ad hoc

Plan zbudowany od początku pracy systemu numeruje rekordy od początku logicznego wyliczonego przez kompilator. Zapytanie dołożone ad hoc nie ma takiej przeszłości — jego pierwszym rekordem jest pierwszy slot, w którym runtime je zobaczył, a nie slot zerowy planu. Import jest przy tym atomowy: skompilowane drzewo i jego instancje strumieni są publikowane pod wspólnym zamkiem, a pętla wykonania przebudowuje siatkę czasu bez cofania się, nawet jeśli nowe zapytanie wnosi do systemu nowe tempo.

NOTE: Zachowanie to ma pokrycie w teście issue227_join_alignment (przypadek adhoc-origin).

Przykład

Przykład rozpoczniemy od przygotowania prostego zapytania:

DECLARE a BYTE STREAM A, 1 FILE 'data1.txt'
DECLARE a BYTE STREAM B, 2 FILE 'data2.txt'
SELECT * STREAM str1 FROM A+B

Plik z zapytaniem zapiszemy pod nazwą qplan1.rql. Do poprawnej realizacji zapytania konieczne jest również przygotowanie plików data1.txt i data2.txt. Proponuję wypełnić data1.txt kolejnymi liczbami od 1 do 6 każda w nowej linii, a w pliku data2.txt liczby od 10 do 15. W tak przygotowanym katalogu uruchamiamy polecenie

$ xretractor qplan1.rql

Jeśli poprzednio w tym katalogu wykonywaliśmy jakieś operacje i stworzyliśmy strumień str1 o innym schemacie – otrzymamy błąd pt. „Error in data descriptor file”. Pojawi się tam również informacja o różnicach pomiędzy dwoma deskryptorami. W takim przypadku plik str1 oraz str1.desc powinniśmy usunąć i ponownie wykonać polecenie.

Proces xretractor rozpocznie przetwarzanie danych. Należy w tym momencie uruchomić kolejny terminal i wydać w nim polecenie:

$ xqry -d
name | duration | size | count | location  | cap
-----+----------+------+-------+-----------+----
str1 | 1        | 48   | 24    |           | 0
A    | 1        | -1   | 3     | data1.txt | 1
B    | 2        | -1   | 2     | data2.txt | 1

W postaci tabelarycznej wyświetli się co w danym systemie się przetwarza. Ile bajtów już napłynęło, z jakich plików dane są czytane. Ile danych zostało już przetworzonych. Oczekując bardziej opisowej formy możemy wydać następujące polecenie:

$ xqry -d -y
---
apiVersion: xqry/v1
streams:
  - name: str1
    delta: 1
    size: 214
    count: 107
  - name: A
    delta: 1
    count: 86
    location: data1.txt
  - name: B
    delta: 2
    count: 43
    location: data2.txt

Udzielona odpowiedź jest w formie yaml.

Aby dołożyć do systemu kolejne zapytanie musimy wydać polecenie:

$ xqry -a "SELECT * STREAM str2 FROM A#B"

Polecenie w tej formie wysyła do procesu xretractor nowe zapytanie. Brak komunikatu i kod wyjścia 0 oznaczają przyjęcie. System otrzymując je prowadzi kompilację i złączy drzewa planów zapytań; przy odmowie xqry zwraca kod niezerowy i zapisuje przyczynę diagnostyczną.

Jeśli zajrzymy ponownie do stanu systemu, zobaczymy następujący obraz:

$ xqry -d
name | duration | size | count | location  | cap
-----+----------+------+-------+-----------+----
str2 | 2/3      | 10   | 10    |           | 0
A    | 1        | -1   | 23    | data1.txt | 1
str1 | 1        | 312  | 156   |           | 0
B    | 2        | -1   | 12    | data2.txt | 1

Lub tak:

$ xqry -d -y
---
apiVersion: xqry/v1
streams:
  - name: str2
    delta: 2/3
    size: 7
    count: 7
  - name: A
    delta: 1
    count: 16
    location: data1.txt
  - name: str1
    delta: 1
    size: 298
    count: 149
  - name: B
    delta: 2
    count: 8
    location: data2.txt

Przyglądając się dokładniej zapytaniom za pomocą polecenia xqry zobaczymy następującą odpowiedź systemu dla zapytania str1:

$ xqry -t str1 -y
---
apiVersion: xqry/v1
stream:
  name: str1
  delta: 1
query: SELECT * STREAM str1 FROM A+B
fields:
  str1.A_0:
    type: BYTE
  str1.B_1:
    type: BYTE

oraz dla zapytania str2:

$ xqry -t str2 -y
---
apiVersion: xqry/v1
stream:
  name: str2
  delta: 2/3
query: SELECT * STREAM str2 FROM A#B
fields:
  str2.a:
    type: BYTE

Jak widać dodatkowe zapytanie str2 zostało poprawnie złączone z istniejącym planem realizacji zapytania. Widać też że zebranych danych jest o wiele mniej w porównaniu z str1.

NOTE: Opisana funkcjonalność ma pokrycie w teście: issue6_adhoc opisanym w załączniku pt. Testy Integracyjne.

Realizacja alarmowania

Mechanizm alarmowania (dyrektywa RULE) jest nieodłączną częścią głównej pętli przetwarzania. Nie jest osobnym procesem działającym w tle — reguły są ewaluowane synchronicznie, w tej samej iteracji siatki czasowej co obliczenia SELECT. Daje to pewność, że alarm zawsze odnosi się do danych właśnie obliczonych, a nie z poprzedniego cyklu.


Miejsce RULE w cyklu przetwarzania

Przypomnijmy schemat funkcji processRows() opisanej w rozdziale Algorytm przeglądu drzewa zapytań. Dla każdego zapytania nie będącego deklaracją wykonywane są kolejno cztery kroki (Rys. 49):

%%{init: {"markdownAutoWrap": false}}%%
flowchart LR
    A["constructInputPayload()"] --> B["constructOutputPayload()"]
    B --> C["write()"]
    C --> D["constructRulesAndUpdate()"]

Rys. 49. Kolejność kroków przetwarzania jednego zapytania

Krok czwarty — constructRulesAndUpdate() — to właśnie wykonanie wszystkich reguł przypiętych do bieżącego zapytania. Wywoływany jest po zapisaniu wyników SELECT na dysk, co oznacza, że reguła zawsze ocenia gotową, właśnie obliczoną próbkę strumienia.


Ewaluacja warunku WHEN

Każda reguła zawiera listę tokenów opisujących wyrażenie logiczne (pole condition struktury rule). W momencie ewaluacji system:

  1. Pobiera outputPayload bieżącego zapytania — to bieżąca próbka strumienia.
  2. Przekazuje warunek do silnika expressionEvaluator::eval()tego samego silnika, który oblicza wyrażenia SELECT.
  3. Rzutuje wynik na wartość logiczną (boolCast): każda niezerowa wartość liczbowa to true, zero to false.

Jeśli warunek jest spełniony, wykonywana jest skojarzony z regułą akcja (DO SYSTEM lub DO DUMP). Jeśli niespełniony — reguła jest pomijana bez żadnych efektów ubocznych. Pełny przepływ przedstawia Rys. 50.

%%{init: {"markdownAutoWrap": false}}%%
flowchart TD
    A["Nowa próbka strumienia"] --> B["expressionEvaluator::eval(warunek, próbka)"]
    B --> C{boolCast}
    C -->|true| D{typ akcji?}
    C -->|false| E([pomiń])
    D -->|DO SYSTEM| F["system(polecenie)"]
    D -->|DO DUMP| G["dumpManager::registerTask()"]
    F --> H["dumpManager::<br/>processStreamChunk()"]
    G --> H

Rys. 50. Przepływ ewaluacji reguły


Akcja DO SYSTEM

Wywołanie DO SYSTEM jest najprostsze: system wywołuje ::system(polecenie) bezpośrednio w wątku przetwarzania. Wywołanie jest synchroniczne — xretractor czeka na zakończenie procesu przed przejściem do następnej reguły.

Kod wyjścia polecenia jest sprawdzany:

  • 0 — sukces, brak wpisu w logu.
  • ≠ 0 — xretractor loguje błąd przez spdlog z kodem wyjścia.
  • Niepowodzenie system() (np. brak powłoki) — logowany jako błąd krytyczny.

⚠️ Ostrzeżenie

Polecenie wykonywane jest synchronicznie. Długo trwające skrypty (np. wysyłanie dużych plików, wywołania sieciowe z timeoutem) opóźnią cały cykl przetwarzania. W takich przypadkach zaleca się uruchamianie procesu w tle: DO SYSTEM 'mój_skrypt &'.


Akcja DO DUMP — szczegółowy algorytm

DO DUMP jest bardziej złożona, ponieważ wymaga zebrania danych z przeszłości (chwile przed zdarzeniem) i z przyszłości (chwile po zdarzeniu). Obsługuje to klasa dumpManager.

  • Zdarzenie — warunek WHEN prawdziwy dla próbki t, reguła wywołuje dumpManager::registerTask()
  • Faza 1 — zapis |step_back| próbek historycznych z bufora strumienia (albo ustawienie opóźnienia startu)
  • Faza 2 — w kolejnych iteracjach processStreamChunk() dopisuje próbki przyszłe
  • KoniecdumpedRecordsToGo osiąga 0, plik zostaje zamknięty, zadanie opuszcza kolejkę

Faza 1: dane historyczne (przy rejestracji zadania)

W chwili wyzwolenia reguły — zaraz po stwierdzeniu, że warunek jest prawdziwy — dumpManager::registerTask():

  1. Tworzy plik docelowy na dysku (POSIX open() z flagą O_CREAT | O_TRUNC).
  2. Jeśli step_back < 0, odczytuje |step_back| próbek z historycznego bufora strumienia.
    Dane historyczne istnieją, bo każdy strumień przechowuje okno poprzednich próbek niezbędne do obliczeń w oknach AGSE.
  3. Zapisuje próbki historyczne do pliku od najstarszej do najnowszej (tzn. od step_back do –1).
  4. Oblicza, ile próbek z przyszłości jeszcze pozostało do zebrania (dumpedRecordsToGo = |step_forward - step_back| - |step_back|).
  5. Jeśli step_back ≥ 0 (opóźnienie startu), ustawia delayDumpRecordsToGo = step_back.
Przykład: DUMP -3 TO 2
  Przy rejestracji: zapisz próbki t-3, t-2, t-1  (history)
  Do zebrania z przyszłości: 2 próbki (t, t+1)
  dumpedRecordsToGo = 2

Faza 2: dane przyszłe (kolejne iteracje pętli)

Po rejestracji zadanie trafia do kolejki bookOfTasks[streamName]. W każdej kolejnej iteracji siatki czasowej (gdy strumień produkuje nową próbkę) wywoływane jest dumpManager::processStreamChunk():

  1. Dla każdego aktywnego zadania w kolejce (dumpedRecordsToGo > 0):
    • Jeśli delayDumpRecordsToGo > 0 — dekrementuj i pomiń (opóźnienie startu).
    • Wpp. — zapisz bieżącą próbkę do pliku i dekrementuj dumpedRecordsToGo.
  2. Gdy dumpedRecordsToGo osiągnie 0 — zamknij deskryptor pliku i usuń zadanie z kolejki.

Pełna sekwencja dla DUMP -3 TO 2 przedstawiona jest na Rys. 51.

%%{init: {"markdownAutoWrap": false}}%%
sequenceDiagram
    participant SI as streamInstance
    participant DM as dumpManager

    note over SI: Próbka t — warunek TRUE
    SI->>DM: registerTask(stream, {-3, 2, retention=0})
    DM->>DM: Otwórz plik dump.tmp
    DM->>DM: Zapisz t-3, t-2, t-1 (historia)
    DM->>DM: dumpedRecordsToGo = 2
    SI->>DM: processStreamChunk(stream)
    DM->>DM: Zapisz t → dumpedRecordsToGo = 1

    note over SI: Próbka t+1
    SI->>DM: processStreamChunk(stream)
    DM->>DM: Zapisz t+1 → dumpedRecordsToGo = 0
    DM->>DM: Zamknij plik — zadanie gotowe

Rys. 51. Sekwencja zbierania danych przez DO DUMP –3 TO 2

Przypadek opóźnionego startu (step_back ≥ 0)

Gdy step_back jest nieujemny, zrzut nie zaczyna się od chwili zdarzenia, lecz od step_back próbek po zdarzeniu:

Przykład: DUMP 2 TO 5
  Przy rejestracji: delayDumpRecordsToGo = 2
  Próbka t   → pomiń (delay=2→1)
  Próbka t+1 → pomiń (delay=1→0)
  Próbka t+2 → zapisz (dumpedRecordsToGo = 3→2)
  Próbka t+3 → zapisz (dumpedRecordsToGo = 2→1)
  Próbka t+4 → zapisz (dumpedRecordsToGo = 1→0) — koniec

Retencja (RETENTION N)

Bez klauzuli RETENTION każde wyzwolenie reguły nadpisuje jeden plik <strumień>_<reguła>_dump.tmp. Pojemność kolejki bookOfTasks wynosi wtedy 1 — nowe zadanie wypycha stare (i zamyka jego deskryptor).

Z klauzulą RETENTION N:

  • Pojemność kolejki bookOfTasks ustawiana jest na N.
  • Numer pliku rotuje modulo N: _dump_0.tmp, _dump_1.tmp, …, _dump_(N-1).tmp.
  • Gdy N-te zadanie trafia do kolejki, najstarsze (jeszcze niezakończone) jest usuwane — destruktor dumpTask zamyka otwarty deskryptor.

Oznacza to, że przy częstych zdarzeniach i małym N nieukończony zrzut może zostać przerwany. Wartość N powinna być dobrana tak, aby czas zbierania jednego zrzutu (|step_back| + step_forward cykli) był mniejszy niż interwał między zdarzeniami pomnożony przez N.


Format pliku zrzutu

Plik zawiera surowe rekordy binarne bez żadnego nagłówka — każdy rekord ma rozmiar określony przez deskryptor (descriptor.getSizeInBytes()). Format jest identyczny z formatem używanym przez artefakty strumienia, co pozwala odczytać go narzędziem xtrdb po ręcznym podaniu schematu:

$ xtrdb
> storage <ścieżka>
> open <strumień>_<reguła>_dump { <typ> <pole> }
> list
> quit

Wiele reguł — kolejność ewaluacji

Do jednego strumienia można przypiąć wiele reguł. Wszystkie ewaluowane są w jednej iteracji constructRulesAndUpdate(), w kolejności ich deklaracji w pliku .rql. Każda reguła jest niezależna — spełnienie jednej nie wpływa na ewaluację pozostałych (Rys. 52).

%%{init: {"markdownAutoWrap": false}}%%
%% pdf-width: 100%
flowchart TD
    A["Nowa próbka strumienia S"] --> R1["Reguła 1: WHEN S[0] > 100"]
    A --> R2["Reguła 2: WHEN S[0] < 10"]
    A --> R3["Reguła 3: WHEN S[0] > 100"]
    R1 -->|true| A1["DO SYSTEM 'notify-send'"]
    R2 -->|true| A2["DO SYSTEM 'echo alarm'"]
    R3 -->|true| A3["DO DUMP -5 TO 5"]
    R1 -->|false| X1([pomiń])
    R2 -->|false| X2([pomiń])
    R3 -->|false| X3([pomiń])

Rys. 52. Niezależna ewaluacja wielu reguł na tym samym strumieniu


Ograniczenia i uwagi praktyczne

SytuacjaZachowanie
Warunek spełniony dwa razy z rzędu (np. pomiar stale powyżej progu)Każda próbka rejestruje nowe zadanie DUMP — pliki nakładają się przy braku RETENTION
Strumień wejściowy DECLARE jako cel ONBłąd kompilacji — reguły można podpiąć wyłącznie pod SELECT
Niedostateczna historia (bufor krótszy niż `step_back
Plik docelowy niedostępny (brak katalogu STORAGE)Błąd krytyczny FatalError — xretractor kończy działanie
DO SYSTEM zwraca niezerowy kodBłąd w logu spdlog; przetwarzanie kontynuuje

Ruchome okno danych AGSE

Ruchome okno danych to pojęcie powszechnie stosowane w systemach przetwarzających strumienie lub serie czasowe. Idea polega na grupowaniu danych w oknach czasowych, dając możliwość użytkownikowi możliwość przetwarzania w zamrożonych migawkach.

RetractorDB wspiera ten model przetwarzania danych poprzez operator AgSe (Agregacja i Serializacja). Operator ten jest dwuargumentowy i działa na strumieniu. Oznaczany znakiem @, ma postać:

strumień@(k, w)

gdzie:

  • k — skok okna (liczba naturalna): o ile rekordów źródłowych przesuwa się okno przy każdym kroku,
  • w — rozmiar okna (liczba całkowita różna od zera): ile pól źródłowych zawiera jeden rekord wyjściowy.

Wartość dodatnia w zachowuje historyczną konwencję RetractorDB: najnowsze pole okna jest pierwsze. Wartość ujemna oznacza agregację lustrzaną — odwraca tę kolejność, więc pola są ułożone zgodnie z napływem.

Jak zmienia się interwał strumienia wyjściowego

Jeśli strumień źródłowy ma W pól w rekordzie i interwał Δ, to strumień wyjściowy operatora @(k, w) ma:

  • |w| pól w rekordzie wyjściowym,
  • interwał wyjściowy Δ_out = (Δ / W) × k.
ParametryEfekt
k = |w|okno tumbling — kolejne okna nie zachodzą na siebie
k < |w|okno przesuwne (sliding) — kolejne okna zachodzą na siebie
k > |w|próbkowanie z przerwami — część danych jest pomijana
k = 1, |w| = 1serializacja — wielopolowy rekord rozbijany na jednoelementowe
w < 0agregacja lustrzana — kolejność napływu od najstarszego pola

Typowe wzorce użycia

# serializacja: 2 pola → 1 pole (interwał ÷ 2)
SELECT * STREAM s1 FROM A@(1,1)

# tumbling window: okna po 4 rekordy, bez nakładania
SELECT * STREAM s2 FROM A@(4,4)

# sliding window: okno 5-elementowe przesuwane o 1
SELECT * STREAM s3 FROM A@(1,5)

# próbkowanie: co piąty rekord (skip=5, okno=1)
SELECT * STREAM s4 FROM A@(5,1)

# deserializacja lustrzana: przywrócenie kolejności pól
SELECT * STREAM s5 FROM s1@(2,-2)

Wizualizacja operatora @

Poniżej schematyczne przedstawienie działania source@(k, w) dla strumienia jednoelementowego:

Dane wejściowe:   0  1  2  3  4  5  6  7  8  9  ...
                  ↓  ↓  ↓  ↓  ↓  ↓  ↓  ↓  ↓  ↓

@(1, 3) — sliding window, skok=1, okno=3:
  [2,1,0]  [3,2,1]  [4,3,2]  [5,4,3]  ...

@(3, 3) — tumbling window, skok=3, okno=3:
  [2,1,0]           [5,4,3]           ...

@(5, 1) — próbkowanie co 5 elementów:
  [0]               [5]               ...

@(2,-2) — lustrzana, skok=2, okno=2:
  [0,1]    [2,3]    [4,5]    [6,7]    ...

Okno jest stemplowane końcem przedziału: rekord o indeksie logicznym n obejmuje pozycje n·k−(|w|−1) … n·k, więc jego najnowsze pole leży dokładnie w pozycji n·k. Dzięki temu indeks logiczny okna oznacza tę samą chwilę co indeks logiczny źródła i złączenie okna z jego własnym źródłem (potok FIR) nie wyprzedza sygnału. Ilustracja powyżej pokazuje ciąg emitowanych okien; pierwsze z nich nosi indeks origin, nie zero.

AGSE emituje dopiero pełne okno. Początkowe sloty, w których okno sięgałoby przed początek źródła, nie są rekordami i nie mają definicji — tworzą raportowany w planie origin=. Sloty, w których okno jest zdefiniowane, ale najnowsze pole jeszcze nie powstało, tworzą tail=. Prawdziwy NULL obecny w danych pozostaje natomiast elementem pełnego okna. Formalna granica ogona i pojemności historii jest opisana w rozdziale Ogony, początki logiczne i obserwowalność operatorów.

Przykłady

Poniższe podrozdziały prezentują konkretne zastosowania operatora AgSe:

  • Przykład serializacji — zamiana wielopolowego rekordu na sekwencję jednoelementowych rekordów i powrót przez agregację lustrzaną.
  • Przykład średniej ruchomej — sliding window jako podstawa filtru uśredniającego sygnał.
  • Różne typy okien — tumbling, sliding i próbkowanie na jednym strumieniu danych.

Na początku rozważymy proces serializacji w operatorze Agregacji i Serializacji – AgSe.

NOTE: Opisana funkcjonalność ma pokrycie w testach: agse1, agse2, agse3, Pattern6 opisanych w załączniku pt. Testy Integracyjne.

Przykład serializacji

Na początku stwórzmy plik qplan3.rql o następującej zawartości:

DECLARE a BYTE, b BYTE STREAM A, 1 FILE 'data3.txt'
SELECT * STREAM str3 FROM A@(1,1)

Oraz przygotujmy plik data3.txt o następującej zawartości:

$ seq 0 9 | paste - -
0       1
2       3
4       5
6       7
8       9


Ostatnia, pusta linia jest istotna i znacząca. Po uruchomieniu xretractor qplan3.rql a w drugim oknie xqry -s str3 ujrzymy coś zbliżonego:

$ xqry -s str3
7
8
9
0
1
2
3
4
5
6

To co widzimy to przykład serializacji. Ciekawy aspekt operatora Agse w tym przypadku widać również w planie realizacji zapytania. Możemy zajrzeć do niego za pomocą polecenia:

$ xretractor -c qplan3.rql -f -p -d > out.dot && dot -Tsvg out.dot -o out.svg

W pliku out.svg zobaczymy następujący plan realizacji zapytania (Rys. 53):

Rys. 53. Plan realizacji zapytania po kompilacji AGSE

Ze strumienia źródłowego, w którym co sekundę przychodzą dane zawierające dwa bajty – tworzony jest strumień danych w którym co pół sekundy pojawia się jeden bajt.

Skoro mamy już w systemie strumień danych str3 zwracający sekwencyjne liczby – możemy go wykorzystać do dalszych transformacji. Dodajmy do pliku qplan3.rql następujące zapytanie:

SELECT * STREAM str4 FROM str3@(2,2)

Po uruchomieniu nie ujrzymy jednak oczekiwanej źródłowej postaci pliku dane3.txt. Pojawi się natomiast coś podobnego:

$ xqry -s str4
2 1
4 3
6 5
8 7
0 9
2 1
4 3
6 5

Na drodze trudnej sztuki przetwarzania strumieni danych znajdują się pułapki. To jedna z nich. Dopiero jak czytelnik dobrze się przyjrzy, to zauważy że dane są odbite w lustrze. Proszę zamień to zapytanie na taką formę:

SELECT * STREAM str4 FROM str3@(2,-2)

Dopiero tak zbudowany strumień przedstawi postać źródłową widoczną w pliku dane3.txt:

$ xqry -s str4
3 4
5 6
7 8
9 0
1 2
3 4

Ten minus przy wskazaniu szerokości okna, to odbicie lustrzane. Rozmiar okna wynosi dwa, natomiast sekwencja pól jest zbudowana w odwrotnej kolejności.

Generując obraz planu zapytania realizujący najpierw serializację a potem deserializację ujrzymy następującą zależność (Rys. 54):

Rys. 54. SErializacja i DEserializacja

Zaprezentowano tutaj najbardziej podstawowy przykład zastosowania operatora ruchomego okna danych. Jeśli zaczniemy eksperymentować ze skokiem i rozmiarem okna, zauważymy, że jesteśmy w stanie stworzyć dowolne, przesuwające się okno nad strumieniem danych lub pominąć niektóre elementy budując skok większy od szerokości okna.

Zapis realizacji eksperymentu (Animacja) przedstawia się następująco:

Animacja. Zapis sesji eksperymentu z serializatorem AGSE

NOTE: Opisana funkcjonalność ma pokrycie w testach: agse1, agse2, agse3, Pattern6 opisanych w załączniku pt. Testy Integracyjne.

Przykład średniej ruchomej

Średnia ruchoma (ang. moving average) to jeden z najprostszych i najczęściej stosowanych filtrów sygnałowych. Każdy punkt wyjściowy jest średnią arytmetyczną N ostatnich próbek. Operator @(1, N) w RetractorDB tworzy dokładnie takie okno: dla każdego nowego pomiaru dostępne jest N ostatnich wartości.

Dane źródłowe

Przyjmijmy strumień temperatury mierzonej co sekundę. Plik temp.txt zawiera kolejne odczyty:

$ seq 10 5 60 > temp.txt
10
15
20
25
30
35
40
45
50
55
60

Zapytanie RQL

Plik avg.rql:

DECLARE temp INTEGER \
STREAM sensor, 1 \
FILE 'temp.txt'

SELECT * \
STREAM window5 \
FROM sensor@(1,5)

SELECT window5[0]+window5[1]+window5[2]+window5[3]+window5[4] \
STREAM sumRow \
FROM window5

SELECT sumRow[0]/5 \
STREAM avg5 \
FROM sumRow

Co robi każde zapytanie

  1. sensor@(1,5) — tworzy przesuwne okno 5-elementowe. Każdy rekord window5 zawiera 5 ostatnich odczytów temperatury. Interwał wyjściowy: 1s / 1 × 1 = 1s (skok=1, W=1 pole).
  2. Suma pięciu pól — klasyczne SELECT po polach window5[0]..window5[4].
  3. Podzielenie sumy przez 5 — wynik to średnia ruchoma.

Uruchomienie

$ xretractor avg.rql &
$ xqry -s avg5

Przykładowy wynik (okno wypełnia się po pierwszych 5 próbkach):

30
35
40
45
50

Wartość 30 odpowiada średniej z pierwszego pełnego okna: (10+15+20+25+30)/5 = 20… uwaga — system RetractorDB nie wyświetla niepełnych okien, więc pierwsze pojawienie się wyniku odpowiada chwili gdy okno jest w pełni nasycone danymi.

Weryfikacja planu zapytania

$ xretractor -c avg.rql -f -p -d > out.dot && dot -Tsvg out.dot -o out.svg

W wygenerowanym planie widać łańcuch: sensor → window5 → sumRow → avg5. Kluczowy jest węzeł sensor@(1,5) — z jednoelementowego strumienia wchodzącego co sekundę powstaje strumień pięcioelementowy, ciągle przesuwany.

Zależność między parametrami okna a opóźnieniem

Średnia ruchoma wprowadza opóźnienie o połowę długości okna. Dla okna N=5 opóźnienie wynosi 2 próbki (2 sekundy). Zwiększenie okna:

  • zmniejsza szum (większe wygładzenie),
  • zwiększa opóźnienie,
  • nie zmienia interwału wyjściowego (przy stałym skoku k=1).

Zmiana skoku przy stałym oknie:

sensor@(5,5)   -- tumbling: wynik co 5 sekund, bez nakładania
sensor@(1,5)   -- sliding:  wynik co sekundę, pełne nakładanie
sensor@(3,5)   -- częściowe nakładanie: wynik co 3 sekundy

NOTE: Opisana funkcjonalność ma pokrycie w testach: agse1, agse2, agse3, Pattern6 opisanych w załączniku pt. Testy Integracyjne.

Różne typy okien

Operator @(k, w) przez dobór dwóch parametrów pozwala zbudować każdy z klasycznych typów okien stosowanych w przetwarzaniu strumieni. Poniżej zestawienie wzorców na jednym wspólnym strumieniu źródłowym.

Strumień źródłowy

Plik data.txt — 12 kolejnych liczb całkowitych:

$ seq 1 12 > data.txt

Deklaracja źródła — jeden rekord co sekundę, jedno pole:

DECLARE val INTEGER \
STREAM src, 1 \
FILE 'data.txt'

Tumbling window — okna bez nakładania

Skok równy rozmiarowi okna: k = w. Każdy element wejściowy należy dokładnie do jednego okna wyjściowego.

SELECT * \
STREAM tumbling \
FROM src@(4,4)

Interwał wyjściowy: 1s × 4 / 1 = 4s. Rekordy wyjściowe:

$ xqry -s tumbling
1  2  3  4
5  6  7  8
9 10 11 12

Zastosowania: agregacja próbek w stałych przedziałach czasu (np. minutowe, godzinowe).

Sliding window — okna z nakładaniem

Skok mniejszy od rozmiaru okna: k < w. Każdy element wejściowy pojawia się w kilku kolejnych oknach.

SELECT * \
STREAM sliding \
FROM src@(1,4)

Interwał wyjściowy: 1s × 1 / 1 = 1s. Rekordy wyjściowe:

$ xqry -s sliding
1  2  3  4
2  3  4  5
3  4  5  6
4  5  6  7
...

Zastosowania: średnia ruchoma, detekcja trendów, filtry FIR (jak w implementacji filtru sygnałowego).

Próbkowanie — okna z przerwami

Skok większy od rozmiaru okna: k > w. Część elementów wejściowych jest pomijana.

SELECT * \
STREAM sampled \
FROM src@(3,1)

Interwał wyjściowy: 1s × 3 / 1 = 3s. Rekordy wyjściowe:

$ xqry -s sampled
1
4
7
10

Zastosowania: decimacja sygnału, redukcja częstotliwości próbkowania, diagnostyka co N-ty pomiar.

Okno lustrzane — odwrócona kolejność pól

Ujemna wartość w odwraca kolejność pól w rekordzie wyjściowym przy zachowaniu tego samego rozmiaru okna.

SELECT * \
STREAM mirrored \
FROM src@(2,-2)

Interwał wyjściowy: 1s × 2 / 1 = 2s. Rekordy wyjściowe (kolejność pól odwrócona):

$ xqry -s mirrored
2  1
4  3
6  5
8  7
...

Porównaj z src@(2,2), które dałoby 1 2, 3 4, 5 6… — kolejność zgodna z napływem. Agregacja lustrzana jest niezbędna przy odwracaniu serializacji (deserializacja), jak opisano w przykładzie serializacji.

Zestawienie wzorców

ZapytanieTyp oknaInterwałRozmiar rekorduNakładanie
src@(4,4)tumbling4 s4 polabrak
src@(1,4)sliding1 s4 polapełne
src@(2,4)hop window2 s4 polaczęściowe
src@(3,1)próbkowanie3 s1 polebrak
src@(2,-2)lustrzane2 s2 polabrak

Plan realizacji zapytań

Wszystkie cztery warianty można uruchomić jednocześnie umieszczając je w jednym pliku .rql:

DECLARE val INTEGER STREAM src, 1 FILE 'data.txt'

SELECT * STREAM tumbling FROM src@(4,4)
SELECT * STREAM sliding  FROM src@(1,4)
SELECT * STREAM sampled  FROM src@(3,1)
SELECT * STREAM mirrored FROM src@(2,-2)
$ xretractor -c windows.rql -f -p -d > out.dot && dot -Tsvg out.dot -o out.svg

Plan zapytania pokaże cztery niezależne gałęzie wywodzące się ze wspólnego węzła src. Każda gałąź realizuje inny typ okna bez wzajemnych zależności.

NOTE: Opisana funkcjonalność ma pokrycie w testach: agse1, agse2, agse3, Pattern6 opisanych w załączniku pt. Testy Integracyjne.

Odtwarzanie strumienia

Serie czasowe bardzo często rozumiemy jako dane oznaczone znacznikami czasowymi. Dane zachowane np. w pliku możemy dowolnie przetwarzać – zachowując ich kolejność w oparciu o zarejestrowane zależności czasowe. System RetractorDB został wyposażony w możliwość ponownego wyemitowania takiego strumienia zachowując zarejestrowane zależności czasowe, tak jakby faktycznie ponownie te dane napływały.

W celu przedstawienia przykładu przygotujmy plik tekstowy wypełniony danymi np. od 30 do 45.

$ seq 30 45 > dane.txt

Tak przygotowany plik będziemy odtwarzać w systemie RetractorDB.

W kolejnym kroku stwórzmy następujący plik wypełniony zapytaniami dla systemu – query.rql zawierający tylko jedną deklarację zakończoną HOLD.

DECLARE a INTEGER STREAM core, 1 FILE 'dane.txt' HOLD

Uruchamiamy w jednym oknie polecenie:

$ xretractor query.rql

W kolejnym wydajemy polecenie:

$ xqry -s core
0
0
0
…

Zobaczymy ciąg zer …

W kolejnym oknie wydajemy następujące polecenie:

$ xqry -a "SELECT * STREAM ping FROM core VOLATILE"

Kod wyjścia 0 bez komunikatu oznacza, że zapytanie zostało przyjęte. W tym momencie w oknie prezentującym wartości ze strumienia core pojawią się wartości core.

$ xqry -s core
0
0
0
…
0
0
0
30
31
32
33
…

Nagrany przykład poniżej (Rys. 55):

Rys. 55. Nagrany przykład odtwarzania strumienia

Przykłady zastosowań

W niniejszym rozdziale zostaną przedstawione krótkie przykłady zastosowania systemu RetractorDB w rozwiązaniu konkretnych zagadnień spotykanych w konstrukcjach systemów monitorowania.

Każdy przykład jest kompletny — zawiera opis problemu, projekt zapytań RQL, uruchomienie oraz interpretację wyników. Przykłady można uruchomić samodzielnie: wymagane pliki danych i skrypty są opisane krok po kroku.

  • Filtracja sygnałów (FIR)

    Przykład demonstruje, jak zrealizować cyfrowy filtr FIR bezpośrednio w strumieniu zapytań RQL, bez zewnętrznych bibliotek DSP. Zagadnienie jest reprezentatywne dla szerokiej klasy problemów przetwarzania sygnałów: filtracja szumów, separacja pasm częstotliwości, wygładzanie serii czasowych.

    Przykład obejmuje:

    • projektowanie współczynników filtru w programie GNU Octave (algorytm Remeza, metoda remez()),
    • przeniesienie współczynników do pliku tekstowego i wczytanie ich jako strumień DECLARE,
    • implementację splotu dyskretnego jako zestawu zapytań SELECT z operatorem ruchomego okna @ i rozwinięciem symbolu _,
    • wizualizację przebiegu filtracji w czasie rzeczywistym za pomocą xqry i gnuplot.

    Wynikiem jest działający system filtrujący sygnał pseudolosowy (50 Hz) do pasma 0–2 Hz, obserwowany na żywo podczas działania xretractor.

  • Analiza sygnałów EKG (MIT-BIH)

    Przykład demonstruje zastosowanie RetractorDB do przetwarzania klinicznych sygnałów EKG z publicznej bazy MIT-BIH Arrhythmia Database (PhysioNet). Jest to złożony przypadek użycia łączący kilka mechanizmów systemu: wielokanałowe strumienie wejściowe, wieloetapowy potok filtracji FIR, adaptacyjny próg detekcji oraz wizualizację w czasie rzeczywistym.

    Przykład obejmuje:

    • przygotowanie danych: konwersja nagrań MIT-BIH (format WFDB) do plików tekstowych kompatybilnych z RetractorDB,
    • implementację pięcioetapowego algorytmu Pan-Tompkins w RQL: filtr pasmowoprzepustowy → różniczkowanie → potęgowanie → całkowanie ruchome → detekcja progowa,
    • wizualizację sygnału EKG i wyniku detekcji QRS w oknie gnuplot (tryb RTL — najnowsze próbki po prawej),
    • interpretację wyników: odczyty interwałów RR, identyfikacja epizodów arytmii na rekordzie 205 MIT-BIH.

    Wynikiem jest działający detektor QRS przetwarzający dwukanałowy sygnał EKG (MLII + V1) z częstotliwością 360 Hz, realizowany wyłącznie zapytaniami RQL bez specjalistycznych bibliotek.

Implementacja filtru sygnałowego

Zagadnienia związane z przetwarzaniem sygnałów cyfrowych zawierają w sobie problemy związane z filtracją. Celem filtracji jest rozdzielenie informacji zawartych wewnątrz sygnału. Zazwyczaj celem jest oddzielenie sygnału od jego zakłóceń.

Filtry mogą być analogowe oraz cyfrowe. W ramach proponowanego rozwiązania skupimy się na filtrach cyfrowych. Filtr cyfrowy implementowany jest jako ciąg operacji na kolejnych danych przetwarzanego sygnału w danym oknie czasowym. Z reguły dobierając filtr cyfrowy musimy zdecydować na jakie kompromisy musimy się zgodzić. Dodatkowo, możemy trafić na zabezpieczenia prawne związane z niektórymi algorytmami lub metodami [9].

Projektowanie filtru w Octave

Projektując filtr cyfrowy musimy określić jaki zakres częstotliwości chcemy wytłumić a jaki wzmocnić lub pozostawić nienaruszony. Parametry te określamy jako pasmo zaporowe i przepustowe. Jednym ze znanych mi narzędzi używanych do konstrukcji filtrów cyfrowych jest program GNU Octave (https://octave.org). Za pomocą tego narzędzia możemy wygenerować wymagane współczynniki do obliczeń prostego cyfrowego filtru sygnałowego.

Dla przykładu przyjmiemy następujące wartości wymagane do konstrukcji filtru sygnałowego:

  • Częstotliwość próbkowania sygnału wejściowego 50Hz
  • Pasmo przepustowe 0-2Hz
  • Pasmo zaporowe 5-25Hz

Częstotliwość próbkowania sygnału wejściowego 50Hz oznacza że 50 próbek pojawi się w ciągu sekundy. W systemie RetractorDB oznacza to że sygnał źródłowy powinien napływać z szybkością Delta = 0,02. I taką częstotliwość powinno wspierać zdefiniowane źródło danych.

Dla takich założeń filtra sygnałowego program w języku Octave tworzący filtr sygnałowy przedstawia się następująco:

pkg signal load
filtord = 25 % Długość filtru
Fs = 50;     % Częstotliwość próbkowania 50Hz
FNq = Fs/2;  % Częstotliwość Nyquista
F1c = 2;     % Pasmo przepustowe 0 - 2Hz
F2c = 5;     % Pasmo zaporowe 5 Hz ->
F3c = 25;    % Pasmo zaporowe <- 25 Hz
f=[0,F1c/FNq,F2c/FNq,F3c/FNq]
m = [ 1 , 1 , 0, 0 ]
freqz ( remez(filtord,f,m) );

Tak przygotowany plik powinniśmy zapisać na dysku lub wkopiować bezpośrednio do okna terminala programu Octave.

Parametry zmiennoprzecinkowe filtru możemy wyświetlić wydając polecenie remez(filtord,f,m). Graficzną charakterystykę filtru uzyskamy wydając następujące polecenie:

octave:1> [h, w] = freqz ( remez(filtord,f,m) );
subplot(2,1,1);
plot (f, m, '', w/pi, abs (h), '');
xlabel('Znormalizowana częstotliwość')
ylabel('wzmocnienie')
grid on
subplot(2,1,2);
plot(f,20*log10(m+1e-5),'', w/pi,20*log10(abs(h)),'');
xlabel('Znormalizowana częstotliwość')
ylabel('wzmocnienie (dB)')
grid on

Uruchomienie powyższego kodu w programie Octave zaprezentuje następującą odpowiedź w postaci graficznej (Rys. 56):

Rys. 56. Reprezentacja graficzna w dziedzinie częstotliwości wyznaczonego filtru cyfrowego

Na osi rzędnych Octave przedstawia znormalizowaną częstotliwość. Zakres prezentowanej na rysunku częstotliwości na osi rzędnych od 0 do 1 odpowiada częstotliwości od 0Hz do 25Hz. Na osi odciętych pierwszy rysunek prezentuje liniowe wzmocnienie, drugi tą samą wielkość ale w skali logarytmicznej.

Parametry filtru można wyświetlić za pomocą polecenia:

octave:11> remez(filtord,f,m)
ans =
  -4.2689e-03
  -2.0148e-02
  -1.4865e-02
  -1.8188e-02
  -1.4031e-02
  -4.5861e-03
…

Chcąc otrzymać stałoprzecinkowe parametry 16 bitowego filtru należy wydać polecenie:

octave:12> floor(remez(filtord,f,m) * 32767)
ans =
   -140
   -661
   -488
   -596
   -460

Implementacja w systemie RetractorDB

Uzyskane wartości powinniśmy przenieść do pliku tekstowego o nazwie filterremez.txt

Dla celów testowych sygnał źródłowy pobierzemy z generatora liczb pseudolosowych. Dane efemeryczne pobierzemy bezpośrednio ze źródła z częstotliwością 50Hz.

Początkowa część pliku query.rql zapytania zawierająca deklaracje źródeł dla systemu RetractorDB przedstawia się następująco:

DECLARE coef INTEGER[25] \
STREAM filter, 1 \
FILE 'filterremez.txt'

DECLARE data BYTE \
STREAM source, 0.02 \
FILE '/dev/urandom'

W kolejnej części znajdują się polecenia tworzące proces przetwarzania sygnałów.

SELECT source[_] * filter[_] \
STREAM accRow \
FROM source@(1,25)+filter

SELECT accRow[0] \
STREAM output \
FROM SUMC(accRow)

SELECT int(output[0]/25/1000),source[0] \
STREAM outputAll \
FROM output+source

Pierwsze z trzech zapytań umieszcza okno bezpośrednio w klauzuli FROM. Indeks source[_] przyjmuje szerokość 25 slotów wnoszonych przez source@(1,25), dlatego kompilator tworzy 25 iloczynów z odpowiadającymi współczynnikami filter[_]. Nie jest potrzebny osobny, nazwany strumień okna; kompilator wydziela go jako substrat planu. Następnie SUMC(accRow) sumuje iloczyny, a ostatnie zapytanie łączy wynik filtru z bieżącą próbką źródła. Suma z SUMC ma typ RATIONAL, dlatego ostatnie zapytanie skaluje ją i rzutuje funkcją int(...) na liczbę całkowitą; bez rzutowania xqry wypisywałby ułamki postaci 2225159/12500, z których gnuplot odczytuje tylko licznik, i przebieg przefiltrowany znalazłby się poza zakresem osi.

Po rozwinięciu symbolu [_] plan zawiera wiele pól, więc pełny wynik kompilacji zajmuje kilka ekranów. Możliwy do szybkiej analizy podgląd procesu można uzyskać poleceniem:

$ xretractor -c query.rql -p -d > out.dot && dot -Tsvg out.dot -o out.svg

Ujrzymy następujący obraz (Rys. 57):

Rys. 57. Zależność przetwarzanych strumieni danych w trakcie realizacji filtru sygnałowego

Uruchomienie

Próba podejrzenia zawartych pól oraz typów danych spowoduje rozszerzenie wygenerowanego rysunku na tyle, że niemożliwe jest załączenie tutaj wygenerowanego wyniku bez utraty czytelności.

Pragnąc podejrzeć proces przetwarzania sygnałów w czasie rzeczywistym powinniśmy wydać następujący ciąg poleceń:

- w pierwszym oknie uruchomić proces serwera przetwarzający zgromadzone dane. Powinny się w tym katalogu znajdować pliki query.rql oraz filterremez.txt za pomocą polecenia

$ xretractor query.rql

- w drugim oknie wydać należy następujące polecenie:

$ xqry -s outputAll -p 50:256 | gnuplot

Na ekranie powinniśmy ujrzeć następujący wykres biegnący z lewa na prawo wypełniany na bieżąco danymi (Rys. 58):

Rys. 58. Filtracja sygnału zrealizowana wewnątrz RetractorDB

Na Rys. 58 widzimy dwa wykresy nałożone na siebie. Ten bardziej zróżnicowany – na ekranie komputera widoczny jako niebieska linia zawierająca dużą zmienność to wizualizacja sygnału wejściowego. Dane pobrane z generatora liczb pseudolosowych z częstotliwością 50 próbek na sekundę. Oraz drugi wykres opływający dane wejściowe – na ekranie komputera prezentowany w kolorze czerwonym, bardziej łagodny, opływający – to właśnie dane przefiltrowane opracowanym filtrem sygnałowym. Sygnał, którego pasmo przepustowe zostało ograniczone do 0-2Hz (niskich częstotliwości) i ograniczone zaporowo w obszarze (5-25Hz) w obszarze wysokich częstotliwości. Obrazowo można powiedzieć, że wyizolowaliśmy linię melodyczną dla basów.

Należy pamiętać, że na ekranie komputera ten wykres przesuwa się w prawo bardzo szybko, prezentując obraz możliwości bieżącego przetwarzania danych realizowanych w systemie RetractorDB.

Zapis ekranu w trakcie realizacji procesu przetwarzania przedstawia Rys. 59:

Rys. 59. Animacja procesu filtracji sygnału w czasie rzeczywistym

NOTE: Opisana funkcjonalność ma pokrycie w teście: dsp opisanym w załączniku pt. Testy Integracyjne.

Wizualizacja EKG i Detekcja Arytmii — baza MIT-BIH

Źródło danych — PhysioNet MIT-BIH Arrhythmia Database

Baza MIT-BIH Arrhythmia Database jest publicznie dostępnym zbiorem nagrań elektrokardiograficznych opublikowanym przez PhysioNet pod adresem:

https://physionet.org/content/mitdb/1.0.0/

Zawiera 48 półgodzinnych nagrań dwukanałowych zebranych od 47 pacjentów w Beth Israel Hospital w Bostonie w latach 1975–1979. Nagrania zostały manualnie zaadnotowane przez co najmniej dwóch niezależnych kardiologów i są szeroko stosowane w badaniach nad automatyczną detekcją arytmii.

Rekord 205

Przykład korzysta z rekordu 205 — nagrania 59-letniego mężczyzny leczonego Digoksyną i Quinaglutem. Rekord zawiera przypadki częstoskurczu komorowego (VT) i jest często cytowany w literaturze jako trudny diagnostycznie ze względu na dwie morfologicznie odmienne formy dodatkowych pobudzeń komorowych (PVC).

Parametry nagrania:

ParametrWartość
Czas trwania≈ 30 min
Częstotliwość próbkowania360 Hz
Liczba próbek650 000
Kanał 1 (MLII)Zmodyfikowane odprowadzenie kończynowe II
Kanał 2 (V1)Odprowadzenie przedsercowe V1
Rozdzielczość12 bitów, wzmocnienie 200 LSB/mV, punkt zerowy 1024

Wartości surowe przechowywane są jako liczby całkowite bez jednostek (tzw. wartości ADC). Przeliczenie na miliwolty:

\[\text{mV} = \frac{\text{ADC} - 1024}{200}\]

Zakres rzeczywistych wartości w pliku rec205 mieści się w przedziale 589–1315 (MLII) i 718–1106 (V1), co odpowiada amplitudzie sygnału w granicach ok. ±1,5 mV.

Przygotowanie danych

Oryginalne pliki nagrania (205.hea, 205.dat, 205.atr) są dostarczane w formacie MIT-BIH i wymagają konwersji do formatu binarnego rozpoznawanego przez RetractorDB.

Format MIT-BIH 212

Sygnał w pliku 205.dat jest spakowany dwanaście-bitowo w formacie 212: każde trzy bajty przechowują dwie kolejne próbki obu kanałów według schematu:

[B0][B1][B2] → MLII = B0  | ((B1 & 0x0F) << 8)
               V1   = B2  | ((B1 >>  4)  << 8)

Wartości są 12-bitowe ze znakiem (zakres –2048..2047).

Konwersja do formatu RetractorDB

Skrypt examples/ecg/mitbih2rdb.py czyta nagłówek 205.hea, dekoduje pary próbek i zapisuje je jako rekordy int32 little-endian do pliku rec205:

650 000 rekordów × 2 pola × 4 bajty = 5 200 000 bajtów

Jednocześnie skrypt generuje skrypt RQL odtwarzający sygnał (rec205-replay.rql). Plik deskryptora rec205.desc jest tworzony przez build.sh.

Całość przygotowania uruchamia się jednym poleceniem z katalogu głównego projektu:

bash examples/ecg/build.sh

Wynikiem są trzy pliki w katalogu examples/ecg/rec205/:

PlikGenerujeOpis
rec205mitbih2rdb.pyDane binarne (int32 LE)
rec205.descbuild.shDeskryptor strumienia
rec205-replay.rqlmitbih2rdb.pySkrypt RQL odtwarzania

Zapytanie RQL

Plik rec205-replay.rql definiuje dwa strumienie:

DECLARE MLII INTEGER, V1 INTEGER STREAM ecg, 1/360 FILE 'rec205'

SELECT ecg.MLII, ecg.V1 STREAM s205out FROM ecg VOLATILE

Klauzula STREAM ecg, 1/360 określa interwał czasowy jednej próbki jako 1/360 s, co odpowiada rzeczywistej częstotliwości próbkowania 360 Hz. Klauzula TYPE DEVICE w deskryptorze powoduje, że plik rec205 jest czytany sekwencyjnie w pętli (po ostatniej próbce odczyt wraca do początku), co umożliwia ciągłe odtwarzanie nagrania.

Strumień wyjściowy s205out jest zadeklarowany jako VOLATILE, dlatego nie jest zapisywany na dysk — dane trafiają wyłącznie do procesu konsumenta (xqry).

Wizualizacja na ekranie

Do wyświetlenia wykresu w czasie rzeczywistym służy cel ecg w systemie budowania. Uruchamia on skrypt scripts/xplot.sh, który startuje xretractor w tle, a następnie przepuszcza strumień danych przez xqry do gnuplot.

# z katalogu build/Debug
ninja ecg

Wywołanie rozwijane przez CMake:

scripts/xplot.sh s205out rec205-replay.rql 720,560,1360 --gnuplot-rtl

Znaczenie parametrów:

ParametrZnaczenie
s205outNazwa strumienia wynikowego
rec205-replay.rqlPlik zapytań
720Szerokość okna danych (próbki widoczne jednocześnie)
560,1360Zakres osi Y (wartości ADC pasujące do rzeczywistego sygnału)
--gnuplot-rtlNajnowsze próbki po prawej stronie, wykres przesuwa się od prawej do lewej

Opcja --gnuplot-rtl jest parametrem xqry powodującym odwrócenie osi X gnuplota (set xrange [720:0]). Efekt jest taki, że najświeższe próbki pojawiają się po prawej stronie okna, a starsze przesuwają się w lewo — analogicznie do klasycznego wydruku EKG na taśmie papierowej.

Skrypt nadaje uruchamianemu serwerowi nazwę wyprowadzoną z katalogu roboczego i przekazuje ją do wszystkich wywołań xqry --server. Dzięki temu kilka celów wykresowych może działać równocześnie bez przejęcia cudzej instancji. Opcjonalny piąty argument pozwala podać inną nazwę; jeżeli jest już zajęta, skrypt kończy się przed usunięciem katalogu wynikowego.

Widok okna gnuplot z przebiegiem EKG odtwarzanym w RetractorDB

Rys. 60. Widok okna gnuplot z odtwarzanym sygnałem EKG (rekord 205)

Okno przedstawione na Rys. 60 prezentuje 720 próbek, czyli dokładnie 2 sekundy sygnału przy 360 Hz, co odpowiada typowej szerokości jednego paska EKG używanej w diagnostyce.

Detekcja QRS i identyfikacja arytmii

Kontekst — algorytm Pan-Tompkins

Detekcja zespołów QRS jest fundamentem automatycznej analizy EKG. Zespół QRS reprezentuje depolaryzację komór serca i odpowiada każdemu uderzeniu serca widocznemu jako ostry pik w sygnale. Znając położenia QRS w czasie, można wyliczyć interwały RR, a na ich podstawie rozpoznać podstawowe zaburzenia rytmu:

Miara pochodna od QRSZastosowanie
Interwały RRCzęstotliwość akcji serca (HR), VT, bradykardia
Zmienność RR (HRV)Autonomiczny układ nerwowy, przewidywanie zdarzeń
Morfologia QRSRozróżnienie PVC od normalnego rytmu, APC
Czas trwania QRSBlok odnogi pęczka Hisa (BBB)

Algorytm Pan-Tompkins (1985) jest klasycznym, pięcioetapowym potokowym algorytmem cyfrowego przetwarzania sygnałów realizowanym za pomocą filtrów FIR. RetractorDB implementuje go bezpośrednio jako strumień zapytań RQL, bez specjalistycznych bibliotek DSP.

Generowanie filtrów sygnałowych (coef)

Algorytm wymaga dwóch zestawów współczynników FIR, przechowywanych jako pliki tekstowe (bp_coef.txt, d_coef.txt). Generowane są jednorazowo skryptami Pythona przed uruchomieniem detekcji.

Filtr pasmowoprzepustowy — gen_bp_coef.py

Krok 1 algorytmu wymaga filtru wycinającego szumy i artefakty poza pasmem QRS. Pasm przepustowe 5–15 Hz przy fs = 360 Hz daje odpowiedź zawierającą morfologię QRS przy jednoczesnym tłumieniu linii bazowej (< 5 Hz) i szumów mięśniowych (> 15 Hz).

Metoda projektowania to okienkowy sinc (windowed sinc):

h_bp[n] = (h_lp2[n] − h_lp1[n]) · w[n]

gdzie:

  • h_lp[n] = 2·fc·sinc(2·fc·(n−M)) — idealny filtr dolnoprzepustowy
  • w[n] = 0,54 − 0,46·cos(2πn/(N−1)) — okno Hamminga tłumiące efekty Gibbsa
  • M = (N−1)/2 = 12 — punkt centralny filtru (opóźnienie grupowe = 12 próbek)

Parametry:

ParametrWartość
Długość filtru N25 współczynników
Dolna f. graniczna fc₁5 Hz (znorm. 5/360)
Górna f. graniczna fc₂15 Hz (znorm. 15/360)
Skala całkowitoliczbowa×1000 (dzielona /1000 w RQL)

Uruchomienie skryptu:

cd examples/ecg/rec205
python3 gen_bp_coef.py
# Zapisano 25 współczynników do bp_coef.txt
# Współczynniki: [-2, -2, -1, 0, 3, 8, 14, 23, 32, 41, 49, 54, 56, ...]
# Suma (wzmocnienie DC): 5 / 1000 = 0.0050

Współczynniki są symetryczne względem centrum (n=12), co potwierdza fazę liniową filtru — niezbędną właściwość przy analizie EKG, gdyż gwarantuje brak zniekształceń fazowych morfologii QRS.

Filtr różniczkujący — gen_d_coef.py

Krok 2 algorytmu stosuje filtr podkreślający strome zbocza QRS. Pan i Tompkins zaproponowali 5-punktowy estymator pochodnej:

y[n] = (1/8T) · (−x[n−4] − 2·x[n−3] + 2·x[n−1] + x[n])

Współczynniki (od najstarszej do najnowszej próbki):

h = [−1, −2, 0, 2, 1]

Właściwości filtru:

WłaściwośćWartość
Suma współczynników0 (zerowe wzmocnienie DC — eliminuje offsety)
Maksymalna odpowiedźf ≈ 10–25 Hz (zakres zbocza QRS)
Czynnik skali (1/8T)360/8 = 45 Hz (pomijany — nie wpływa na detekcję)
cd examples/ecg/rec205
python3 gen_d_coef.py
# Zapisano 5 współczynników do d_coef.txt
# Współczynniki: [-1, -2, 0, 2, 1]
# Suma (wzmocnienie DC): 0  (powinno być 0)

Implementacja potoku w RQL — rec205-detect.rql

Plik rec205-detect.rql implementuje kompletny pięcioetapowy potok dla dwóch kanałów EKG (MLII i V1):

# Okna wydzielone automatycznie z FROM mają pozostać w pamięci
SUBSTRAT 'memory'

DECLARE MLII INTEGER, V1 INTEGER STREAM ecg, 1/360 FILE 'rec205'
DECLARE bp_coef INTEGER[25] STREAM bpf, 1 FILE 'bp_coef.txt'
DECLARE d_coef INTEGER[5]   STREAM df,  1 FILE 'd_coef.txt'

# Wyodrębnienie kanałów
SELECT ecg.MLII STREAM mlii FROM ecg VOLATILE
SELECT ecg.V1   STREAM v1   FROM ecg VOLATILE

# 1. Filtr pasmowoprzepustowy (5-15 Hz) — splot FIR 25-tap
SELECT mlii[_]*bpf[_] STREAM bp_acc FROM mlii@(1,25)+bpf VOLATILE
SELECT int(bp_acc[0]/1000) STREAM bp_out FROM SUMC(bp_acc) VOLATILE

# 2. Różniczkowanie — splot FIR 5-tap
SELECT bp_out[_]*df[_] STREAM d_acc FROM bp_out@(1,5)+df VOLATILE
SELECT int(d_acc[0]) STREAM d_out FROM SUMC(d_acc) VOLATILE

# 3. Kwadrat (/1000 zapobiega przepełnieniu int32)
SELECT d_out[0]^2/1000 STREAM sq_out FROM d_out VOLATILE

# 4. Całkowanie ruchome 30 próbek (~83 ms)
SELECT int(sq_out[0]) STREAM mwi FROM AVG(sq_out@(1,30)) VOLATILE

# 5. Próg adaptacyjny — 2× średnia ruchoma 180 próbek (0,5 s)
SELECT int(mwi[0]) STREAM mwi_thr FROM AVG(mwi@(1,180)) VOLATILE

# Wyjście: MLII wycentrowane, V1 wycentrowane, sygnał detekcji ×5
SELECT mlii[0]-900, v1[0]-900, (mwi[0]-mwi_thr[0]*2)*5 \
STREAM detect_out FROM mlii+v1+mwi+mwi_thr VOLATILE

Uzasadnienie parametrów

Operator @(1,25) tworzy ruchome okno 25 próbek bezpośrednio w FROM. Indeks mlii[_] rozwija się zgodnie z 25 slotami, które to okno wnosi do rekordu wejściowego, a SUMC sumuje iloczyny z bpf[_]. W ten sposób splot dyskretny nie wymaga osobnego zapytania mlii_win. Ten sam zapis tworzy pięcioelementowy splot różniczkujący — patrz rozdział Przetwarzanie symbolu _.

Kompilator wydziela okna i reduktory z rozbudowanej klauzuli FROM jako własne substraty. VOLATILE dotyczy strumienia nazwanego w danym SELECT, nie tych automatycznych węzłów. Dyrektywa SUBSTRAT 'memory' utrzymuje cały potok pośredni w pamięci; bez niej wygenerowane okna korzystałyby z domyślnego składowania dyskowego.

Dzielenie bp_acc[0]/1000 w kroku 1 kompensuje skalę całkowitoliczbową współczynników filtru pasmowoprzepustowego. Drugie /1000, po potęgowaniu w kroku 3, ogranicza wzrost wartości; bez niego d_out[0]^2 mógłby przekroczyć zakres int32 (2 147 483 647) dla typowych amplitud EKG.

Potok liczy w arytmetyce całkowitej, dlatego każdy wynik reduktora wraca do INTEGER jawnym int(...) (skrót to_integer). SUMC i AVG nad polem INTEGER dają RATIONAL; bez rzutowania mianowniki rosną z etapu na etap (/1000, kwadrat, średnia z 30 próbek), aż boost::rational<int> przepełnia się bez ostrzeżenia.

Wyrażenie wyjściowe (mwi[0]-mwi_thr[0]*2)*5 implementuje próg adaptacyjny: wartość jest dodatnia tylko wówczas, gdy obwiednia MWI przekracza dwukrotność bieżącej średniej ruchomej — co wskazuje na wykryty QRS. Mnożnik ×5 skaluje sygnał detekcji do zakresu wizualnie porównywalnego z surowym EKG na wykresie.

Uruchomienie — ninja ecg-detect-qrs

Proces uruchamia się jedną komendą z katalogu build/Debug:

cd build/Debug
ninja ecg-detect-qrs

CMake rozwinął ten cel do polecenia:

scripts/xplot.sh detect_out rec205-detect.rql 720,-400,400 --gnuplot-rtl

Znaczenie parametrów:

ParametrZnaczenie
detect_outNazwa strumienia wynikowego (3 pola)
rec205-detect.rqlPlik zapytań z powyższym potokiem
720Szerokość okna: 720 próbek = 2 sekundy przy 360 Hz
−400,400Zakres osi Y w jednostkach ADC (≈ ±2 mV)
--gnuplot-rtlNajnowsze próbki po prawej stronie (prawo-lewo)

Skrypt xplot.sh uruchamia xretractor w tle (kompiluje i wykonuje zapytania), a następnie przez xqry przekazuje strumień detect_out do gnuplot w trybie ciągłym. Okno gnuplot odświeża się przy każdej nowej paczce próbek.

Opis rysunku — okno gnuplot

Okno gnuplot detekcji QRS: MLII, V1 i sygnał detekcji na rekordzie 205

Rys. 61. Okno gnuplot uruchomionego celem ninja ecg-detect-qrs — rekord 205 MIT-BIH, 720 próbek (2 s), RTL

Na Rys. 61 widoczne są trzy sygnały odpowiadające trzem polom strumienia detect_out:

[detect-out-0] linia czerwona — MLII wycentrowane (mlii − 900)

Surowy sygnał EKG z odprowadzenia MLII przesunięty o punkt bazowy 900 ADC tak, że oś zerowa odpowiada izolinii. Dwa ostre piki (amplituda ≈ 280 ADC ≈ 1,4 mV) w okolicach próbek 520 i 350 od prawej krawędzi reprezentują dwa kolejne zespoły QRS. Wyraźna morfologia QRS z dominującym pikiem R potwierdza prawidłowe działanie filtru pasmowoprzepustowego — szumy zostały stłumione, a pik zachował amplitudę.

[detect-out-1] linia niebieska — V1 wycentrowane (v1 − 900)

Sygnał z odprowadzenia V1 tego samego nagrania. Morfologia QRS w V1 jest z reguły mniej wyrażona niż w MLII, co widać na rysunku — sygnał niebieski wykazuje mniejszą amplitudę piku R przy podobnych pozycjach czasowych QRS. Jednoczesna obecność obu kanałów pozwala różnicować pobudzenia nadkomorowe (APC) od komorowych (PVC), ponieważ QRS komorowe wykazują odmienną morfologię w V1.

[detect-out-2] linia zielona — sygnał detekcji QRS ((mwi − 2·mwi_thr) × 5)

Sygnał wyniku algorytmu. Wartość dodatnia oznacza wykryty zespół QRS — obwiednia całkowania ruchomego przekroczyła dwukrotność progu adaptacyjnego. Na rysunku widoczne są dwa wyraźne dodatnie impulsy pokrywające się w czasie z pikami QRS na kanale MLII. Między uderzeniami linia pozostaje blisko zera lub nieznacznie poniżej — potwierdzając specyficzność detekcji.

Odstęp między dwoma widocznymi QRS wynosi w przybliżeniu 170 próbek, co przy 360 Hz daje:

RR ≈ 170 / 360 ≈ 0,47 s  →  HR ≈ 127 bpm

Wartość ta mieści się w zakresie odnotowanego w rekordzie 205 częstoskurczu komorowego (VT, 79–216 bpm), co sugeruje że wizualizowany fragment nagrania pochodzi z jednego z 6 epizodów VT odnotowanych przez kardiologów MIT-BIH.

Schemat przepływu procesu

Poniższy diagram (Rys. 62) pokazuje kompletny przepływ danych od surowego nagrania MIT-BIH do identyfikacji arytmii, ze wskazaniem miejsca, w którym RetractorDB realizuje algorytm Pan-Tompkins, oraz powiązania z klasycznymi metodami rozpoznawania arytmii:

Schemat przepływu danych w procesie detekcji QRS i identyfikacji arytmii

Rys. 62. Przepływ danych — od nagrania MIT-BIH przez potok Pan-Tompkins w RQL do wizualizacji i identyfikacji arytmii

Prawa gałąź diagramu — Identyfikacja arytmii — reprezentuje klasyczne metody analizy po detekcji QRS, które można zbudować jako kolejne zapytania RQL nadbudowane na strumieniu detect_out:

MetodaOpisPowiązanie z QRS
Interwały RRCzas między kolejnymi QRS → HRbezpośrednio z pozycji detekcji
HRV (zmienność)Odchylenie standardowe RRstatystyki strumienia RR
Klasyfikacja PVCSzerokość QRS > 120 ms, morfologia V1szerokość okna mwi
Detekcja VTSekwencja ≥ 3 PVC z HR > 100 bpmRULE na strumieniu HR+PVC
Detekcja APCWczesny, wąski QRS poprzedzający pauzęmorfologia MLII vs V1

RetractorDB udostępnia operatory RULE oraz reduktory okienkowe (AVG, SUMC), które umożliwiają implementację powyższych metod w tym samym języku zapytań RQL, bez wychodzenia poza środowisko systemu. Detekcja QRS jest pierwszym i niezbędnym etapem tej hierarchii.

Załączniki

W obszarze załączników znalazły się dokumenty, które nie są związane bezpośrednio z konstrukcją systemu, ale stanowią opis motywacji decyzji projektowych, dokumentację narzędzi oraz materiał pomocniczy dla osób wdrażających lub rozwijających system.

  • Budowanie produkcyjne i warianty diagnostyczne

    Opis kontraktu bezpieczeństwa produkcyjnego release oraz izolowanych trybów release-ablation i probe. Rozdział przedstawia kontrolę czystości źródeł, jawne wartości przełączników optymalizatora, rozdzielenie katalogów CMake i Conan, weryfikację konfiguracji gotowej binarki oraz niezmiennik zgodności wyniku między wariantami.

  • API monitorowania strumieni

    Wersjonowany kontrakt JSON Lines oraz opcjonalne biblioteki Python i C++ do obserwacji strumieni jawnie nazwanej instancji. Rozdział opisuje mapowanie typów, cykl życia subskrypcji, timeouty, ograniczone bufory, obsługę błędów oraz osobne cele budowania, instalowania i testowania API.

  • Geneza systemu

    Opis historycznych okoliczności, które doprowadziły do powstania RetractorDB. Punkt wyjścia stanowi doświadczenie autora przy budowie systemu nadzoru neonatologicznego na początku XXI wieku — zderzenie z ograniczeniami relacyjnych baz danych przy rejestracji sygnałów o wysokiej granulacji, próby oparte na ówczesnych systemach strumieniowych oraz ewolucja ku dedykowanemu silnikowi przetwarzania serii czasowych. Rozdział wyjaśnia również, skąd pochodzi nazwa „Retractor“ — nawiązanie do grupy narzędzi chirurgicznych rozdzielających i łączących struktury tkankowe, traktowane tu jako analogia do operacji na strumieniach danych.

  • Kolorowanie składni RQL

    Pliki zapytań RetractorDB (rozszerzenie .rql) mają dedykowane definicje kolorowania składni dla trzech środowisk:

    • Visual Studio Code — rozszerzenie rql-vscode instalowane z repozytorium GitHub,
    • Vim — pliki syntax/rql.vim i ftdetect/rql.vim, instalowane przez scripts/buildrdb.sh vimsyntax lub ręcznie do ~/.vim/,
    • bat / batcat — definicja w formacie Sublime Text 3, instalowana przez scripts/buildrdb.sh batsyntax.

    Każde ze środowisk rozpoznaje słowa kluczowe RQL (SELECT, DECLARE, RULE, STREAM, …), typy danych, komentarze, literały łańcuchowe i wartości liczbowe.

  • Opcje wywołania

    Kompletna dokumentacja flag wiersza poleceń dla wszystkich trzech narzędzi systemu:

    NarzędzieRola
    xretractorGłówny proces przetwarzania: kompiluje zapytania RQL i realizuje plan
    xqryKlient: odpytuje działający xretractor przez wspólną pamięć
    xtrdbNarzędzie inspekcji: analizuje artefakty binarne i metadane

    Każde narzędzie opisano w osobnym podrozdziale wraz z przykładami wywołań i objaśnieniem znaczenia poszczególnych przełączników.

  • Testy integracyjne

    Katalog wszystkich testów integracyjnych systemu z opisem weryfikowanej funkcjonalności. Testy integracyjne uruchamiają rzeczywiste binaria (xretractor, xqry, xtrdb) i porównują wyniki z wzorcami — w odróżnieniu od testów jednostkowych GTest, które testują izolowane klasy bibliotek.

    Scenariusze znajdują się we wspólnym drzewie test/IntegrationTest. Testy uruchamiające serwer otrzymują jedną z szesnastu przestrzeni RDB_NAMESPACE i blokadę zasobu CTest dla swojego katalogu, dzięki czemu większość z nich może działać równolegle bez kolizji nazw strumieni, IPC ani plików roboczych. Tylko scenariusze badające produkcyjną, globalną tożsamość pozostają RUN_SERIAL.

    Uruchomienie: ninja test lub ctest -R <nazwa> -V w katalogu build/Debug/.

Budowanie produkcyjne i warianty diagnostyczne

Skrypt scripts/buildrdb.sh rozdziela budowanie produkcyjne od kompilacji z wyłączanymi optymalizacjami oraz włączaną instrumentacją. Rozdzielenie obejmuje konfigurację CMake, katalogi wynikowe, generatory Conan oraz kontrolę gotowej binarki.

⚠️ Ostrzeżenie

Binarki z release-ablation i probe są wariantami diagnostycznymi. Nie należy ich instalować ani pakować jako wydania produkcyjne.

Tryby budowania

PoleceniePrzeznaczenieKatalog binarny
scripts/buildrdb.sh releasezweryfikowane wydanie produkcyjnebuild/Release
scripts/buildrdb.sh release-ablationwybrana konfiguracja optymalizatora i sondybuild/Release-Ablation/<konfiguracja>
scripts/buildrdb.sh probediagnostyka z włączoną sondąbuild/Release-Probe

Tryby diagnostyczne korzystają również z osobnych katalogów generatorów Conan:

  • build/Conan-Release-Ablation/<konfiguracja>,
  • build/Conan-Release-Probe.

Dzięki temu ich cache CMake, definicje kompilatora i binaria nie są zapisywane w produkcyjnym build/Release.

Kontrakt produkcyjnego release

Polecenie:

scripts/buildrdb.sh release

działa w trybie fail closed: każda niespełniona kontrola przerywa budowanie. Skrypt:

  1. wymaga repozytorium Git oraz całkowicie czystego drzewa roboczego;
  2. odrzuca zmiany śledzone, staged i pliki nieśledzone;
  3. usuwa poprzedni katalog build/Release;
  4. usuwa z procesu konfiguracji typowe zmienne pozwalające wstrzyknąć flagi kompilatora, linkera lub CMake;
  5. jawnie przekazuje pełną konfigurację produkcyjną;
  6. buduje binarkę w świeżym katalogu;
  7. odczytuje konfigurację z gotowego xretractor;
  8. ponownie sprawdza czystość drzewa źródeł.

Zmienne usuwane ze środowiska procesu budowania to między innymi CFLAGS, CPPFLAGS, CXXFLAGS, LDFLAGS, CMAKE_ARGS, CMAKE_GENERATOR oraz CMAKE_TOOLCHAIN_FILE. Zmienne uruchomieniowe sondy RDB_BENCH_CSV i RDB_BENCH_PLAN również nie są przekazywane.

Konfiguracja produkcyjna jest zawsze następująca:

RDB_OPT_DEDUP_SUBSTRATES=ON
RDB_OPT_SHARE_EQUIVALENT_SELECTS=ON
RDB_OPT_COMMUTATIVE_ADD=ON
RDB_OPT_FACTOR_MATCHED_HASH_TIMEMOVES=ON
RDB_BENCH_PROBE=OFF
RDB_OPT_SIMPLIFY_EXPRESSIONS=ON

Po kompilacji skrypt wykonuje:

build/Release/src/retractor/xretractor --build-info

i porównuje wynik z powyższym zestawem. Brak binarki albo choć jedna inna wartość kończy release błędem.

ℹ Info

Kontrola czystości Git dowodzi, że budowanie nie korzysta z lokalnych, niezatwierdzonych zmian. Nie dowodzi poprawności zawartości zatwierdzonego commitu. Za tę część odpowiadają przegląd zmian, testy i CI.

Warianty z wyłączanymi optymalizacjami

Polecenie:

scripts/buildrdb.sh release-ablation

otwiera podmenu pozwalające niezależnie przełączać:

RDB_OPT_DEDUP_SUBSTRATES
RDB_OPT_SHARE_EQUIVALENT_SELECTS
RDB_OPT_COMMUTATIVE_ADD
RDB_OPT_FACTOR_MATCHED_HASH_TIMEMOVES
RDB_BENCH_PROBE
RDB_OPT_SIMPLIFY_EXPRESSIONS

Każdy wariant otrzymuje katalog opisujący pełną konfigurację, na przykład:

build/Release-Ablation/dedup-OFF_share-ON_comm-ON_factor-ON_probe-OFF_simplify-ON

Wartości wszystkich sześciu przełączników są przekazywane jawnie. Zapobiega to dziedziczeniu wartości zapisanych przez wcześniejszą konfigurację w CMakeCache.txt.

Konfiguracja:

RDB_OPT_SHARE_EQUIVALENT_SELECTS=OFF
RDB_OPT_COMMUTATIVE_ADD=ON

jest niedozwolona. Kanonizacja przemiennego dodawania jest częścią współdzielenia równoważnych obliczeń SELECT, dlatego podmenu i CMake odrzucają takie połączenie.

Po zbudowaniu wariantu skrypt porównuje --build-info z wartościami wybranymi w podmenu. Niezgodność jest błędem konfiguracji.

Sonda pomiarowa

RDB_BENCH_PROBE jest opcjonalną instrumentacją, a nie optymalizacją planu. Polecenie:

scripts/buildrdb.sh probe

buduje wariant ze wszystkimi optymalizacjami włączonymi oraz:

RDB_BENCH_PROBE=ON

Binarka trafia do build/Release-Probe. Jest zbudowana na zoptymalizowanym kodzie Release, ale nie jest binarką produkcyjną.

W release-ablation sondę można włączyć albo wyłączyć niezależnie od konfiguracji optymalizatora.

Sonda nie uczestniczy w wyborze ani kolejności przebiegów optymalizatora. Nie jest jednak instrumentacją o zerowym koszcie: RDB_BENCH_PLAN dodatkowo przegląda plan i zapisuje statystyki, a RDB_BENCH_CSV wykonuje pomiary zegara i operacje plikowe. Sonda jest więc semantycznie nieinwazyjna, ale jej narzut może wpływać na mierzone czasy.

Jeżeli binarka ma RDB_BENCH_PROBE=ON, a podczas kompilacji ustawiona jest zmienna RDB_BENCH_PLAN, kompilator zapisuje na standardowe wyjście błędów stabilny wiersz:

REWRITE_APPLIED r1=<liczba> r2=<liczba> r3=<liczba>

Liczniki są zerowane przed każdym wywołaniem kompilatora. r1 oznacza liczbę skutecznych przekształceń (A > i) # (B > k) -> (A # B) > (i + k). r2 oznacza liczbę unikalnych węzłów STREAM_ADD, w których kanoniczny odcisk planu rzeczywiście zamienił kolejność dzieci. r3 oznacza liczbę uproszczeń programów pól i warunków RULE: zwinięć stałych, połączeń ogonów stałych i usuniętych elementów neutralnych, a także zastąpień powtórzonego dokładnego czynnika potęgą (E*E*E -> E^3). Ostatnia reguła obejmuje tylko typy BYTE, INTEGER, UINT i RATIONAL; nie przepisuje mnożenia FLOAT ani DOUBLE. Liczniki opisują zastosowane przepisania, a nie przyspieszenie. Przy RDB_BENCH_PROBE=OFF kod liczników nie trafia do binarki i wiersz REWRITE_APPLIED nie jest emitowany.

Ręczna kontrola wariantu

Każdy xretractor udostępnia:

ścieżka/do/xretractor --build-info

Polecenie wypisuje konfigurację i kończy działanie bez uruchamiania silnika (równoważny skrót: -b). Jest obsługiwane przed wczytaniem i walidacją pliku konfiguracyjnego, więc daje poprawny wynik także wtedy, gdy konfiguracja hosta uniemożliwiłaby normalny start programu. Przykładowy wynik wariantu produkcyjnego:

RDB_OPT_DEDUP_SUBSTRATES=ON
RDB_OPT_SHARE_EQUIVALENT_SELECTS=ON
RDB_OPT_COMMUTATIVE_ADD=ON
RDB_OPT_FACTOR_MATCHED_HASH_TIMEMOVES=ON
RDB_BENCH_PROBE=OFF
RDB_OPT_SIMPLIFY_EXPRESSIONS=ON

Nazwa katalogu jest pomocnicza; informacja z binarki jest ostatecznym potwierdzeniem użytych definicji kompilatora.

Testy wariantów

Wyłączenie optymalizacji może celowo zmienić strukturę planu i dostępność testów wymagających konkretnego kształtu. Nie może natomiast zmienić części wartościowej wyniku: interwału, początku logicznego, publicznego deskryptora, rekordów z mapami wartości pustych ani polityki materializacji. Ogon startowy podlega słabszej gwarancji opisanej poniżej.

CTest przypisuje testom wymagającym konkretnej optymalizacji etykiety requires_* i może je wyłączyć dla niezgodnej konfiguracji. Etykieta expected_ablation_failure opisuje wtedy oczekiwaną niedostępność testu kształtu planu, a nie przyzwolenie na różnicę semantyczną.

Procedura oceny błędu powinna być następująca:

  1. uruchomić ten sam test w konfiguracji produkcyjnej;
  2. potwierdzić, że przechodzi z wymaganymi optymalizacjami;
  3. uruchomić go w badanym wariancie;
  4. wykazać związek błędu z wyłączonym przełącznikiem;
  5. jeżeli test wymaga wyłączonego przebiegu, wyłączyć go dla tego wariantu;
  6. każdy inny błąd traktować jako regresję.

Test it_optimizer_ablation-build-info kontroluje zgodność informacji raportowanej przez binarkę z konfiguracją CMake. Pozostałe testy it_optimizer_ablation-* sprawdzają strukturę planów i porównania semantyczne między wariantami.

Wariant z wyłączoną optymalizacją może zmienić strukturę planu, ale nie może zmienić wartości, map NULL, publicznego deskryptora, początku logicznego ani polityki materializacji. Ogon może się skrócić po włączeniu poprawnego przepisania planu, lecz nie może spowodować emisji przed dostępnością danych. Każde inne odchylenie jest regresją, a nie dopuszczalną właściwością wariantu.

Pakowanie

Pakiety produkcyjne należy przygotowywać dopiero po poprawnym, zweryfikowanym release:

scripts/buildrdb.sh release package

Opcja package ponownie ustawia produkcyjne wartości przełączników i przebudowuje wybrany katalog przed uruchomieniem CPack. Nie należy uruchamiać pakowania na katalogach Release-Ablation ani Release-Probe.

Opcjonalne API klienckie

Katalog api/ jest rozwijany i testowany razem z silnikiem, ale nie należy do domyślnego produktu. Zwykłe cele ninja, ninja install, ninja test oraz ninja package pozostawiają biblioteki i testy API poza wynikiem.

Jawne wejścia są rozdzielone:

PolecenieZnaczenie
ninja install-withapiBuduje i instaluje silnik oraz komponent api.
ninja test-apiBuduje testowego klienta C++ i uruchamia testy z etykietą api.
cmake -DRDB_WITH_API=ON .Dołącza komponent api do pakietów CPack; zwykły cel test przestaje wtedy odfiltrowywać etykietę api.

Przełącznik pakowania musi być ustawiony podczas konfiguracji, ponieważ CPack ustala listę komponentów właśnie wtedy. Bez RDB_WITH_API=ON pakiety .deb i .tar.gz zawierają wyłącznie silnik, jednostkę systemd i przykłady konfiguracji. Test it_packaging chroni ten domyślny, minimalny zestaw.

Cele C++ API są zawsze znane CMake, ale mają EXCLUDE_FROM_ALL. Reguły instalacji należą do osobnego komponentu api, więc samo ninja install ich nie wykonuje. Szczegóły użycia bibliotek i kontraktu JSONL zawiera rozdział API monitorowania strumieni.

API monitorowania strumieni

Opcjonalne API klienckie służy do monitorowania strumieni działającej, jawnie nazwanej instancji xretractor na tym samym hoście Linux. Warstwa transportowa uruchamia procesy xqry --jsonl; dzięki temu API nie powiela protokołu Boost IPC i korzysta z tych samych reguł wyboru strumienia oraz kończenia subskrypcji co narzędzie wiersza poleceń.

Dostępne są:

  • pakiet Python 3.10+ bez zależności uruchomieniowych poza biblioteką standardową;
  • statyczna biblioteka C++23 z publicznym nagłówkiem i konfiguracją CMake;
  • wersjonowany kontrakt JSON Lines v1, który można obsłużyć także bez gotowej biblioteki.

API nie uruchamia serwera, nie ładuje planów, nie łączy się ponownie po awarii i nie odtwarza pominiętych próbek. Jest interfejsem obserwacji na żywo, a nie transportem trwałym ani bezstratnym.

Kontrakt JSON Lines v1

xqry --jsonl obsługuje cztery polecenia tylko do odczytu:

xqry --server laboratory --jsonl --hello
xqry --server laboratory --jsonl --dir
xqry --server laboratory --jsonl --detail temperature
xqry --server laboratory --jsonl --select temperature --elimitqry 10

Każdy wiersz stdout jest kompletnym obiektem UTF-8 JSON z version: 1 i event. Komunikaty diagnostyczne trafiają na stderr.

ZdarzenieZawartość
pongOdpowiedź na hello.
streamsTablica elementów {name, delta}; pusta dla serwera bezczynnego.
schemaNazwa strumienia, delta, oryginalne zapytanie i uporządkowane pola.
recordNazwa strumienia i spłaszczona tablica wartości.
endNormalny koniec: limit albo server_stopped_or_reloaded.
errorStabilny kod oraz opis błędu; proces kończy się niezerowo.

Subskrypcja emituje najpierw schemat, potem rekordy i dokładnie jedno końcowe zdarzenie end albo error. EOF bez zdarzenia końcowego jest błędem procesu, nawet gdy kod wyjścia wynosi zero.

{"version":1,"event":"schema","stream":"temperature","delta":"1/20","query":"...","fields":[{"name":"v","type":"INTEGER","count":2}]}
{"version":1,"event":"record","stream":"temperature","values":["21",null]}
{"version":1,"event":"end","reason":"limit"}

Pole count w schemacie jest licznością skalarną. Tablice liczbowe zachowują wszystkie elementy i osobne wartości NULL; STRING[N] pozostaje jednym napisem. Niepuste wartości na przewodzie są napisami interpretowanymi według typu ze schematu. Dzięki temu liczby wymierne zachowują licznik i mianownik, a napis "null" nie miesza się z JSON null.

Typ RQLPythonC++ Value
NULLNonestd::monostate
BYTE, INTEGER, UINTintstd::int64_t
FLOAT, DOUBLEfloatdouble
RATIONALfractions.FractionRational
INTPAIRpara liczbstd::pair<int64_t, int64_t>
IDXPAIRpara napis–liczbastd::pair<std::string, int64_t>
STRINGstrstd::string

Wiadomości nie niosą znacznika czasu źródła ani trwałego numeru sekwencji. Precyzję liczb zmiennoprzecinkowych ogranicza istniejąca tekstowa serializacja IPC, a całe opakowanie INFO musi mieścić się w limicie 1024 bajtów kolejki serwera.

--idle-timeout N kończy JSONL po N milisekundach bez rekordu; zero wyłącza limit. Jest to niezależne od timeoutu pojedynczego odczytu w bibliotekach.

Python

Pakiet instaluje się ze źródeł:

python3 -m venv .venv-api
.venv-api/bin/python -m pip install ./api/python

Każda subskrypcja posiada własny proces potomny xqry:

from retractordb import Client, ReadTimeout

with Client("laboratory", xqry="/path/to/xqry") as db:
    print(db.streams())
    print(db.describe("temperature"))
    with db.subscribe("temperature", limit=10) as samples:
        for record in samples:
            print(record["v"])
        print(samples.end_reason)

Client(server, xqry="xqry", timeout=5.0) przyjmuje timeout w sekundach. subscribe(stream, limit=0, idle_timeout=0.0, capacity=1024) zwraca obiekt ze schematem znanym już przy zakończeniu wywołania. next(timeout=...) może zgłosić ReadTimeout bez zamykania subskrypcji. Zwykła iteracja czeka bez limitu. Rekord mapuje nazwę pola na skalar albo listę elementów tablicy.

Należy używać menedżerów kontekstu lub jawnego close(). Samo przerwanie pętli for nie zamyka iteratora. Biblioteka nie instaluje obsługi sygnałów aplikacji.

C++

Biblioteka wymaga C++23. W drzewie RetractorDB jej cele są dostępne na żądanie:

cmake --build build/Debug --target rdb_monitor
build/Debug/api/cpp/rdb_monitor laboratory temperature 10 /path/to/xqry

Projekt może dołączyć źródła bezpośrednio:

add_subdirectory(/path/to/retractordb/api/cpp rdb-api)
target_link_libraries(my_monitor PRIVATE RetractorDB::client)

Po instalacji komponentu API dostępny jest pakiet CMake:

find_package(RetractorDBClient CONFIG REQUIRED)
target_link_libraries(my_monitor PRIVATE RetractorDB::client)

Minimalna subskrypcja:

#include <iostream>
#include "retractordb/client.hpp"

int main() {
  retractordb::Client db("laboratory");
  auto samples = db.subscribe("temperature", {.limit = 10});
  while (auto record = samples.next())
    std::cout << record->values.at("v").size() << '\n';
}

SubscribeOptions udostępnia limit, idleTimeout i capacity. next(timeout) zwraca std::optional<Record>; brak wartości oznacza normalny koniec lub jawne zamknięcie. Błędy rzucają retractordb::Error ze stabilnym polem code. Uchwyty subskrypcji są przenoszalne, ale niekopiowalne; destruktor i close() kończą oraz zbierają własny proces potomny.

Ograniczenia i obsługa błędów

Bufory bibliotek są ograniczone: domyślnie 1024 oczekujące zdarzenia, 1 MiB na wiersz JSONL i 64 KiB zachowanego stderr. Przepełnienie bufora aplikacji daje buffer_overflow i zamyka subskrypcję bez cichego pomijania rekordów. Przepełnienie kolejki po stronie serwera jest ograniczeniem istniejącego IPC i może ujawnić się dopiero jako timeout bezczynności.

Zamknięcie jest idempotentne: biblioteka wysyła SIGTERM do własnego xqry, czeka do sekundy, a w razie potrzeby używa SIGKILL i zbiera proces. Zamknięcie klienta zamyka wszystkie jego subskrypcje; nigdy nie wysyła xqry --kill do serwera.

Kody błędów obejmują między innymi read_timeout, idle_timeout, buffer_overflow, stream_not_found, no_active_plan, server_stopping, server_no_response, client_queue_missing, disconnected, communication_error, protocol_error, spawn_error, process_exit, process_timeout i closed.

Budowanie i testowanie

API jest rozwijane razem z silnikiem, ale pozostaje opcjonalne. Zwykłe ninja, ninja install, ninja test i ninja package nie budują, nie instalują ani nie pakują API.

PolecenieEfekt
ninja install-withapiBuduje i instaluje silnik oraz komponent api.
ninja test-apiBuduje klienta testowego i uruchamia ctest -L api.
cmake -DRDB_WITH_API=ON .Dołącza komponent api do pakietów CPack i testy API do zwykłego celu test.

API C++ jest konfigurowane zawsze, ale jego cele mają EXCLUDE_FROM_ALL. xqry ma własną jednostkę kompilacji Boost.JSON, dlatego silnik nie linkuje niczego z katalogu api/. Zależność biegnie wyłącznie od API do publicznego interfejsu procesu xqry.

Testy st_api_fake i st_api_real sprawdzają oba języki. Pierwszy obejmuje typy, NULL, błędne wyjście, przepełnienie i zamykanie procesu. Drugi używa prawdziwego xretractor i sprawdza niezależne subskrypcje, tablice, liczby wymierne, brakujący strumień oraz zatrzymanie serwera.

Opcje wywołania

RetractorDB składa się z trzech narzędzi wiersza poleceń, z których każde pełni odrębną rolę w architekturze systemu:

NarzędzieRola
xretractorProces przetwarzania: kompiluje RQL i realizuje jeden niezależny plan
xqryKlient: wyszukuje lub wskazuje instancję i komunikuje się przez jej IPC
xtrdbNarzędzie inspekcji: analizuje artefakty binarne i metadane

Każde z narzędzi opisano w osobnym podrozdziale.

xretractor

Program xretractor jest podstawowym procesem systemu RetractorDB. Kompiluje pliki z zapytaniami RQL i realizuje plan przetwarzania danych. Przygotowany jest do uruchomienia autonomicznego jako proces demona systemd.

Tryby pracy

xretractor uruchamia się w jednym z dwóch trybów:

TrybOpis
PrzetwarzaniaDomyślny — kompiluje zapytania i uruchamia pętlę realizacji zapytań
Tylko kompilacja -cKompiluje zapytania bez uruchamiania pętli; umożliwia wizualizację planu

Wywołanie -h pokazuje inną listę opcji w zależności od trybu — skróty opcji się nakładają, dlatego należy zwrócić uwagę, w którym trybie dana opcja funkcjonuje.


Tryb przetwarzania (domyślny)

$ xretractor -h
xretractor - compiler & data processing tool.

Usage: xretractor queryfile [option]

Available options:
  -h [ --help ]               Show program options
  -b [ --build-info ]         show optimizer build configuration
  -c [ --onlycompile ]        compile only mode
  -q [ --queryfile ] arg      query set file
  -r [ --quiet ]              no output on screen, skip presenter
  -s [ --status ]             check service status
  -v [ --verbose ]            verbose mode (show stream params)
  -x [ --xqrywait ]           wait with processing for first query
  -n [ --name ] arg           instance name; own IPC area and lock
  -a [ --autoname ]           generate a docker-style instance name
  -k [ --noanykey ]           do not wait for any key to terminate
  -j [ --service ]            service mode: log to stderr (journald)
  -t [ --realtime ]           enable real-time scheduling
  -f [ --no-clock ]           offline mode: compute slots without waiting
  -u [ --until-eof ]          forces one-shot all sources
  -g [ --config ] arg         config file (TOML); overrides search
  -m [ --llimitqry ] arg (=0) loop iteration limit, 0 - no limit

Opcje trybu przetwarzania

OpcjaZnaczenie
helpWyświetlenie tekstu podpowiedzi. Lista różni się w zależności od trybu (z -c lub bez).
build-infoWypisuje konfigurację optymalizatora, z jaką zbudowano binarkę (flagi RDB_OPT_* oraz RDB_BENCH_PROBE), i kończy działanie bez uruchamiania silnika. Obsługiwana przed wczytaniem i walidacją pliku konfiguracyjnego, więc działa także na hoście z niepoprawnym storage.dir. Wynik jest stabilny i przeznaczony do przetwarzania automatycznego — korzystają z niego scripts/buildrdb.sh oraz test it_optimizer_ablation-build-info. Szczegóły w załączniku o budowaniu produkcyjnym i wariantach diagnostycznych.
onlycompilePrzełączenie narzędzia w tryb „tylko kompilacja“. Pętla realizacji zapytań nie jest uruchamiana.
queryfileNazwa pliku z zapytaniami do kompilacji i uruchomienia.
quietPominięcie wyświetlania wyników na ekranie. Przetwarzanie działa normalnie, ale prezenter wyników nie jest uruchamiany.
statusSprawdzenie blokady instancji wskazanej przez --name, RDB_NAMESPACE albo historyczną pustą nazwę. Wynik Running oznacza, że inny proces utrzymuje tę samą tożsamość.
verboseTryb zwiększonej komunikatywności — wyświetla parametry strumieni. Pozostałość po fazie rozwojowej; prawdopodobnie zostanie zachowana.
xqrywaitKompiluje zapytania i wstrzymuje pętlę przetwarzania do chwili nadejścia pierwszego zapytania z procesu xqry. Wymagane przy jednoczesnym użyciu -m N w skryptach i testach: bez tej flagi serwer może przetworzyć wszystkie N cykli zanim klient zdąży się podłączyć, co skutkuje brakiem danych i oczekiwaniem po stronie xqry aż do przekroczenia limitu czasowego. Pierwsze polecenie odebrane od xqry (np. -d lub -s) odblokowuje pętlę przetwarzania.
name argNadaje instancji stałą nazwę. Nazwa wybiera osobny plik blokady i obszar IPC oraz pozwala kierować polecenia przez xqry --server. Dopuszczalne są najwyżej 32 znaki: małe litery, cyfry, _ i -, przy czym pierwszy znak musi być literą.
autonameGeneruje nazwę instancji w stylu nazw kontenerów i wypisuje ją przy starcie. Wzajemnie wyklucza się z --name.
noanykeyDowolny klawisz nie przerywa pętli przetwarzania. Bez tej opcji naciśnięcie dowolnego klawisza zatrzymuje system.
serviceTryb usługowy: dziennik trafia na stderr (przechwytywany przez journald), bez pliku dziennika w katalogu tymczasowym, bez własnego znacznika czasu i bez kodów ANSI. Tryb można włączyć również zmienną środowiskową XRETRACTOR_SERVICE o dowolnej wartości poza pustą i 0 — wygodne w jednostce systemd przez Environment=.
realtimeWłącza szeregowanie czasu rzeczywistego: SCHED_FIFO, mlockall i absolutne uśpienie wątku przetwarzającego. Wymaga uprawnień CAP_SYS_NICE i CAP_IPC_LOCK (lub root). Zalecane w środowisku produkcyjnym przy wymogu deterministycznego czasu reakcji.
no-clockTryb offline: zachowuje racjonalną oś czasu, indeksy logiczne, początki i ogony planu, ale pomija oczekiwanie na zegar ścienny. Nie może być łączony z --realtime.
until-eofPrzełącza deklarowane źródła plikowe w tryb bez zawijania i kończy przebieg, gdy pierwsze z nich wyczerpie dane. Źródło DEVICE nie ma końca pliku.
configŚcieżka do pliku konfiguracyjnego w formacie TOML. Pomija standardową kolejność wyszukiwania (/etc/retractor/retractor.toml, następnie $XDG_CONFIG_HOME/retractor/retractor.toml lub ~/.config/retractor/retractor.toml). Brak pliku konfiguracyjnego jest stanem poprawnym — program startuje z ustawieniami domyślnymi.
llimitqryOgranicza liczbę iteracji w pętli realizacji zapytań. Wartość 0 oznacza brak limitu.

Wiele instancji

Różne nazwane instancje mogą działać równocześnie:

xretractor pomiary.rql --name pomiary --noanykey &
xretractor diagnostyka.rql --name diagnostyka --noanykey &
xqry --bus

Każda otrzymuje własną blokadę i zestaw obiektów IPC. Wspólna magistrala odrzuca jednak plan, który koliduje z żywą instancją nazwą strumienia, zapisywanym plikiem magazynu albo plikiem licznika :ROTATION. Kontrola odbywa się przed usuwaniem artefaktów. Brak --name zachowuje historyczną instancję bezimienną.

Tryb usługowy stanowi osobną gwarancję: w każdej przestrzeni RDB_NAMESPACE może działać dokładnie jedna instancja usługowa, w domyślnej przestrzeni nazwana service. Szczegóły zawiera rozdział Wiele instancji i magistrala.

Przetwarzanie wsadowe bez zegara

Najprostszy przebieg całego pliku bez ręcznego dobierania liczby iteracji ma postać:

xretractor query.rql --no-clock --until-eof --noanykey --quiet

--no-clock usuwa wyłącznie uśpienia. Nie zmienia kolejności slotów ani zawartości artefaktów, dlatego nadaje się do szybkiej weryfikacji po zakończeniu procesu. Może jednak wyprzedzić klienta xqry, więc nie jest właściwym trybem do obserwacji na żywo.

--until-eof sprawia, że źródło sekwencyjne nie wraca na początek pliku. Koniec jest sprawdzany po przetworzeniu slotu, dokładnie przed rekordem, który musiałby już powstać ze syntetycznego NULL za końcem danych. Przy wielu źródłach kończy pierwsze wyczerpane, aby plan nie kontynuował obliczeń z brakującym wejściem. Opcję można łączyć z -m N; działa warunek, który wystąpi wcześniej.

⚠️ Ostrzeżenie Skróty zależą od trybu. W wykonaniu -f oznacza --no-clock, a -u oznacza --until-eof. Przy -c te same litery oznaczają odpowiednio --fields i --rules oraz nie uruchamiają przetwarzania.

NOTE: Równoważność wykonania taktowanego i offline sprawdza noclock_offline, a zatrzymanie na pierwszym końcu danych i kontrolę zawijania sprawdza untileof_stop.


Tryb tylko kompilacja (-c)

$ xretractor -h -c
xretractor - compiler & data processing tool.

Usage: xretractor -c queryfile [option]

Available options:
  -h [ --help ]          show help options
  -b [ --build-info ]    show optimizer build configuration
  -c [ --onlycompile ]   compile only mode
  -q [ --queryfile ] arg query set file
  -r [ --quiet ]         no output on screen, skip presenter
  -d [ --dot ]           create dot output
  -m [ --csv ]           create csv output
  -f [ --fields ]        show fields in dot file
  -t [ --tags ]          show tags in dot file
  -s [ --streamprogs ]   show stream programs in dot file
  -u [ --rules ]         show rules in dot file
  -i [ --hideruleprog ]  hide rule program in rules (-u) output
  -p [ --transparent ]   make dot background transparent
  -w [ --diagram ] arg   create diagram output
  -z [ --shmbudget ]     show shared memory budget of the compiled plan

W tym trybie dostępne są opcje tworzenia diagramów i zrzutów diagnostycznych opisywanych szerzej w opracowaniu.

Opcje wizualizacji i diagnostyki

OpcjaZnaczenie
helpWyświetlenie tekstu podpowiedzi (identycznie jak w trybie przetwarzania, lista różni się w zależności od trybu).
build-infoZnaczenie identyczne jak w trybie przetwarzania — wypisuje konfigurację optymalizatora i kończy działanie. Flaga -c nie ma na wynik wpływu; opcja jest dostępna w obu trybach, aby zrzut konfiguracji dał się pobrać niezależnie od sposobu wywołania.
onlycompileWłączony — w tej tabeli opisano opcje obowiązujące przy aktywnej fladze -c.
queryfileNazwa pliku z zapytaniami do kompilacji.
quietTestowanie samego procesu kompilacji bez prezentowania wyników. Pozostałe opcje prezentacji nie są uruchamiane. Opcja dołączona na potrzeby rozwojowe.
dotTworzy plik tekstowy w formacie DOT opisujący hierarchiczne struktury wytworzone przez kompilator. Plik można przekazać do narzędzia Graphviz w celu wygenerowania graficznego opisu zależności.
csvEksportuje hierarchiczne struktury danych do pliku CSV (wartości oddzielone przecinkami).
fieldsDołącza do wykresu DOT pola i ich typy dla każdego strumienia danych.
tagsDołącza do wykresu DOT programy wewnętrznego języka systemu, które tworzą pola poszczególnych zapytań. Musi być wywołana razem z fields — wizualnie łączy pola z ich programami.
streamprogsDołącza do wykresu DOT programy algebry strumieniowej tworzące poszczególne strumienie zapytań.
rulesDołącza reguły alarmowania do wykresu.
hideruleprogUkrywa programy opisujące warunki alarmowania (używane razem z rules).
transparentGeneruje wykres z przezroczystym tłem.
diagramGeneruje diagramy kulkowe. Argument w postaci typ:ilość_cykli: typ (0 lub 1) określa, czy diagramy prezentują znaczniki czasu; ilość_cykli określa liczbę cykli na diagramie.
shmbudgetRaportuje stałą rezerwację IPC, pojemność i wolne miejsce systemu plików shm_open (zwykle /dev/shm) oraz koszt jednej kolejki klienta dla każdej delty planu. Pozwala oszacować liczbę równoczesnych subskrypcji przed uruchomieniem serwera.

Plik konfiguracyjny (TOML)

Opcja --config wskazuje plik konfiguracyjny; bez niej program przeszukuje warstwowo dwie lokalizacje, w podanej kolejności, a każda kolejna warstwa nadpisuje klucze poprzedniej:

  1. /etc/retractor/retractor.toml — warstwa systemowa,
  2. $XDG_CONFIG_HOME/retractor/retractor.toml (lub ~/.config/retractor/retractor.toml) — warstwa użytkownika.

Brak plików jest stanem poprawnym — program startuje z wartościami domyślnymi. Błąd składni TOML w warstwie wyszukiwanej powoduje ostrzeżenie i pominięcie tej warstwy; przy jawnie podanej ścieżce (--config) brak pliku lub błąd składni są twarde, bo stanowią jawne żądanie użytkownika. Ten sam plik czyta również xqry (pod skrótem -e), dlatego sekcje [ipc] i [timing] dotyczą obu procesów.

KluczDomyślnieZnaczenie
storage.dir(brak)Domyślny katalog artefaktów. Stosowany tylko gdy zestaw RQL nie zawiera dyrektywy :STORAGE — RQL ma pierwszeństwo. Katalog musi istnieć i być zapisywalny, inaczej program kończy się błędem Configuration error: storage.dir ….
ipc.queue_buffer_seconds10Głębokość kolejki IPC wyrażona w sekundach strumienia; liczba elementów to sekundy / interwał.
ipc.min_queue_elements100Dolna granica pojemności kolejki, niezależna od interwału strumienia.
ipc.client_response_max_fails300Liczba prób odczytu odpowiedzi z pamięci współdzielonej przez xqry. Efektywny czas oczekiwania to iloczyn tej wartości i interwału odpytywania.
timing.server_startup_wait_s30Maksymalny czas oczekiwania xqry --wait-server na gotowość serwera.
timing.server_startup_poll_ms100Interwał odpytywania podczas oczekiwania na start serwera.
timing.query_no_data_timeout_ms10000Czas braku danych, po którym klient xqry uznaje serwer za martwy.
scheduling.rt_priority50Priorytet SCHED_FIFO w trybie --realtime; dopuszczalny zakres 1–99.
paths.lock_dir(katalog tymczasowy systemu)Katalog na pliki blokad instancji. Dla usług systemd zalecane /var/run/retractor lub $XDG_RUNTIME_DIR. Ścieżka musi być bezwzględna.
server.autonamefalseGeneruje nazwę, gdy nie podano --name ani --autoname. Jawne --name wygrywa. Wartość false zachowuje historyczną instancję bezimienną.
service.query_file(wartość z konfiguracji budowania)Plik zapytań nadpisywany przy przekazaniu zestawu działającej usłudze. Używany wyłącznie jako zapasowy, gdy usługa nie zaraportowała własnego QUERYFILE w pliku blokady. Musi być zgodny z argumentem ExecStart jednostki systemd — konfiguracja nie zmienia ExecStart.

Wartości spoza sensownego zakresu nie zatrzymują usługi: program zapisuje ostrzeżenie w dzienniku i używa wartości domyślnej. Wyjątkiem jest storage.dir, którego niepoprawność jest błędem twardym — wskazywałaby, że wyniki trafiłyby w niezamierzone miejsce lub nigdzie.

Przykładowy plik:

[storage]
dir = "/var/lib/retractor"

[ipc]
queue_buffer_seconds = 30

[scheduling]
rt_priority = 60

[paths]
lock_dir = "/var/run/retractor"

[server]
autoname = false

NOTE: Wczytywanie warstw i walidację pokrywa test jednostkowy ut_appConfig; twarde odrzucenie niepoprawnego storage.dir — test integracyjny config_storage_validation.


Usługa i wymiana planu

Start bez pliku .rql albo z pustym plikiem tworzy bezczynną instancję z działającym IPC. Pierwszy lub kolejny pełny plan można załadować bez restartu:

xqry --server service --reset plan.rql

Serwer parsuje i kompiluje całą treść, sprawdza kolizje zasobów, rezerwuje nowy zestaw, a następnie przełącza plan na granicy slotu. Odmowa pozostawia poprzedni plan bez zmian. Pusty plik resetu przywraca stan bezczynny. W instancji usługowej zaakceptowana treść jest również zapisywana w pliku startowym usługi.

Alternatywna ścieżka xretractor nowy-plan.rql wykrywa działającą jednostkę systemd, weryfikuje zestaw, atomowo nadpisuje jej plik startowy i wywołuje restart. Jawne wskazanie innej tożsamości przez --name albo --autoname oznacza zamiast tego start osobnej instancji. W razie krytycznego błędu plan usługowy jest opróżniany, aby systemd uruchomił proces ponownie w bezpiecznym stanie bezczynnym.


Informacje o wersji

Na końcu każdego komunikatu pomocy wyświetlana jest linia z informacjami o buildzie:

Branch: issue_31-doc:2707ce0,
Code compiler: GNU Ver. 13.3.0,
Build time: 2512211449,
Type: Debug
PoleZnaczenie
BranchNazwa odnogi repozytorium i skrót commita (hash), z którego zbudowano program
Code compilerWersja kompilatora GCC użytego do budowy
Build timeData i godzina kompilacji w formacie YYMMDDHHMM (tu: 21 grudnia 2025, godz. 14:49)
TypeTyp buildu: Debug lub Release

Kolejna linia wskazuje lokalizację pliku dziennika:

Log: /tmp/xretractor.log

Plik /tmp/xretractor.log rejestruje historię wywołań i zdarzeń wewnętrznych systemu. W środowisku produkcyjnym należy zadbać o regularne czyszczenie lub rotację tego pliku.

Ostatnia linia zawiera informację o licencji MIT, która umożliwia bezpieczne użycie kodu w zastosowaniach korporacyjnych.

xqry

Program xqry komunikuje się z działającym procesem xretractor przez Boost IPC. Odczytuje bieżące rekordy, pokazuje plan i schematy, dołącza pojedyncze polecenia RQL, wymienia cały plan oraz zatrzymuje wskazaną instancję. Wiele procesów xqry może działać równocześnie, także wobec różnych serwerów.

Uruchomienie

$ xqry -h
xqry - data query tool.

Usage: xqry [option]

Allowed options:
  -s [ --select ] arg            show this stream
  -t [ --detail ] arg            show details of this stream
  -a [ --adhoc ] arg             adhoc query mode
  -q [ --reset ] arg             replace the whole plan of the target instance
                                 with this RQL file
  -m [ --elimitqry ] arg (=0)    limit of elements, 0 - no limit
  -n [ --null ]                  if null row appear - skip it in output
  -l [ --hello ]                 diagnostic - hello db world
  -k [ --kill ]                  kill xretractor server
  -d [ --dir ]                   list of queries
  -y [ --yaml ]                  yaml output format for --dir, --detail and
                                 --bus
  -j [ --jsonl ]                 versioned JSON Lines API output
  -i [ --idle-timeout ] arg (=0) JSONL idle timeout in ms; 0 disables
  -r [ --raw ]                   raw output mode (default)
  -g [ --graphite ]              graphite output mode
  -f [ --influxdb ]              influxDB output mode
  -p [ --gnuplot ] arg           x,y - gnuplot output mode
  -z [ --gnuplot-rtl ]           gnuplot output: newest samples on the right
  -e [ --config ] arg            config file (TOML); overrides search
  -h [ --help ]                  produce help message
  -c [ --needctrlc ]             force ctl+c for stop this tool
  -w [ --wait-server ]           poll until xretractor server is available
  -x [ --server ] arg            target xretractor instance name
  -b [ --bus ]                   list live xretractor instances and their streams

Wybór instancji

Jawne --server nazwa wybiera instancję bez korzystania z automatycznego routingu:

xqry --server pomiary --dir
xqry --server pomiary --select temperatura
xqry --server pomiary --kill

Bez tej opcji klient czyta magistralę xrdbbus. Przy jednej żywej instancji wybiera ją automatycznie. Przy kilku instancjach --select i --detail trafiają do właściciela podanego strumienia. Polecenia dotyczące całej instancji (--hello, --dir, --kill, --reset) są niejednoznaczne i wymagają --server.

Routing ad hoc analizuje źródła z FROM, a dla RULE strumień z ON. Wszystkie muszą należeć do jednego serwera. DECLARE nie zawiera adresata, więc przy wielu instancjach również wymaga --server. Literówka w nazwie i zapytanie przecinające granicę serwerów są odrzucane przed wysłaniem polecenia.

Lista instancji: --bus

xqry --bus odczytuje magistralę bez kontaktowania się z serwerami. Wiersze są sortowane po nazwie, a (unnamed) oznacza zgodną wstecz instancję uruchomioną bez nazwy.

$ xqry --bus
SERVER | PID    | MODE | QUERY              | STREAMS
-------+--------+------+--------------------+-----------
alfa   | 249247 | N    | .../plans/alfa.rql | srca, dsta
beta   | 249248 | FS   | .../plans/beta.rql | srcb, dstb
MODE: N=normal, R=realtime, F=no-clock, U=until-eof, M=llimitqry, X=xqrywait, S=service

Ścieżka w tabeli jest skracana dla czytelności. --bus --yaml zachowuje pełną ścieżkę:

---
apiVersion: xqry/v1
servers:
  - name: alfa
    pid: 249247
    modes: N
    query: "/home/user/plans/alfa.rql"
    streams:
      - srca
      - dsta

Pusta magistrala daje poprawny dokument servers: [] w YAML. Informacja diagnostyczna o braku instancji trafia na stderr.

Lista i szczegóły strumieni

--dir wypisuje wyrównaną tabelę:

$ xqry --server alfa --dir
name  | duration | size | count | location      | cap
------+----------+------+-------+---------------+----
core0 | 1/10     | -1   | 0     | datafile2.dat | 4
str1  | 1/30     | 0    | 0     |               | 0

duration jest dokładnym interwałem strumienia, size rozmiarem zapisanych danych, count liczbą rekordów, location plikiem źródła, a cap pojemnością historii wyliczoną przez kompilator. Dla deklarowanego źródła size ma wartość -1.

--detail strumień pokazuje oryginalne zapytanie i pola. Modyfikator --yaml przełącza --dir, --detail i --bus na dokument apiVersion: xqry/v1; nie jest samodzielnym poleceniem. Nieznany strumień kończy działanie kodem 2.

Odbiór danych

OpcjaZnaczenie
-s / --select strumieńSubskrybuje bieżące rekordy strumienia.
-m / --elimitqry NKończy po dokładnie N rekordach; 0 oznacza brak limitu.
-n / --nullPomija rekordy, w których wszystkie wartości są NULL.
-c / --needctrlcWymaga Ctrl+C zamiast zakończenia dowolnym klawiszem.

Jedna subskrypcja tworzy własną kolejkę odpowiedzi. Po zatrzymaniu lub wymianie planu serwer wysyła znacznik końca i klient zamyka odbiór. Nagła awaria bez znacznika jest wykrywana przez timeout timing.query_no_data_timeout_ms.

Formaty prezentacyjne

OpcjaFormat
-r / --rawDomyślny tekst bez dekoracji.
-g / --graphiteWiersze zgodne z Graphite.
-f / --influxdbLine protocol InfluxDB.
-p / --gnuplot x,yDane i polecenia do bezpośredniego zasilenia gnuplot.
-z / --gnuplot-rtlModyfikator gnuplot umieszczający najnowsze próbki po prawej.

Można wybrać tylko jeden format. --gnuplot-rtl wymaga --gnuplot. Surowy format przesyła wszystkie elementy pól tablicowych; mapa NULL jest zachowywana per element.

Polecenia ad hoc

--adhoc dołącza dokładnie jedno SELECT, DECLARE albo RULE do aktywnego planu:

xqry --server pomiary --adhoc \
  "SELECT AVG(value : 10) STREAM avg10 FROM sensor"

Dyrektywy kompilatora i kilka poleceń w jednym żądaniu są odrzucane. Szczegóły początku logicznego, deklaracji źródeł, reguł i roszczeń zasobów opisano w rozdziale Zapytania Ad hoc.

Wymiana całego planu: --reset

--reset plik.rql przesyła zawartość pliku i zastępuje cały plan wybranej instancji. To inna operacja niż ad hoc: pełny zestaw może zawierać wiele poleceń, reguły i dyrektywy :STORAGE, :SUBSTRAT oraz :ROTATION.

xqry --server service --reset plan.rql

Serwer przed zmianą aktywnego modelu parsuje i kompiluje zestaw oraz rezerwuje jego nazwy strumieni, pliki magazynu i licznik rotacji. Odmowa nie zatrzymuje starego planu. Przyjęty plan jest aktywowany na końcu bieżącego slotu, stare subskrypcje dostają znacznik końca, a artefakty poprzedniej epoki są sprzątane zgodnie z zasadami startu i rotacji. Pusty plik przełącza serwer w stan bezczynny.

Jeżeli celem jest instancja usługowa, zaakceptowana treść zostaje także zapisana do jej pliku startowego, aby przetrwała restart procesu.

JSON Lines dla aplikacji

--jsonl udostępnia wersjonowane wyjście maszynowe dla --hello, --dir, --detail i --select. Wymaga jednoznacznego serwera; aplikacje powinny zawsze podawać go jawnie.

xqry --server laboratory --jsonl --hello
xqry --server laboratory --jsonl --dir
xqry --server laboratory --jsonl --detail temperature
xqry --server laboratory --jsonl --select temperature --elimitqry 10

Każdy wiersz stdout jest kompletnym obiektem JSON z version: 1 i polem event. Obsługiwane zdarzenia to pong, streams, schema, record, end i error. Diagnostyka trafia na stderr. --idle-timeout N podaje w milisekundach dopuszczalny czas bez rekordu dla subskrypcji; zero wyłącza limit.

Polecenia modyfikujące, --bus, YAML, pozostałe formaty wyjścia, --null i --wait-server nie łączą się z JSONL. Pełny kontrakt oraz gotowe klienty Python i C++ opisuje API monitorowania strumieni.

Jedno polecenie naraz

--select, --detail, --adhoc, --reset, --dir, --bus i --hello są różnymi poleceniami; podanie kilku naraz kończy się kodem 22. --kill może być świadomie połączone z --select -m N albo z --adhoc, aby zatrzymać serwer po wykonaniu operacji.

Czekanie na serwer

--wait-server odpytuje dostępność IPC zgodnie z timing.server_startup_wait_s i timing.server_startup_poll_ms. Przy jawnej nazwie czeka na nią. Bez nazwy ponawia routing: historycznie czeka na instancję bezimienną, a po pojawieniu się jednej nazwanej instancji wybiera ją automatycznie. Niejednoznaczność przy wielu serwerach jest zgłaszana od razu. --bus nie wymaga serwera i ignoruje czekanie.

Typowy wzorzec testowy:

xretractor query.rql --name test --llimitqry 100 --noanykey --xqrywait &
xqry --server test --wait-server --select strumien --elimitqry 10

Informacje o wersji

Informacje pod listą pomocy zawierają nazwę odnogi, skrót commita, wersję kompilatora, czas i typ budowania oraz ścieżkę dziennika. Opis formatu znajduje się w rozdziale xretractor — Informacje o wersji.

xtrdb

Program xtrdb to interaktywne narzędzie do analizy artefaktów i substratów zapisanych przez system RetractorDB. Pracuje głównie w trybie interaktywnym (REPL), ale udostępnia także kilka opcji uruchomienia (np. --help, --noprompt, --storagemap).

⚠️ Ostrzeżenie

Wywołanie xtrdb blokuje uruchomiony równolegle xretractor — przed użyciem xtrdb zatrzymaj serwer lub poczekaj na zakończenie pracy systemu. Narzędzie samo wykrywa blokadę i zgłosi błąd, jeśli xretractor działa.


Uruchomienie

$ xtrdb                    # tryb interaktywny (z promptem)
$ xtrdb -n                 # tryb wsadowy (bez promptu i bez "ok")
$ xtrdb --noprompt         # to samo co -n
$ xtrdb noprompt           # zgodność wsteczna (legacy, argument pozycyjny)
$ xtrdb -s plik_danych     # pokaż strukturę storage dla wskazanego pliku
$ xtrdb --storagemap plik  # to samo co -s
$ xtrdb -h                 # help i informacje o buildzie, potem zakończ

Tryb -n/--noprompt usuwa kolorowanie, prompt . i komunikat ok — przydatny, gdy wejście pochodzi z pliku lub potoku. Wciąż działa też historyczny wariant pozycyjny noprompt.

$ xtrdb -n < script.xtrdb

Opcja -s/--storagemap uruchamia tylko raport struktury pliku danych i kończy działanie programu (bez wejścia do REPL).

Po uruchomieniu narzędzie wypisuje prompt . i czeka na polecenie. Każde polecenie kończy się naciśnięciem Enter.


Przegląd poleceń

Polecenie help lub h wyświetla listę dostępnych poleceń:

$ xtrdb
.help
exit|quit|q                     exit
quitdrop|qd                     exit & drop artifacts (data, .desc, .meta)
open file [schema]              open or create database with schema
                                example: .open test_db { INTEGER dane 
                                STRING name[3] }
storage [path]                  set storage path for database
policy [name]                   set storage policy
dropfile [file1] [file2] ... }  remove listed file(s), end with }
desc|descc                      show schema
read|rread [n]                  read record from database into payload
write [n]                       from payload send record to database
purge                           remove all records from database
append                          append payload to database
set [field][value]              set payload field value
setpos [position][number value] set payload field number value
getpos [position]               show payload field value
status                          show current payload status
rox                             remove on exit flip (data, .desc, .meta)
print|printt                    show payload
list|rlist [count]              print first records
input [[field][value]]          fill payload
hex|dec                         type of input/output of byte/number fields
size                            show database size in records
cap [value]                     set device stream backread capacity
dump                            show payload memory
meta                            show meta index (null patterns) for open db
metaraw                         show internal meta file structure
echo                            print message on terminal
system                          execute system command
#|rem [text]                    comment line
help|h                          show this help

Zarządzanie sesją

PolecenieOpis
exit, quit, qZakończ narzędzie. Dane niezapisane w bazie pozostają na dysku.
quitdrop, qdZakończ i usuń otwarte pliki artefaktu (dane, .desc, .meta).

Konfiguracja środowiska

PolecenieOpis
storage [ścieżka]Ustaw katalog roboczy. Kolejne polecenie open szuka pliku w tej ścieżce.
policy [nazwa]Ustaw politykę przechowywania (DEFAULT, DIRECT, POSIX, MEMORY, …). Musi poprzedzać open.

Otwieranie artefaktu

open nazwa_pliku
open nazwa_pliku { TYP pole TYP pole ... }

Jeśli plik .desc istnieje — schemat jest z niego odczytany. Jeśli nie istnieje — schemat należy podać w nawiasach {}.

Tablicowe typy pól: STRING name[8] oznacza pole tekstowe o długości 8 bajtów (array multiplicity = 8).

Przykłady:

.open str1                          # schemat z pliku str1.desc
.open dump.tmp { INTEGER wartosc }  # schemat podany ręcznie
.open wyniki { INTEGER a FLOAT b STRING name[8] }

Odczyt i zapis rekordów

PolecenieOpis
read NOdczytaj rekord N (0-based) z pliku do bufora payload.
rread NJak read, ale odczytuje od końca pliku (reverse read).
write NZapisz bieżący payload do rekordu N w pliku.
appendDołącz bieżący payload jako nowy rekord na końcu pliku.
purgeUsuń wszystkie rekordy z pliku (skróć plik do 0 rekordów).

Przeglądanie zawartości

PolecenieOpis
list NWypisz N pierwszych rekordów (od początku), jeden wiersz = jeden rekord.
rlist NJak list, ale odczytuje od końca pliku.
printWypisz bieżący payload w formacie wieloliniowym.
printtWypisz bieżący payload w jednym wierszu.
sizeWypisz liczbę rekordów i rozmiar jednego rekordu w bajtach.
dumpWypisz surowe bajty bieżącego payload w formacie hex.
descWypisz schemat pól otwartego artefaktu (wieloliniowy).
desccWypisz schemat w jednym wierszu (compact).

Edycja payload

PolecenieOpis
set pole wartośćUstaw pole o podanej nazwie w buforze payload.
setpos N wartośćUstaw pole o indeksie N (0-based) w buforze payload.
getpos NWypisz wartość pola o indeksie N z bieżącego payload.
inputInteraktywne wypełnienie payload — wpisz wartości po kolei dla każdego pola.
statusWypisz stan payload: clean, fetched, changed, stored.
hex / decPrzełącz format wejścia/wyjścia pól liczbowych między szesnastkowym a dziesiętnym.

Metadane null (.meta)

PolecenieOpis
metaWypisz indeks null i przerw w transmisji z pliku .meta — opisowo (segmenty z liczbą rekordów i wzorcem null).
metarawWypisz surową strukturę binarną pliku .meta — każdy wpis RLE z polami count, gap, bitsetHex.

meta wyświetli segmenty z informacją o brakach (null) i przerwach w transmisji (gap). metaraw pokaże surową strukturę binarną pliku .meta.


Pozostałe polecenia

PolecenieOpis
roxPrzełącz flagę „remove on exit“ — po zakończeniu narzędzia usuwa dane, .desc, .meta.
cap NUstaw pojemność bufora cofania (backread) dla urządzeń strumiennych.
dropfile f1 f2 … }Usuń wymienione pliki. Lista kończy się tokenem }.
echo tekstWypisz tekst na terminal (przydatne w skryptach).
system polecenieWywołaj polecenie powłoki.
# lub remLinia komentarza (ignorowana). # nie wypisuje nawet promptu.

Przykłady użycia

Podgląd artefaktu

$ xtrdb
.storage temp
.open str1
.size
.list 10
.quit

Odczyt pliku DUMP bez deskryptora

Pliki zrzutu tworzone przez DO DUMP nie mają pliku .desc — schemat należy podać ręcznie:

$ xtrdb
.open wyniki_alarm_dump.tmp { INTEGER wartosc }
.size
.list 6
.quit

Skrypt wsadowy

xtrdb noprompt << 'EOF'
storage /var/retractor
open sensor_dump.tmp { INTEGER a FLOAT b }
list 20
quit
EOF

Inspekcja metadanych null

.open str1
.meta
.metaraw

Geneza systemu

Ponad dwadzieścia lat temu pracowałem w pewnym instytucie naukowym w Zabrzu. Zajmowałem się m.in. budową systemu nadzoru neonatologicznego. Stosunkowo niedawno ukończyłem studia, moja głowa nadal była wypełniona teorią dotyczącą budowy systemów opartych na centralnej bazie danych. Budując system monitorowania stwierdziłem – zrobię go tak jak sztuka każe – oparty na relacyjnej bazie danych. To nie był dobry pomysł. Trafiłem na problem ogólnej wydajności takiego rozwiązania. Rejestrowane sygnały cechowały się wysoką granulacją. Dodatkowo, dostępne systemy baz danych nie były przygotowane na ciągły i nieskończony napływ danych.

Rok 2003 był czasem, w którym bardzo obiecująco prezentowały się w literaturze naukowej tzw. bazy strumieniowe. Po analizie stwierdziłem, że to chyba najbliższa dziedzina w tym czasie, która odpowiada temu, czego potrzebuję. Przyjąłem założenie, że tworzę strumieniową bazę danych do przetwarzania sygnałów. Decyzja z czasem okazała się nie do końca zgodna z prawdą. Systemy strumieniowe przyszły i poszły – ale potrzeba systemów przetwarzających szeregi czasowe pozostała. Systemy strumieniowe przeobraziły się w systemy przetwarzające serie czasowe – Time Series Databases. Do dnia dzisiejszego systemy baz danych przetwarzające serie czasowe znajdują zastosowanie w systemach monitorowania.

Opracowany system nadzoru neonatologicznego obsługiwał kilkanaście pulsoksymetrów. Na sali nadzoru leżało kilkanaście noworodków wymagających ciągłego nadzoru. Każdy noworodek podłączony był m.in. do pulsoksymetru. Każdy pulsoksymetr monitorował rytm serca oraz zawartość tlenu we krwi noworodka. Noworodki się wierciły, sondy odpadały, pulsoksymetry podnosiły alarm co chwilę raportując różnego typu problemy. W takim szumie informacyjnym jeden z noworodków mógł się dusić. Nie działo się to nagle – ale powoli, można było to rozpoznać w szerszym horyzoncie czasowym. Ten jeden przypadek wymagał jednak natychmiastowej reakcji. Równocześnie i do tego bardzo głośno sygnalizowało dźwiękiem kilka urządzeń - a ten jeden z noworodków, ten, który potrzebował pomocy, łapał powietrze cichutko w rogu sali. Tak mniej więcej można opisać skalę problemu. Budowany system umożliwiał jednym rzutem oka stwierdzić, czy wycie urządzenia na sali nadzoru to efekt zsunięcia się czujnika, chwilowy problem czy może coś poważniejszego. Zmieniając skalę czasową można było od razu zidentyfikować problem. Szybka ocena zagrożenia w oparciu o wskazania systemu monitorującego w takim przypadku ratuje zdrowie i życie.

System monitorowania powstał i został wdrożony u klienta w jednym z Warszawskich szpitali. Byłem na miejscu i widziałem, jak działa. Niestety wewnątrz nie było systemu zarządzania danymi, który opisywałem w publikacjach naukowych. Rozwiązanie opracowałem ręcznie bez implementacji języka zapytań, algorytmów i mechanizmów zarządzania. Termin i ograniczone zasoby wymagały dowiezienia tematu na czas. Publikacje, które wtedy powstały opisywały szlachetne potrzeby i założenia – jednak praktyka była inna. Trzeba było dostarczyć produkt a czasu nie było.

Tak przedstawia się w ogólnym zarysie generyczna przyczyna, z której wynikła potrzeba stworzenia systemu zarządzania danymi dla potrzeb przetwarzania sygnałów. Z czasem doszły kolejne obszary zastosowań wynikające z rozszerzających się obszarów rozwojowych związanych z telemetrią, monitorowaniem oraz rozbudową systemów IoT.

W skrócie

  • Początek XXI wieku — Zabrze

    System nadzoru neonatologicznego oparty na relacyjnej bazie danych. Sygnały o wysokiej granulacji i ciągły napływ danych ujawniają ograniczenia wydajności.

  • 2003 — bazy strumieniowe

    Strumieniowe bazy danych jako najbliższa dziedzina. Założenie: strumieniowa baza danych do przetwarzania sygnałów.

  • Wdrożenie — szpital w Warszawie

    System monitorowania działa u klienta, ale bez języka zapytań i mechanizmów zarządzania danymi opisywanych w publikacjach — termin wymusił rozwiązanie ręczne.

  • 2019 — repozytorium RetractorDB

    Pierwszy commit repozytorium (grudzień 2019): początek implementacji systemu zarządzania danymi dla przetwarzania sygnałów.

  • Dziś — telemetria, monitorowanie, IoT

    Systemy strumieniowe przeobraziły się w bazy serii czasowych, a obszar zastosowań rozszerzył się na telemetrię i systemy IoT.

Dlaczego wybrano taką nazwę dla systemu?

Retraktory w medycynie to cała grupa narzędzi chirurgicznych. Retraktory, znane są również jako haki chirurgiczne lub rozwieraki. Są to narzędzia umożliwiające odsuwanie lub łącznie ze sobą struktur anatomicznych (np. ran, mięśni, kości, itd…). Znajdują zastosowanie z reguły podczas operacji lub innego zabiegu. Niektóre, te bardziej pomysłowe noszą nazwy swoich twórców.

Na zasadzie analogii postanowiłem że nazwę swoje narzędzie retraktorem. RetractorDB ma za zadanie rozdzielać, łączyć oraz umożliwiać realizację obliczeń na seriach czasowych w czasie rzeczywistym, w biegu operując na danych efemerycznych, artefaktach lub substratach (patrz podrozdział pt. Artefakty, Substraty, Efemerydy).

ℹ Info

Definicja (Retrakcja i Retraktor danych): Zastosowanie aparatu numerycznego do wydobycia, przetworzenia a następnie zwrócenia danych zawartych w seriach czasowych lub sygnałach cyfrowych nazywamy retrakcją danych. Narzędzie służące do realizacji tego procesu nazywamy Retraktorem danych.

Kolorowanie składni RQL

Pliki zapytań RetractorDB mają rozszerzenie .rql. Repozytorium dostarcza gotowe definicje kolorowania składni dla trzech środowisk: Visual Studio Code, Vim oraz narzędzia bat/batcat. Wszystkie potrzebne pliki znajdują się w katalogu scripts/ projektu.

Visual Studio Code

Rozszerzenie rql-vscode dodaje do VS Code pełną obsługę języka RQL: kolorowanie składni, rozpoznawanie rozszerzenia .rql oraz ikonę pliku.

Instalacja z repozytorium GitHub:

git clone https://github.com/michalwidera/rql-vscode.git
cd rql-vscode
npm install
npm run compile
code --install-extension *.vsix

Jeżeli repozytorium zawiera gotowy plik .vsix, można pominąć kompilację i zainstalować go bezpośrednio:

code --install-extension rql-vscode-*.vsix

Po instalacji VS Code automatycznie rozpozna pliki .rql i zastosuje kolorowanie składni. Brak konieczności modyfikacji ustawień użytkownika.

Przykład podświetlonego zapytania w VS Code:

STORAGE 'temp'

DECLARE a INTEGER STREAM core0, 0.1 FILE '/dev/urandom'

# Wybierz kolumnę i jej połowę
SELECT str[0], str[0] / 2 STREAM str1 FROM core0

Rys. 63. Podświetlenie składni RQL w edytorze Visual Studio Code

Jak widać na Rys. 63, słowa kluczowe (STORAGE, DECLARE, SELECT, FROM) są podświetlane jako komendy, a typy danych (INTEGER) jako typy. W aktualnym RQL # rozpoczyna komentarz tylko jako pierwszy niebiały znak całego wiersza; wewnątrz klauzuli FROM zawsze jest operatorem przeplotu. Komentarz kończący wiersz zaczyna się od //, a komentarz blokowy ma postać /* ... */. Definicja kolorowania powinna zachowywać to rozróżnienie.


Vim

Repozytorium zawiera dwa pliki Vima w katalogu scripts/.vim/:

PlikOpis
scripts/.vim/syntax/rql.vimDefinicja grup składniowych i ich przypisań kolorystycznych
scripts/.vim/ftdetect/rql.vimAutomatyczne wykrywanie typu pliku po rozszerzeniu .rql

Instalacja przez buildrdb.sh

Najwygodniejsza metoda — skrypt kopiuje oba pliki do odpowiednich podkatalogów ~/.vim/:

scripts/buildrdb.sh vimsyntax

Skrypt tworzy brakujące katalogi i informuje o lokalizacji docelowej:

-- RetractorQL vim syntax installed to /home/user/.vim

Instalacja przez CMake

Cel vimconf z scripts/CMakeLists.txt kopiuje cały katalog .vim do katalogu domowego:

cmake --build build --target vimconf

Instalacja ręczna

mkdir -p ~/.vim/syntax ~/.vim/ftdetect
cp scripts/.vim/syntax/rql.vim   ~/.vim/syntax/
cp scripts/.vim/ftdetect/rql.vim ~/.vim/ftdetect/

Po instalacji Vim automatycznie aktywuje kolorowanie dla każdego pliku z rozszerzeniem .rql. Plik ftdetect/rql.vim zawiera jedną linię:

au BufRead,BufNewFile *.rql set filetype=rql

Podświetlane elementy

Grupa VimaPrzykłady
KeywordSELECT, DECLARE, STREAM, FROM, FILE, RULE, ON, WHEN, DO
PreProcSTORAGE, ROTATION, SUBSTRAT
OperatorAND, OR, NOT
ConstantMEMORY, POSIX, DIRECT, GENERIC, TEXTSOURCE
TypeINTEGER, FLOAT, BYTE, CHAR, UINT, STRING, DOUBLE
FunctionMIN, MAX, AVG, SUMC, Sqrt, Abs, Length, null2zero
Comment# komentarz, // komentarz, /* blok */
String'ścieżka/do/pliku.dat'
Number42, 3.14, 1/2, 1e5

Przykład pliku zapytania z zaznaczonymi fragmentami:

DECLARE a UINT STREAM core0, 1 FILE 'datafile1.txt'
DECLARE a UINT STREAM core1, 2 FILE 'datafile2.txt' ONESHOT

SELECT str4[0] STREAM str4 FROM core0#core1

RULE regulation1 \
ON str4 \
WHEN str4[0] = 20 OR str4[0] = 23 \
DO SYSTEM 'echo "test"'

Widok tekstu w edytorze vim przedstawia Rys. 64.

Rys. 64. Podświetlenie składni RQL w edytorze vim


bat / batcat

Narzędzie bat (na niektórych dystrybucjach dostępne jako batcat) to ulepszony zamiennik cat z wbudowaną obsługą podświetlania składni. Obsługuje definicje syntaktyczne w formacie Sublime Text 3, które repozytorium RetractorDB dostarcza pod ścieżką scripts/sublime/retractorql.sublime-syntax.

Wymaganie wstępne

Upewnij się, że bat jest zainstalowany:

# Debian/Ubuntu
sudo apt-get install bat

# Sprawdzenie polecenia (może być bat lub batcat zależnie od dystrybucji)
command -v batcat || command -v bat

Instalacja przez buildrdb.sh

scripts/buildrdb.sh batsyntax

Skrypt samodzielnie wykrywa polecenie (bat lub batcat), kopiuje plik składni do właściwego katalogu konfiguracyjnego i przebudowuje pamięć podręczną syntaktyk:

-- RetractorQL syntax installed to /home/user/.config/bat/syntaxes

Instalacja ręczna

# Wykryj nazwę polecenia
BAT=$(command -v batcat || command -v bat)

# Utwórz katalog na definicje syntaktyk
mkdir -p "$($BAT --config-dir)/syntaxes"

# Skopiuj definicję
cp scripts/sublime/retractorql.sublime-syntax "$($BAT --config-dir)/syntaxes/"

# Przebuduj pamięć podręczną
$BAT cache --build

Użycie

Po instalacji bat automatycznie koloruje pliki .rql:

bat query.rql

Rozpoznawane jest też rozszerzenie .desc (pliki deskryptorów strumieni). Można wymusić podświetlanie ręcznie, jeśli plik ma inne rozszerzenie:

bat --language rql dowolny-plik.txt

Weryfikacja instalacji — dostępne języki:

bat --list-languages | grep -i rql
# RetractorQL:rql,desc

Przykład wywołania

Dla pliku query.rql zawierającego:

STORAGE 'temp'

DECLARE a INTEGER STREAM core0, 0.1 FILE 'datafile2.dat'

SELECT str1[0] STREAM str1 FROM core0

RULE testrule1 ON str1 WHEN str1[0] < 15 DO DUMP -5 TO 5
RULE testrule2 ON str1 WHEN str1[0] > 11 DO DUMP -5 TO 5 RETENTION 100

RULE testrule3 \
ON str1 \
WHEN str1[0] = 13 OR str1[0] = 11 \
DO SYSTEM 'echo "systemcall"'

Wywołanie bat query.rql wyświetli zawartość pliku z numeracją linii i podświetleniem składni w terminalu, gdzie słowa kluczowe, typy, komentarze i literały łańcuchowe będą miały odrębne kolory zgodne z aktywnym motywem bat (Rys. 65).

Widok polecenia batcat test.rql

Rys. 65. Podświetlenie składni RQL w terminalu — polecenie batcat

Testy integracyjne

Testy integracyjne weryfikują zachowanie systemu jako całości — uruchamiają rzeczywiste binaria (xretractor, xqry, xtrdb) i porównują ich wyjście z wzorcami lub sprawdzają konkretne właściwości plików wynikowych. Różnią się tym od testów jednostkowych, które za pomocą frameworka GTest testują izolowane klasy i funkcje bibliotek rdb i retractor (np. payload, descriptor, crsMath, compiler), nie wymagają uruchomionego serwera i nie produkują artefaktów na dysku. Testy integracyjne uruchamiają się poleceniem ninja test (lub ctest) w katalogu build/Debug/; pojedynczy test można uruchomić przez ctest -R <nazwa> -V.

Wszystkie scenariusze znajdują się w jednym katalogu test/IntegrationTest. CMake przydziela większości katalogów jedną z szesnastu przestrzeni RDB_NAMESPACE oraz odpowiadającą jej blokadę zasobu. Rozdziela to magistralę, nazwę instancji, obiekty IPC i plik dziennika bez zmiany zapytań ani wzorców. Testy jednego katalogu pozostają wzajemnie wykluczone, ale różne katalogi mogą uruchamiać serwery równolegle. RUN_SERIAL zachowują tylko scenariusze badające produkcyjną tożsamość globalną i współpracę wielu serwerów.

Poniższe tabele opisują zamiar każdego scenariusza. Nie są inwentarzem wykonywalnym: jeden katalog rejestruje zwykle kilka wpisów ctest (warianty -run, -compile, -vg, warianty nazwane), a lista rośnie z każdym wydaniem. Aktualny stan zwraca ctest -N w katalogu build/Debug.

Po scaleniu drzew wszystkie testy integracyjne mają prefiks it_; dawny prefiks pt_ nie opisuje już osobnej klasy. Testy skryptowe używają st_, a testy API dodatkowo etykiety api i są domyślnie pomijane przez ninja test. Dwa strażniki nadrzędne — harness_guard-selftest i harness_command_integrity — sprawdzają, że wrapper rzeczywiście uruchomił polecenie pod testem i nie zamaskował jego kodu wyjścia.

Scenariusze wykonawcze i usługowe

Nazwa testuOpis
DataWspólny zestaw danych i zapytań dla scenariuszy przekrojowych: waterfall (przebieg pełnego planu przez -m 10 i posprzątanie artefaktów), workflow (zapytanie referencyjne, porównanie dwóch strumieni wynikowych) oraz all-operators (ten sam przebieg dla zapytania używającego kompletu operatorów algebry). Patrz: Przetwarzanie i dystrybucja danych.
agse1Operator okna czasowego @(start, length) — warianty do przodu @(1,4), wstecz @(1,-4), różne długości. Patrz: Ruchome okno danych AGSE.
agse_arrayAGSE nad liczbowym polem tablicowym i równoważnym schematem skalarnym: każdy płaski element musi wejść do okna w tej samej kolejności. Patrz: Operatory agregujące, Przebiegi kompilacji.
agse_volatileOkno AGSE nad strumieniem VOLATILE (bufor MEMORY) musi widzieć cały zakres okna. Przy zbyt małej pojemności bufor kołowy nadpisuje najstarsze pole i okno czyta w jego miejsce rekord najnowszy. Patrz: Klauzula VOLATILE, Ruchome okno danych AGSE.
array_derivedRównoważność liczbowego T[N] i jawnych pól skalarnych przez kopię, przesunięcie, AGSE, redukcję SUMC, przeplot oraz mieszany przeplot; obejmuje też mapowanie NULL. Patrz: Operatory agregujące, Przebiegi kompilacji.
client_tty_keystroke_immunityBajt oczekujący na pseudoterminalu nie może skrócić odbioru xqry --elimitqry N; klient ma odebrać cały budżet rekordów i dopiero potem wykonać połączone --kill.
config_storage_validationTwarde odrzucenie niepoprawnego storage.dir w pliku TOML: wariant nonexistent (katalog nie istnieje) i unwritable (brak prawa zapisu). W obu program kończy się kodem niezerowym i komunikatem Configuration error: storage.dir …. Patrz: Opcje wywołania — xretractor, Plik konfiguracyjny.
deinterleave_roundtripBitowa weryfikacja tożsamości okrężnej przeplot–rozplot: a2 i b2 odtwarzają składowe dokładnie, od rekordu zerowego, bez rekordu-zastępnika. Wzorce wyprowadzone z definicji formalnych, nie z wyjścia silnika. Patrz: Ogony, początki logiczne i obserwowalność operatorów.
ecg_qrsPełne wykonanie przykładowego potoku EKG w zwartej postaci: okna bezpośrednio w FROM, rozwinięcie [_] według ich szerokości i reduktory funkcyjne. Test kontroluje origin= i tail= w planie oraz 4830 rekordów deterministycznej treści czterech strumieni pośrednich i końcowych. Patrz: Wizualizacja EKG MIT-BIH, Przetwarzanie symbolu _, Ogony, początki logiczne i obserwowalność operatorów.
agse2Kombinacje okna @(n,m) na strumieniu 3-polowym, wyrównanie i rate-conversion przy proporcjach 1:1, 1:2, 2:3, 2:4. Patrz: Ruchome okno danych AGSE.
agse3Operator @(n,m) gdy output rate jest niższy niż input rate (source rate 0.1) — okna @(3,2), @(3,3), @(3,-3). Patrz: Ruchome okno danych AGSE.
consistencySpójność odczytu: dwa strumienie czytają to samo źródło; ich różnica musi być stale równa 100. Patrz: Przepływ danych i sterowania.
fatal_exit_pathBłąd krytyczny podczas startu i w wątku komunikacyjnym kończy proces statusem 1, usuwa IPC oraz blokadę usługi i nie przechodzi w SIGSEGV ani SIGABRT. Patrz: Przepływ danych i sterowania.
fncall_runtime_caseWykonanie funkcji matematycznych zapisanych formami dopuszczonymi przez gramatykę, w tym Sqrt, Ceil i Floor; test sprawdza wartości w runtime, nie tylko poprawną kompilację. Patrz: Wyrażenia pól i funkcje skalarne.
index_wildcard_mixedRozwinięcie [_] jako jednej pozycji mieszanej listy SELECT: zachowanie kolejności pól przed i po rozwinięciu, dwa niezależne rozwinięcia oraz zgodność układu payloadu z ręcznym wzorcem.
adhoc_parse_errorBłędna składnia i kilka instrukcji w jednym żądaniu ad hoc są odrzucane bez zatrzymania serwera ani zmiany planu.
adhoc_ruleDołączenie RULE ... DO DUMP w locie, granica dostępnej historii oraz odrzucenie DO SYSTEM i zbyt głębokiego zakresu dla magazynu MEMORY.
issue202_hash_shift_e2eWykonawcza weryfikacja tożsamości (A>2)#(B>1) = (A#B)>3 dla ΔA=0,1 i ΔB=0,2: niezależne źródła tekstowe, identyczność fizycznych strumieni matched i CC oraz porównanie z sekwencją wyliczoną z formuły przeplotu. Patrz: Substraty.
issue113_meta_internalStruktura pliku sidecar .meta: rozmiar nagłówka (8 B), rozmiar wpisu (18 B), interwał próbkowania, bitsety null dla rekordów z null i bez. Patrz: Format zapisu danych — Pliki.
issue113_meta_xtrdbWeryfikacja przez xtrdb że po uruchomieniu xretractor+xqry plik .meta powstaje i jest raportowany poprawnie (meta: temp/str_null.meta). Patrz: Format zapisu danych — Analiza artefaktów.
issue113_null_skipFlaga -n w xqry — wiersze w całości null są pomijane; bez flagi wszystkie wiersze (łącznie z all-null) muszą być obecne. Patrz: Opcje wywołania — xqry.
issue113_null_xqryNull przesyłane przez IPC: wartości null z pliku źródłowego wyświetlane jako null w wyjściu xqry. Patrz: Opcje wywołania — xqry.
issue121_isnullFunkcja isnull(field) — zwraca 1 gdy pole jest null, 0 gdy nie jest. Patrz: Wyrażenia pól i funkcje skalarne.
issue121_null_propagationPropagacja wartości null przez SELECT do strumienia wynikowego. Patrz: Polecenie SELECT.
issue128_numeric_to_stringKonwersja INTEGER/FLOAT do STRING funkcją to_string() z deklaracją szerokości pola; weryfikacja deskryptora wynikowego. Patrz: Wyrażenia pól i funkcje skalarne.
issue128_string_to_numericKonwersja STRING do typów numerycznych: to_integer(), to_float(), to_double(); propagacja null przez konwersję. Patrz: Wyrażenia pól i funkcje skalarne.
issue167_dedup_cascadedKaskadowe wchłanianie substratów przez deduplicateSubstrats() — wieloetapowe przepisywanie tokenów PUSH_ID. Patrz: Substraty.
issue167_dedup_field_namesDeduplikacja substratów bez porównania nazw pól schematu — scalanie gdy typy pól są równoważne, niezależnie od nazw. Patrz: Substraty.
issue167_dedup_nonzero_offsetAktualizacja PUSH_ID w lSchema konsumenta przy niezerowym offsecie wchłanianego substratu; pokrycie ścieżki w compiler.cpp. Patrz: Substraty.
issue167_dedup_positivePodstawowy przypadek deduplikacji: substrat scalany z nazwanym strumieniem o równoważnym programie i typach pól. Patrz: Substraty.
issue167_triargWieloargumentowe wyrażenia strumieniowe: s1+s2+s3, (s1#s2)#s3, s1+(s2#s3), s1+s2+s3+s4; substraty pamięciowe i dyskowe. Patrz: Substraty, Sekwencjonowanie operacji.
issue217_client_diag_stderrDiagnostyka xqry musi trafiać na stderr, a nie na stdout. Wywołanie przy braku serwera kończy się kodem niezerowym i niepustym stderr — bez tego przekierowanie >/dev/null 2>err czyniło każdą awarię klienta niewidoczną. Patrz: Opcje wywołania — xqry.
issue227_join_alignmentRozdzielenie początku logicznego od ogona. Wariant run sprawdza wypisywane przez kompilator origin= i tail= oraz treść złączenia okna z jego źródłem; wariant adhoc-origin — że zapytanie dołożone w locie zaczyna od slotu, w którym zobaczył je runtime. Wartości oczekiwane wyprowadzone z definicji operatorów. Patrz: Ogony, początki logiczne i obserwowalność operatorów, Zapytania Ad hoc.
issue42_rulePolecenie RULE — warunkowe akcje DUMP i SYSTEM wyzwalane na wartościach strumienia; uruchamia xretractor i odczytuje wynik przez xtrdb. Patrz: Polecenie RULE.
k19_boundariesGranice obserwowalności operatorów: okno do przodu i wstecz, różnica o interwale równym i dwukrotnym, redukcja .sumc. Rozróżnia prawdziwy NULL wewnątrz pełnego okna od wewnętrznego rekordu all-null zwracanego jako bezpiecznik przy odczycie poza historią. Patrz: Ogony, początki logiczne i obserwowalność operatorów.
k24_capacityGłębokość historii dla operatorów czytających w przód. Różnice o ilorazie całkowitym ≥ 3 dawały wcześniej strumień poprawnej długości złożony wyłącznie z rekordów all-NULL (cichy defekt), a łańcuch okna, przesunięcia i przeplotu kończył się przerwaniem procesu w storage::revRead. Patrz: Przebiegi kompilacji.
k24h10_exact_tailsGranice emisji dla różnicy oraz obu rozplotów, także nad źródłami o niezerowym ogonie. Liczba i treść rekordów chronią dokładne reguły fazowe przed przedwczesną lub opóźnioną emisją. Patrz: Ogony, początki logiczne i obserwowalność operatorów.
noclock_offlineRównoważność artefaktów i logicznej osi czasu między wykonaniem taktowanym a --no-clock, razem z odrzuceniem połączenia --no-clock --realtime. Patrz: Opcje wywołania — xretractor.
multiserver_namedDwie nazwane instancje, rozłączne blokady i IPC, wszystkie formy --name, automatyczne nazwy oraz niezależne zatrzymanie.
multiserver_no_clobberPrzegrany start tej samej instancji nie kasuje artefaktów właściciela i raportuje jego PID.
multiserver_routingAutomatyczny routing strumieni, ad hoc i RULE, odmowa przekroczenia granicy serwerów, lista magistrali oraz oczekiwanie na właściwą instancję.
multiserver_uniquenessAtomowa unikalność nazw strumieni, plików magazynu i licznika rotacji przy starcie, ad hoc, dostarczaniu do usługi i konkurencyjnych uruchomieniach.
null_divide_by_zeroDzielenie przez zero jest wartością pochłaniającą: strumień oddaje NULL i pracuje dalej. Istotą testu jest rekord następujący po dzieleniu — gdyby brak wyniku był obsługiwany wyjątkiem, nigdy by nie powstał. Patrz: Wyrażenia pól i funkcje skalarne.
issue56_timeshiftOperator filtra > na połączonych strumieniach — do wynikowego strumienia trafiają tylko rekordy spełniające warunek. Patrz: Polecenie SELECT — Sekwencjonowanie.
issue61_tmpmemSubstrat pamięciowy SUBSTRAT 'memory' — dane pośrednie przechowywane w RAM zamiast na dysku. Patrz: Typy STORAGE.
issue6_adhocTryb zapytania ad-hoc: xqry -a 'SELECT ...' — definicja i wykonanie zapytania w locie bez pliku .rql. Patrz: Zapytania Ad hoc.
operationsOperator # (HASH merge) dwóch strumieni o różnych rate — weryfikacja stosunku liczby rekordów w wyjściu. Patrz: Sekwencjonowanie operacji przeplotu.
optimizer_ablationWariantowe budowanie z wyłączanymi regułami optymalizatora. build-info sprawdza tożsamość zgłaszanej konfiguracji binarki, plan — kształt planu, a pozostałe warianty (semantic, factor-*, dedup-*) porównują wynik wykonania między konfiguracjami. Każda dopuszczalna konfiguracja optymalizatora musi zachować obserwowalny wynik. Patrz: Budowanie produkcyjne i warianty diagnostyczne.
packagingPoprawność pakietów CPack: pakiet binarny DEB zawiera dokładnie wymagany zestaw plików (trzy binaria i jednostkę systemd), bez nadmiarowych, a pakiet źródłowy nie zawiera wygenerowanych ani lokalnych artefaktów. Patrz: Budowanie produkcyjne i warianty diagnostyczne.
r1_identity_nullsTożsamość R1 dla planu przepisanego, nieprzepisanej lewej strony i jawnej prawej strony: równość origin=, payloadu oraz mapy NULL w .meta. Strona nieprzepisana ma ogon ściśle większy — R1 zachowuje ciąg rekordów, nie opóźnienie — więc jej porównanie obejmuje wspólny prefiks. Przypadek ΔA/ΔB=3/2 chroni maksimum fazowe własnego ogona #. Patrz: Substraty.
replay_stabilityPowtórzenie tego samego planu na tych samych danych musi wytworzyć identyczny zestaw niepustych artefaktów: payloady, deskryptory, mapy NULL i pliki cienia; z metadanych pomijany jest wyłącznie 8-bajtowy nagłówek zarezerwowany. Patrz: Odtwarzanie strumienia, Format zapisu danych.
rotation_testMechanizm rotacji plików binarnych strumieni (ROTATION) — liczba plików po dwóch cyklach xretractor -m 2. Patrz: Mechanizm rotacji.
select_cse_commutative_addWspółdzielenie równoważnych obliczeń SELECT dla a+b i b+a: jeden STREAM_SELECT_*, zachowanie osobnych publicznych artefaktów i deskryptorów, identyczność danych oraz metadanych NULL. Test chroni też SELECT *, kolejność projekcji i kontrprzykład ze zmianą grupowania trzech źródeł. Patrz: Substraty.
service_deliveryPrzekazanie zestawu do działającej usługi: właściwy wybór celu, zapis planu startowego, odmowa przy kolizji z inną instancją oraz uruchomienie osobnej instancji po podaniu --name lub --autoname.
service_idleTryb usługowy i bezczynny: start bez pliku .rql przechodzi czysto, a dziennik trafia na stderr w formacie sd-daemon (prefiks priorytetu, bez znacznika czasu). Dwa warianty włączenia — flagą --service i zmienną XRETRACTOR_SERVICE. Patrz: Perspektywa ogólna, Opcje wywołania — xretractor.
service_resetDwufazowe zastąpienie pełnego planu, zachowanie starego planu po odmowie, przejście do i ze stanu bezczynnego oraz zapis planu startowego usługi.
service_reset_doubleDwie kolejne wymiany planu tej samej instancji i poprawne odtworzenie zasobów kolejnej epoki.
service_reset_raceKonkurencyjne resetowanie i polecenia klienta nie widzą częściowo rozebranego ani częściowo aktywnego planu.
show_handler_failureAwaria serwerowej obsługi subskrypcji show dociera do klienta jako bezpośrednia przyczyna odmowy, zamiast ujawnić się dopiero jako timeout oczekiwania na kolejkę odpowiedzi.
simpleDymny test arytmetyki na połączonych strumieniach (core0 rate 0.1 + core1 rate 0.2) z odczytem przez xtrdb. Patrz: Polecenie SELECT.
simple_maxZgodna wstecz, wygaszana notacja .max — wartość maksymalna i złączenie z oryginalnym strumieniem. Patrz: Operatory agregujące.
stream_generatorGenerator SELECT ... STREAM cell[N]: wykonanie rodziny odpowiada ręcznie rozpisanym strumieniom, instancje mają nazwy cell$0cell$(N-1), a sprzątanie usuwa artefakty całej rodziny. Patrz: Polecenie SELECT — Generatory strumieni.
string_field_passthroughTekst bez cudzysłowów ze źródła TEXTSOURCE, wyrównanie sąsiednich pól, przycięcie do STRING[N], propagacja typu i rzeczywista wartość Length. Patrz: Wyrażenia pól i funkcje skalarne, Przebiegi kompilacji.
tty_keystroke_immunityBajt oczekujący na pseudoterminalu nie może skrócić wykonania xretractor --llimitqry N; przebieg pod TTY ma wytworzyć tyle samo danych co przebieg bez terminala.
untileof_stop--until-eof odpowiada ręcznie ograniczonemu źródłu ONESHOT, nie zawija pliku i przy wielu źródłach zatrzymuje się na pierwszym wyczerpanym. Patrz: Opcje wywołania — xretractor.
wide_from_namesSzeroka klauzula FROM po przekroczeniu progu 200 bajtów używa stabilnej skróconej nazwy substratu, nie przekracza NAME_MAX, zachowuje deduplikację i wykonuje redukcję SUMC nad oknem poprawnie. Patrz: Substraty.
window_aggregateAgregaty MIN/MAX/AVG/SUMC(wyrażenie : W) po historii rekordów: wartości, typy, brzegi, tablice, wyrażenia, wspólne grupy, NULL, null2zero i propagacja schematu przez kopię. Patrz: Operatory agregujące.
xqry_array_rowPełna serializacja liczbowego pola tablicowego przez xqry: wszystkie elementy i ich kolejność w formatach raw, Graphite oraz InfluxDB, bez ograniczenia liczby wartości do liczby wpisów deskryptora. Patrz: Opcje wywołania — xqry, API monitorowania strumieni.
xqry_elem_limitParametr -m N w xqry — limit liczby odebranych rekordów do dokładnie N, niezależnie od długości źródła. Patrz: Opcje wywołania — xqry.
xqrywait_gateBramka --xqrywait nie gubi pierwszego polecenia, nie zeruje budżetu --llimitqry i daje się przerwać sygnałem przed nadejściem klienta.

Nowsze scenariusze regresyjne

Nazwa testuOpis
expr_result_typesTypy i rozmiary pól wynikowych, wartości ułamkowe, NULL oraz kopiowanie schematu przez SELECT *.
field_ref_outside_fromOdrzucenie odwołania do pola strumienia spoza FROM i poprawność odwołania przez pole strumienia wynikowego.
reducer_field_refOdrzucenie nazwy reduktora jako pola w SELECT oraz odczyt wyniku przez zmaterializowany strumień.
reducer_float_max_avg_countMAX nad ujemnymi FLOAT i DOUBLE oraz AVG nad rekordami o 256 i 257 polach.
self_ref_field_shapeTyp pola wskazanego własną nazwą strumienia pochodzi z odpowiedniego slotu rekordu FROM.
self_ref_simplify_synthTypy slotów wejściowych okna i reduktora chronią obliczenia FLOAT przed błędnym uproszczeniem stałych.
self_ref_simplify_typeOdwołanie własną nazwą w wyrażeniu podlegającym uproszczeniu używa typu slotu FROM, a nie typu pola wyjściowego.
silent_arith_overflowPrzepełnienie arytmetyki INTEGER i RATIONAL w wyrażeniach i reduktorach daje NULL zamiast zawiniętej liczby.
synth_node_output_shapeTypy pól wyjściowych po oknie, redukcji i złączeniu odpowiadają rzeczywistym slotom wejściowym.
xqrywait_first_rowPierwszy rekord nie ginie między otwarciem bramki --xqrywait a utworzeniem kolejki subskrypcji xqry.

Scenariusze kompilacyjne i offline

Poniższe katalogi rejestrują przede wszystkim warianty kompilacji, prezentacji planu albo operacji na artefaktach. Część z nich jest ta sama co w tabeli wykonawczej, ponieważ jeden CMakeLists.txt może rejestrować kilka niezależnych wpisów CTest.

Nazwa testuOpis
DataKompilacyjna strona wspólnego zestawu Data (pliki dzielone z wariantem sekwencyjnym): on-query — kompilacja zapytania referencyjnego, presenter — prezentacja planu po tej kompilacji, banned-artifacts — strażnik sprawdzający, że w katalogu instalacyjnym nie powstał artefakt o zakazanej nazwie kolidującej z narzędziami systemowymi. Patrz: Kompilacja i budowa planu.
dspRegresja kompilacyjna dla zwartego potoku filtra FIR: okno source@(1,25) bezpośrednio w FROM, 25-elementowe rozwinięcie source[_] * filter[_], redukcja SUMC(accRow) oraz złączenie sygnału i wyjścia. Patrz: Implementacja filtru sygnałowego, Przetwarzanie symbolu _.
issue113_metaOperacje xtrdb po dwóch append — lista rekordów i hexdump pliku binarnego porównywane ze wzorcem. Patrz: Format zapisu danych — Analiza artefaktów.
issue113_meta_autocreateAutomatyczne tworzenie pliku sidecar .meta po pierwszym append; rozmiar >16 B; xtrdb raportuje poprawną ścieżkę. Patrz: Format zapisu danych — Pliki.
issue113_null_txtsrcKomendy rread/getpos w xtrdb na strumieniu TEXTSOURCE zawierającym null. Patrz: Format zapisu danych — Analiza artefaktów.
issue153_storagemap_meta_casesMapa składowania xtrdb -s dla pliku zwykłego i retractordb-style: znaczniki slotów, lista segmentów, pliki rotowane, referencje .meta/.shadow. Patrz: Narzędzie inspekcji xtrdb -s.
issue202_hash_shift_factorizationAlgebraiczna optymalizacja (A>i)#(B>k) do (A#B)>(i+k) dla i·ΔA=k·ΔB oraz ochrona przypadku niedopasowanego. Patrz: Substraty.
issue31_docGenerowanie grafów DOT/SVG przez xretractor -c -d ... dla przykładów dokumentacyjnych na trzech poziomach szczegółowości. Patrz: Debugowanie kompilacji.
issue42_ruleKompilacja składni RULE — tylko etap -c, bez uruchamiania serwera. Patrz: Polecenie RULE.
issue56_timeshiftKompilacja operatora filtra > — tylko etap -c. Patrz: Polecenie SELECT — Sekwencjonowanie.
issue61_tmpmemKompilacja zapytania z SUBSTRAT 'memory' — tylko etap -c. Patrz: Typy STORAGE.
issue95_loopInCompileWykrywanie cykli w grafie zapytań przez kompilator — oczekiwany błąd “Circular dependency” i niezerowy kod wyjścia. Patrz: Wykrywanie pętli w kompilacji.
issue96_no_substrat_reductionStrumienie zdefiniowane przez użytkownika o identycznej strukturze NIE są scalane; scalaniu podlegają tylko automatyczne substraty. Patrz: Substraty.
issue96_substrat_referenceWygenerowany substrat współdzielony przez dwa strumienie użytkownika — poprawne referencje w drzewie zależności. Patrz: Substraty.
Pattern1Kompilacja operatora # (HASH-merge), selekcji pól z przesunięciem i łączenia strumieni +. Patrz: Sekwencjonowanie operacji przeplotu i sumowania.
Pattern2Kompilacja zapytań na strumieniach BYTE z /dev/urandom: SELECT, arytmetyka, łączenie strumieni. Patrz: Polecenie SELECT.
Pattern3Kompilacja SELECT * (unfold) z deklaracją pliku wyjściowego i retencją. Patrz: Rozwijanie symbolu *.
Pattern5Operacje xtrdb na rekordzie wielotypowym (STRING, INTEGER, BYTE, FLOAT): append, read, list/rlist, input, write. Patrz: Analiza artefaktów.
Pattern6Kompilacja operatora okna @(n,m) do przodu @(1,10) i wstecz @(1,-10) + valgrind bez wycieków. Patrz: Ruchome okno danych AGSE.
Pattern7Kompilacja z identycznymi nazwami pól w wielu strumieniach (issue #17) — poprawna identyfikacja pola przez indeks strumienia. Patrz: Polecenie DECLARE, Aliasowanie.
retentionOperacje xtrdb na pliku z parametrem RETENTION: open, purge, append, list, write dla konkretnego rekordu. Patrz: Format zapisu danych — Mechanizm rotacji.
simpleKompilacja podstawowego zapytania arytmetycznego + graf DOT + valgrind; korzysta z danych IntegrationTest/simple. Patrz: Polecenie SELECT.
simple_maxKompilacja zapytania z .max + graf DOT + valgrind; korzysta z danych IntegrationTest/simple_max. Patrz: Operatory agregujące.
subqueryKompilacja zagnieżdżonych podzapytań: (a#b)>1 (hash-merge wewnątrz filtru) i (a>1)#b (filtr wewnątrz hash-merge). Patrz: Budowa drzewa zależności.
txtsrcOperacje xtrdb descc/rread/printt na strumieniu TEXTSOURCE (plik tekstowy jako źródło danych). Patrz: Format zapisu danych.

Literatura

1. S. Beatty, “Problem 3173” American Mathematical Monthly, vol. 33, p. 159, 1926.

2. A.S.Fraenkel, „The bracket function and complementary sets of integers“ Canadian Journal of Mathematics, tom 21, pp. 6-27, 1969. (link)

3. M. Widera, „Deterministic method of data sequence processing“ Annales UMCS, Sectio AI Informatica, tom IV, pp. 314-331, 2006 (link); wersja polska: „Deterministyczna metoda przetwarzania ciagow danych“ w XXI Autumn Meeting of Polish Information Processing Society, Conference Proceedings, pp. 243-254, 2006. (pdf)

4. Z. W. Zen, „Classified publications on covering systems“ updated 2006. [Online]. Available: http://maths.nju.edu.cn/~zwsun/. (pdf)

5. T. Parr, The Definitive ANTLR 4 Reference, The Pragmatic Bookshelf, 2013. (amazon)

6. T. D. Pauw, „Swirly - A marble diagram generator.“ 2022. [Online]. Available: https://github.com/timdp/swirly. [Data uzyskania dostępu: 3 11 2025]. (link)

7. A. Staltz, „RxJS Marbles“ https://github.com/staltz/rxmarbles, [Online]. Available: https://rxmarbles.com/. [Data uzyskania dostępu: 4 11 2025]. (link)

8. „Conan.io - the Open Source C and C++ Package Manager for Developers“ JFrog, [Online]. Available: https://conan.io/. [Data uzyskania dostępu: 9 11 2025].

9. D. W. Gunness, „Creating digital signal processing (DSP) filters to improve loudspeaker transient response“. US Patent US8081766B2, 20 12 2011. (link)

10. M. Widera, „RetractorDB - separator serii czasowych“ Programista, tom 92, nr 5/2020, pp. 14-20, 6/7 2020. (ebookpoint)

11. J. Shallit, „A Generating Function Technique for Beatty Sequences and Other Step Sequences“ Journal of Number Theory, tom 64, nr 2, pp. 273-298, 1997.

12. L. Schaeffer, J. Shallit i S. Zorcic, „Beatty Sequences for a Quadratic Irrational: Decidability and Applications“ arXiv:2402.08331, 2024. (pdf)

13. M. A. Berger, A. Felzenbaum i A. S. Fraenkel, „Disjoint covering systems of rational Beatty sequences“ Journal of Combinatorial Theory, Series A, tom 42, nr 1, pp. 150-153, 1986.

14. D. Eppstein i in., „Aperiodic pinwheel scheduling using Beatty sequences“ – omówienie problemu szeregowania okresowego w oparciu o komplementarne sekwencje Beatty’ego, 2023. (link)

15. H. Fujiwara, K. Miyagi i K. Ouchi, „Pinwheel Scheduling with Real Periods“ arXiv:2510.24068, 2025 – dowody oparte na podziale Rayleigha/Beatty’ego z tożsamościami na funkcjach podłogi i sufitu. (html)

16. S. Samadi, M. O. Ahmad i M. N. S. Swamy, „Characterization of nonuniform perfect-reconstruction filterbanks using unit-step signal“ IEEE Transactions on Signal Processing, tom 52, nr 9, pp. 2490-2499, 2004. (link)

17. G. Margolis i Y. C. Eldar, „Nonuniform Sampling of Periodic Bandlimited Signals“ IEEE Transactions on Signal Processing, tom 56, nr 7, pp. 2728-2745, 2008. (pdf)

18. J. Kovačević i M. Vetterli, „Perfect Reconstruction Filter Banks with Rational Sampling Factors“ IEEE Transactions on Signal Processing, tom 41, nr 6, pp. 2047-2066, 1993.

19. S. Kalra i N. K. Shukla, „Ramanujan sums in signal recovery and uncertainty principle inequalities“ arXiv:2512.16190, 2025. (pdf)

20. A. Arasu, S. Babu i J. Widom, „The CQL continuous query language: semantic foundations and query execution“ The VLDB Journal, tom 15, nr 2, pp. 121-142, 2006. (pdf)

21. J. Krämer i B. Seeger, „Semantics and implementation of continuous sliding window queries over data streams“ ACM Transactions on Database Systems, tom 34, nr 1, pp. 1-49, 2009 (system PIPES).

22. S. K. Jensen, T. B. Pedersen i C. Thomsen, „Time Series Management Systems: A Survey“ IEEE Transactions on Knowledge and Data Engineering, tom 29, nr 11, pp. 2581-2600, 2017. (pdf)

23. K. O’Bryant, „Fraenkel’s partition and Brown’s decomposition“ Integers: Electronic Journal of Combinatorial Number Theory, tom 3, A11, 2003.

24. G. E. Pfander, S. Revay i D. Walnut, „Exponential bases for partitions of intervals“ Applied and Computational Harmonic Analysis, tom 68, art. 101607, 2024.

25. M. Widera, J. Jezewski, R. Winiarczyk, J. Wrobel, K. Horoba i A. Gacek, „Data stream processing in fetal monitoring system: I. Algebra and query language“ Journal of Medical Informatics & Technologies, tom 5, pp. 83-90, 2003.

26. E. A. Lee i D. G. Messerschmitt, „Static Scheduling of Synchronous Data Flow Programs for Digital Signal Processing“ IEEE Transactions on Computers, tom C-36, nr 1, pp. 24-35, 1987. (doi)

27. G. Bilsen, M. Engels, R. Lauwereins i J. A. Peperstraete, „Cyclo-Static Dataflow“ IEEE Transactions on Signal Processing, tom 44, nr 2, pp. 397-408, 1996.

28. A. Cohen, M. Duranton, C. Eisenbeis, C. Pagetti, F. Plateau i M. Pouzet, „N-Synchronous Kahn Networks: A Relaxed Model of Synchrony for Real-Time Systems“ w Proceedings of the 33rd ACM SIGPLAN-SIGACT Symposium on Principles of Programming Languages (POPL), pp. 180-193, 2006. (doi)

29. F. McSherry, A. Lattuada, M. Schwarzkopf i T. Roscoe, „Shared Arrangements: Practical Inter-Query Sharing for Streaming Dataflows“ Proceedings of the VLDB Endowment, tom 13, nr 10, pp. 1793-1806, 2020. (doi)

30. A. Chaudhary, J. Karimov, S. Zeuch i V. Markl, „Incremental Stream Query Merging“ w Proceedings of the 26th International Conference on Extending Database Technology (EDBT), pp. 604-617, 2023. (doi)