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
| Typ | Rozmiar pojedynczej wartości |
|---|---|
BYTE | 1 B |
INTEGER | 4 B |
UINT | 4 B |
FLOAT | 4 B |
DOUBLE | 8 B |
RATIONAL | 8 B (dwa int32) |
STRING | N 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 jako0/0ani0/5; - liczba całkowita zapisuje się jako
n/1- poleRATIONALo 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 SELECT | Wynik dla -8/3 | Uwaga |
|---|---|---|
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 -2 | obcię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 pliku | Zawartość | Rozmiar |
|---|---|---|
| 0 | Rekord 0 | R bajtów |
| R | Rekord 1 | R bajtów |
| 2R | Rekord 2 | R bajtów |
| … | … | … |
| (N-1) × R | Rekord N-1 | R bajtów |
Każdy rekord zawiera upakowane wartości pól w kolejności zdefiniowanej przez deskryptor:
| Offset w rekordzie | Pole | Rozmiar |
|---|---|---|
| 0 | pole_0 | len_0 bajtów |
| len_0 | pole_1 | len_1 bajtów |
| len_0 + len_1 | … | … |
| len_0 + len_1 + … + len_n | pole_n | len_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
| Pozycja | Zawartość | Rozmiar |
|---|---|---|
| Nagłówek | pole zarezerwowane (int64, zawsze 0) | 8 bajtów |
| Wpis RLE 0 | gapFlag | count | bitsetSize | bitset | zmienny |
| Wpis RLE 1 | gapFlag | count | bitsetSize | bitset | zmienny |
| … | … | … |
| Wpis RLE k | wpis bieżący (w pamięci) | zmienny |
Format wpisu RLE
Każdy wpis opisuje ciąg kolejnych rekordów z identycznym wzorcem null:
| Pole | Rozmiar | Opis |
|---|---|---|
gapFlag | 1 B | 0 = normalny rekord, 1 = przerwa |
recordCount | 8 B (size_t) | liczba rekordów w ciągu |
bitsetSize | 8 B (size_t) | liczba pól (N) |
bitset | ⌈N/8⌉ B | bit 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:
| Wpis | isGap | count | bitset |
|---|---|---|---|
| wpis 0 | F | 10 | [F,F] |
Null w polu 1 od rekordu 5:
| Wpis | isGap | count | bitset |
|---|---|---|---|
| wpis 0 | F | 5 | [F,F] |
| wpis 1 | F | 5 | [F,T] |
Przerwa w transmisji po rekordzie 3:
| Wpis | isGap | count | bitset |
|---|---|---|---|
| wpis 0 | F | 3 | [F,F] |
| wpis 1 | T | 7 | [T,T] |
| wpis 2 | F | … | [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_autocreateopisanych 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:
| Jednostka | Nagłówek | Rola |
|---|---|---|
IndexRecord | indexRecord.hpp | format pojedynczego wpisu i jego (de)serializacja |
MetaIndexStore | metaIndexStore.hpp | surowe I/O pliku .meta - nagłówek, wpisy zatwierdzone, cache |
GapDetector | gapDetector.hpp | maszyna stanów wykrywania przerw (nullfill, absorpcja, przerwa oczekująca) |
splitSegment(), sumNonGapRecords() | rleSegment.hpp | operacje na segmentach RLE |
storageShadow | storageShadow.hpp | wariant kierujący aktualizacje do cienia indeksu (.meta.shadow) |
Sama metaData hermetyzuje trzy obszary odpowiedzialności:
- 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. - 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). - Indeks zapytań - udostępnia interfejs do odpytywania wzorca null dla dowolnego rekordu oraz wykrywania przerw w transmisji.
Klasa przechowuje dwa stany:
| Stan | Lokalizacja | Opis |
|---|---|---|
| Zatwierdzone segmenty | plik .meta na dysku | wszystkie zakończone przebiegi RLE |
Segment bieżący (currentEntry_) | pamięć operacyjna | aktualnie 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, wyznaczacommittedRecordCount_, a ostatni niegapowy segment przenosi z powrotem docurrentEntry_(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:
%% pdf-height: 55%
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):
- Pomija nagłówek - 8 bajtów zarezerwowanych; nic z nich nie jest interpretowane.
- Wczytuje wszystkie zatwierdzone wpisy z pliku.
- 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). - Wyznacza
committedRecordCount_jako sumęrecordCountwszystkich 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ń
| Metoda | Opis |
|---|---|
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.
| Metoda | Opis |
|---|---|
| konstruktor | Wczytuje 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
| Pole | Rozmiar | Opis |
|---|---|---|
position | 8 B (size_t) | indeks rekordu w pliku głównym |
data | R bajtów | nowe 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:
- Magazyn jest typu
DEFAULTlubPOSIXSHD- czyli taki, który trzyma modyfikacje rekordów w pliku.shadow(nie w pliku głównym). - 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:
| Warunek | Zwracany obiekt | Zachowanie |
|---|---|---|
ź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 danych | storageShadow | onRecordModified() kieruje nadpisania do metaShadow (.meta.shadow) |
| pozostałe | metaData | modyfikacje 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):
| Pole | Rozmiar | Znaczenie w .meta.shadow |
|---|---|---|
gapFlag | 1 B | zawsze 0 (nadpisania nie są przerwami) |
recordCount | 8 B (size_t) | bezwzględny indeks nadpisywanego rekordu |
bitsetSize | 8 B (size_t) | liczba pól deskryptora (N) |
bitset | ⌈N/8⌉ B | nowy 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 .shadow | Akcja na .meta.shadow |
|---|---|
| Pierwsza modyfikacja rekordu | Tworzenie pliku; dołączenie pierwszego wpisu |
| Kolejne modyfikacje | Dołączanie kolejnych wpisów |
merge() - scalenie cienia z plikiem głównym | mergeShadow() - nadpisania aplikowane do .meta; plik usuwany |
purge() / reset() - odrzucenie cienia | discardShadow() - plik usuwany bez scalania |
| Restart procesu | konstruktor storageShadow → metaShadow::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):
- Odczytuje wszystkie wpisy z
.meta.shadow(brak nagłówka - format bezpośredni). - Ładuje je do listy nadpisań w kolejności zapisu.
getNullBitset()i kolejneonRecordModified()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.shadowjest testowany w testach jednostkowychut_metaShadow_usage(format i cykl życia pliku cienia) orazut_storageShadow_usage(integracja zmetaDatai 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
xtrdbmoże analizować dowolny artefakt bez dodatkowych parametrów. - Zmiana struktury strumienia (dodanie pola, zmiana typu) jest jawna i wersjonowalna.
- Pole
TYPEw 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
.shadoww 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 ↔ .metaoraz.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.shadowsą natychmiast dostępne bez ponownego skanowania pliku cienia danych.