Kiedy planowałem trzeci provider hubu integracji w Artovnia.com po Shopify, założenie było proste: skoro dokumentacja API każdej większej platformy e-commerce wspomina o OAuth albo o tokenach, to jeden przycisk "Połącz sklep" powinien wystarczyć dla wszystkich. Onboarding miał się różnić kosmetycznie, a resztę miał załatwić wspólny wzorzec autoryzacji.
Po dodaniu Shoper i PrestaShop do WooCommerce i Shopify to założenie okazało się tylko częściowo prawdziwe. O ile lokalnie mechanizmy autoryzacji działały bez problemu, o tyle na produkcji brakowało jednego czynnika: możliwości udostępnienia integracji wielu niezależnym sprzedawcom na warunkach danej platformy.
Problem nie leży w samym API. Każda z tych platform potrafi w jakiejś formie wydać poświadczenia zewnętrznej aplikacji. Leży w innym pytaniu, którego nie zadałem sobie wystarczająco wcześnie:
API mówi, jak wykonać request. Polityka platformy decyduje, czy i w jaki sposób wolno tym requestem podłączyć setki cudzych sklepów naraz.
To jest właściwa oś tego artykułu. Nie „OAuth przegrał”, lecz: dostępność OAuth w dokumentacji to za mało, żeby zaprojektować onboarding marketplace’u zgodny z zasadami dystrybucji danej platformy. Tego często nie widać w dokumentacji technicznej.
Krótkie przypomnienie, czym jest ten hub
Zanim przejdę do czterech providerów, jeden konkretny scenariusz, który hub obsługuje niezależnie od tego, skąd przychodzi sprzedawca:
Sprzedawca łączy istniejący sklep
→ mapuje magazyn na lokalizację w Artovnii
→ wybiera produkty do importu
→ Artovnia tworzy zgłoszenia produktowe (product request)
→ po akceptacji zapisuje mappingi
→ późniejsze zmiany przechodzą przez sync run i sync items
→ stany magazynowe aktualizują się automatycznie
→ zmiany opisu/tytułu trafiają do konfliktów do ręcznej decyzji
Sześć obiektów, które warto znać, zanim przejdę dalej - opisałem je szerzej w pierwszym artykule:
IntegrationConnection - jaki sklep sprzedawcy jest podłączony i jak.
IntegrationEntityMapping - który rekord providera odpowiada któremu rekordowi w Artovnii.
IntegrationLocationMapping - który magazyn zewnętrzny odpowiada lokalizacji magazynowej w Artovnii.
IntegrationSyncRun - jeden przebieg operacji synchronizacji.
IntegrationSyncItem - pojedyncza trwała jednostka pracy w ramach przebiegu.
IntegrationConflict - różnica między wersją providera a wersją Artovnii, czekająca na decyzję sprzedawcy.
Ten artykuł pokazuje dwie strony tej granicy: provider-specific control plane przed utworzeniem IntegrationConnection oraz wspólny, trwały runtime synchronizacji działający po jego aktywacji.
Trzy ważne pytania
Przy Shopify i WooCommerce wystarczyło mi jedno pytanie: czy API potrafi wykonać daną operację. Przy Shoper i PrestaShop okazało się, że trzeba zadać trzy osobne pytania, w tej kolejności:
Czy API technicznie potrafi wykonać daną operację?
Jak konkretna aplikacja otrzymuje poświadczenia do tego API?
Kto pozwala tę aplikację udostępnić stu niezależnym sprzedawcom, a nie jednemu sklepowi?
Pierwsze pytanie było jedynym, które sobie zadawałem, projektując integrację Shopify. Trzecie pytanie jest tym, które w praktyce zdecydowało o kształcie onboardingu dla wszystkich czterech providerów.
To rozróżnienie mapuje się bezpośrednio na architekturę huba, którą już miałem. Hub od początku dzieli się na dwie warstwy:
Control plane - instalacja, autoryzacja, scopes, zapis i rotacja sekretów, aktywacja połączenia, odłączenie i odwołanie dostępu. To ta warstwa najbardziej różni się między providerami.
Data plane - import, mappingi, webhook inbox, sync runs i items, kolejka, retry, konflikty, reconciliation. To ta warstwa pozostała provider-neutralna, tak jak opisałem w poprzednim artykule.
Wniosek z czterech integracji brzmi krótko: provider-neutralny pozostał data plane. Provider-specific okazał się cały lifecycle control plane'u - dużo bardziej, niż zakładałem na starcie.
Czterech providerów, cztery drogi do tego samego huba
Poniżej wszystko rozwinę osobno, w tym samym układzie pytań: co sugerowała dokumentacja, gdzie pojawiła się bariera produkcyjna, jaki flow wybrałem, co widzi sprzedawca, co przechowuje backend, jak przychodzą eventy i jaki kompromis zostaje.
WooCommerce: nie całkiem OAuth, ale najbliżej jego UX
Co sugerowała dokumentacja. WooCommerce REST API dokumentuje dedykowany endpoint autoryzacyjny, /wc-auth/v1/authorize, do którego aplikacja przekierowuje użytkownika z parametrami takimi jak app_name, scope, return_url i callback_url.
Gdzie pojawiła się bariera produkcyjna. Nigdzie, i to jest ważna część tej historii. WooCommerce jako jedyny z czterech providerów daje gotowy flow zbliżony do OAuth UX bez centralnej rejestracji aplikacji, własnego modułu ani ręcznego kopiowania tokenu przez użytkownika.
Jaki flow wybrałem. Dokładnie ten wbudowany. Panel vendora buduje URL do /wc-auth/v1/authorize na domenie sklepu sprzedawcy, sprzedawca loguje się do własnego WordPressa i zatwierdza zakres read_write. Po zatwierdzeniu WooCommerce POST-uje wygenerowane poświadczenia bezpośrednio na callback_url Artovnii, a osobno przekierowuje przeglądarkę sprzedawcy z powrotem przez return_url.
Co widzi sprzedawca. Formularz z adresem sklepu, ekran logowania do własnego WordPressa, jeden ekran zgody i powrót do panelu Artovnii.
Co przechowuje backend. Parę Consumer Key/Consumer Secret, zaszyfrowaną tak samo jak pozostałe sekrety providerów.
Jak przychodzą eventy. Natywne webhooki WooCommerce, rejestrowane automatycznie po stronie sklepu sprzedawcy w ramach tego samego zakresu uprawnień.
Jaki kompromis zostaje. Tu jest miejsce na ważne zastrzeżenie, którego nie zrobiłem w pierwszej wersji tego tekstu: wc-auth nie jest standardowym OAuth2. Nie ma tu wymiany kodu autoryzacyjnego na token ani odświeżania tokenu, jest jednorazowe wygenerowanie pary kluczy API i przesłanie jej POST-em. Dla sprzedawcy różnica jest niewidoczna, bo przechodzi dokładnie taki sam ciąg kroków, jakiego oczekiwałby po OAuth: redirect, zgoda, powrót. Dla mnie jako integratora różnica ma znaczenie, bo te klucze nie wygasają automatycznie i nie da się ich odświeżyć, jedyna droga odwołania dostępu to ręczne usunięcie klucza przez sprzedawcę w swoim wp-admin.
WooCommerce nie daje mi klasycznego tokenu OAuth. Daje mi za to dokładnie ten onboarding, jakiego użytkownik oczekiwałby po OAuth.
Shopify: świadomy merchant-owned workaround, nie domyślny model
Co sugerowała dokumentacja. Shopify opisuje OAuth2 jako standardową drogę dla aplikacji instalowanych przez wielu, niepowiązanych ze sobą merchantów, to jest wprost rekomendowany model dla aplikacji publikowanych w Shopify App Store. Lokalnie działało.
Gdzie pojawiła się bariera produkcyjna. Publiczna aplikacja OAuth w App Store oznacza proces review, wymagania dotyczące listingu i model dystrybucji zaprojektowany pod aplikacje instalowane z jednego katalogu przez wielu niezależnych merchantów. Nie był to model odpowiadający prywatnej integracji jednego huba z kontami sprzedawców marketplace’u. Client credentials, które chciałem wykorzystać, Shopify dopuszcza wtedy, gdy aplikacja i sklep należą do tej samej organizacji.
Dodatkowym problemem były zasady dotyczące zewnętrznych kanałów sprzedaży. W analizowanym modelu publiczna aplikacja nie dawała mi prostej ścieżki do przeniesienia katalogu do marketplace’u realizującego checkout po swojej stronie. Model oparty na przekierowaniu kupującego do sklepu Shopify nie spełniał założeń Artovnii.
Jaki flow wybrałem. Workaround: to sprzedawca tworzy własną aplikację Shopify wewnątrz swojej organizacji i wkleja jej Client ID oraz Client Secret do panelu Artovnii. Skoro aplikacja i sklep należą do tej samej organizacji, spełniony jest warunek Shopify na użycie client credentials, bez przechodzenia przez proces publicznej dystrybucji.
const token = await exchangeShopifyClientCredentials({
shop,
clientId,
clientSecret,
})Co widzi sprzedawca. Instrukcję założenia własnej aplikacji w Shopify Partners/Dev Dashboard, formularz na Client ID i Client Secret, i healthcheck po stronie Artovnii przed aktywacją połączenia.
Co przechowuje backend. Client ID i Client Secret sprzedawcy, zaszyfrowane w IntegrationSecret; token dostępowy jest wymieniany przez backend i odświeżany, bo obowiązuje krócej niż doba.
Jak przychodzą eventy. Natywne webhooki Shopify, rejestrowane programowo po aktywacji połączenia.
Jaki kompromis zostaje. Ten model pogarsza onboarding względem publicznej aplikacji z jednym kliknięciem, sprzedawca musi przejść przez Shopify Partners, zanim w ogóle zobaczy formularz w Artovnii. W zamian unikam procesu dystrybucji publicznej aplikacji, który dla pojedynczego, niszowego huba integracji byłby nieproporcjonalnie kosztowny względem korzyści.
Shoper: token zamiast udawania publicznej aplikacji
Co sugerowała dokumentacja. Shoper ma w API zarówno Authorization Code OAuth2, jak i client credentials, technicznie rzecz biorąc, standardowe mechanizmy autoryzacyjne są dostępne. I jak w Shopify, lokalnie działało bez problemu.
Gdzie pojawiła się bariera produkcyjna. Docelowa aplikacja obsługująca wielu sprzedawców przechodzi przez ekosystem Shoper App Store, z osobnym procesem zgłoszenia i zatwierdzenia. Shoper nie oferował odpowiednika merchant-owned client credentials, który pozwolił zastosować model podobny do Shopify. Publiczna dystrybucja wiązała się dodatkowo z ręcznym procesem umownym oraz warunkami nieproporcjonalnymi do integracji służącej głównie do importu katalogu i synchronizacji stanów.
W praktyce oznaczało to ponad dwa tygodnie pracy, niejednoznaczne informacje ze strony supportu i działający kod, którego nie dało się użyć produkcyjnie w pierwotnie zakładanym modelu.
Jaki flow wybrałem. Zamiast publicznej aplikacji, zewnętrzna integracja tworzona bezpośrednio przez sprzedawcę w panelu Shoper, z ręcznie zaznaczaną listą uprawnień i wygenerowanym API Tokenem. Niestety sam musi ustawić wymagane uprawnienia dla aplikacji w panelu Shoper.
Co widzi sprzedawca. Ścieżkę w panelu Shoper: "Aplikacje i integracje" → "Zewnętrzne aplikacje" → narzędzie typu "Inne", nazwa integracji, zaznaczenie wymaganych uprawnień, wygenerowanie tokenu. W Artovnii sprzedawca wkleja domenę sklepu i ten token.
Co przechowuje backend. API Token, zaszyfrowany tak samo jak pozostałe sekrety. Zanim połączenie zostanie aktywowane, backend wykonuje healthcheck na kilku podstawowych operacjach:
await Promise.all([
client.getApplicationConfig(),
client.getApplicationVersion(),
client.listProducts({ limit: 1 }),
client.listProductStocks({ limit: 1 }),
])Ten healthcheck sprawdza, że token faktycznie działa i ma dostęp do podstawowych zasobów, zanim Artovnia zapisze go jako aktywne połączenie, nie potwierdza jednak w pełni wszystkich wymaganych uprawnień naraz. Część listy permissions, którą sprzedawca musi zaznaczyć w panelu Shoper, weryfikowana jest checklistą po stronie UI, nie odczytem pełnego scope setu z tokenu.
Jak przychodzą eventy. Obecnie bez pełnego natywnego event flow, Shoper opiera się na reconciliation, czyli okresowym porównaniu stanu, a nie na strumieniu webhooków 1:1 jak przy Shopify czy WooCommerce.
Jaki kompromis zostaje. Ręczny token jest mniej eleganckim rozwiązaniem technicznie niż OAuth, ale bywa uczciwszy dla sprzedawcy, który ma sklep na Shoper od lat i nie chce czekać na zatwierdzenie aplikacji w rejestrze. Chce wkleić token i zobaczyć swoje produkty w Artovnii.
PrestaShop: własny moduł jako świadomy wybór, nie wymóg API
Co sugerowała dokumentacja. PrestaShop dopuszcza włączenie Webservice, utworzenie klucza API i nadanie mu uprawnień ręcznie, z poziomu panelu administracyjnego sklepu, bez instalowania jakiegokolwiek dodatkowego modułu.
Gdzie pojawiła się bariera produkcyjna. Bariery technicznej po stronie API nie było. Był problem UX i reliability: ręczne włączanie Webservice, tworzenie klucza, ustawianie permissions dla każdego z kilkudziesięciu zasobów osobno i wklejanie klucza do Artovnii to proces podatny na błędy sprzedawcy, a przy tym nie dostarcza żadnego mechanizmu eventowego, trzeba by okresowo odpytywać cały katalog.
Jaki flow wybrałem. Własny moduł, Artovnia Connect, dystrybuowany prywatnie jako plik ZIP - to jest mój wybór UX i niezawodności, nie wymóg PrestaShop. Moduł po instalacji sam włącza Webservice, generuje losowy 32-znakowy klucz, nadaje mu tylko minimalne, potrzebne uprawnienia i wysyła klucz bezpośrednio do backendu Artovnii. Jeśli coś po drodze zawiedzie, moduł usuwa nowo utworzony klucz zamiast zostawiać go osierocony w sklepie sprzedawcy.
$this->registerHook('actionUpdateQuantity');
$outbox->enqueueInventory(...);
$outbox->dispatchPending(10);Co widzi sprzedawca. Plik ZIP do zainstalowania w panelu PrestaShop i jednorazowy kod parowania, który wpisuje po stronie Artovnii, żeby połączyć moduł z właściwym kontem sprzedawcy.
Co przechowuje backend. Wygenerowany przez moduł klucz Webservice, zaszyfrowany identycznie jak sekrety pozostałych providerów.
Jak przychodzą eventy. Moduł rejestruje hooki PrestaShop, takie jak actionUpdateQuantity, i zapisuje zdarzenia w lokalnym outboksie po stronie sklepu. Komunikaty wysyłane z outboxa są podpisywane HMAC. Outbox samodzielnie ponawia niedostarczone zdarzenia, zanim backend Artovnii w ogóle się o nich dowie.
Jaki kompromis zostaje. Moduł to dodatkowy kod PHP do utrzymania poza Node.js, dodatkowy krok instalacji dla sprzedawcy i zależność od tego, że sklep sprzedawcy w ogóle jest dostępny, żeby outbox mógł dostarczyć zdarzenia. W zamian sprzedawca nie musi ręcznie klikać przez ekrany uprawnień Webservice, a Artovnia dostaje mechanizm eventowy tam, gdzie PrestaShop go natywnie nie oferuje.
Osobna sprawa, którą warto rozdzielić: opłata roczna za obecność w PrestaShop Marketplace (rzędu €99) dotyczy modułów zgłoszonych i sprzedawanych przez oficjalny marketplace PrestaShop, nie prywatnej dystrybucji ZIP-em, z której korzysta Artovnia.
Artovnia nie płaci tej opłaty i nie przechodzi procesu walidacji addonów, moduł trafia do sprzedawcy bezpośrednio.
Co naprawdę pozostało wspólne
Cztery zupełnie różne wejścia kończą w tym samym miejscu: aktywnym IntegrationConnection, od którego zaczyna się wspólny data plane. Dispatch do konkretnego providera sprowadza się do prostego rozgałęzienia w executorze:
export const getIntegrationSyncExecutor = (provider: string) => {
if (provider === IntegrationProviders.SHOPIFY) {
return executeShopifySyncItem
}
if (provider === IntegrationProviders.WOOCOMMERCE) {
return executeWooCommerceSyncItem
}
// Shoper, PrestaShop, Etsy...
}Od tego momentu status, retry, monitoring, mappingi i konflikty są obsługiwane według tego samego modelu niezależnie od tego, czy połączenie zaczęło się od redirectu do WordPressa, formularza Client ID/Secret, wklejonego tokenu czy instalacji modułu PHP.
Różnice providerów nadal mają znaczenie dla samego wykonania operacji, ale pozostają zamknięte w executorze konkretnego providera. Model domenowy opisany w poprzednim artykule nie wymagał osobnego systemu dla żadnego z tych czterech providerów, natomiast runtime kolejki, przez który przechodzą sync itemy, został po drodze istotnie utwardzony.
Event uruchamia pracę, ale nie jest jej pamięcią
Różnice między providerami nie kończą się na autoryzacji. Shopify i WooCommerce dostarczają webhooki, PrestaShop korzysta z hooków i lokalnego outboxa, a Shoper w większym stopniu polega na reconciliation. Wszystkie te źródła ostatecznie muszą jednak uruchamiać ten sam, odporny na awarie runtime synchronizacji.
Pierwsza wersja huba traktowała event niemal jak kolejkę: endpoint albo webhook emitował zdarzenie, subscriber uruchamiał workflow, a workflow wykonywał synchronizację. Ten model działał, dopóki proces nie został zrestartowany pomiędzy tymi krokami, provider nie odpowiadał przez kilka minut albo kilka produktów nie próbowało synchronizować się jednocześnie.
Najważniejsza zmiana architektoniczna polegała na odwróceniu tej zależności:
Event nie jest już źródłem prawdy o pracy do wykonania. Jest tylko dzwonkiem informującym workera, że trwała praca czeka już w PostgreSQL.
Najpierw w jednej transakcji powstaje IntegrationSyncRun oraz jego IntegrationSyncItem. Dopiero po zapisaniu ich w bazie emitowany jest event zawierający identyfikatory elementów do uruchomienia:
const queued = await service.queueCatalogImport(input)
const executableItems = queued.items.filter(
(item) => item.status === 'pending' || item.status === 'failed'
)
if (executableItems.length) {
await eventBus.emit({
name: IntegrationEvents.SYNC_ITEM_QUEUED,
data: {
run_id: queued.run.id,
queued: executableItems.map((item) => ({
sync_item_id: item.id,
})),
},
})
}To pozornie niewielka zmiana, ale zmienia model awarii. Jeżeli backend zapisze elementy kolejki i zgaśnie przed emisją eventu, praca nie przepada. Cykliczny backstop odnajdzie gotowe elementy w bazie i ponownie obudzi kolejkę.
IntegrationSyncRun opisuje operację widzianą przez użytkownika, na przykład import 30 produktów albo reconciliation magazynu. IntegrationSyncItem jest pojedynczą, niezależnie wykonywaną jednostką pracy: jednym produktem, jednym eventem magazynowym albo jednym krokiem naprawczym.
Webhook, zamówienie lub akcja użytkownika→ trwały SyncRun i SyncItems w PostgreSQL→ event budzący worker→ atomowy claim elementu→ executor konkretnego providera→ completed / skipped / conflict / failed / dead_letter→ aktualizacja postępu całego runu
Lease i claim fencing zamiast zwykłego statusu running
Samo ustawienie status = running nie wystarcza. Jeżeli worker zostanie zatrzymany podczas wdrożenia albo proces Node.js zakończy się w połowie requestu do providera, rekord mógłby pozostać w tym stanie na zawsze.
Dlatego każdy uruchomiony element otrzymuje czasowy lease:
{
status: 'running',
worker_id: '18432:550e8400-e29b-41d4-a716-446655440000',
claim_token: 'clm_09f70ec9-0df4-4a32-a96b-cd0cf2ae5cb8',
claimed_at: '2026-07-30T10:00:00Z',
lease_expires_at: '2026-07-30T10:02:00Z'
}Worker nie „posiada” zadania bezterminowo. Ma wyłączne prawo do wykonania konkretnej generacji claimu tylko do czasu wygaśnięcia lease. worker_id identyfikuje wykonawcę, natomiast losowy claim_token identyfikuje konkretne przejęcie itemu.
W trakcie dłuższej operacji heartbeat przedłuża lease. Domyślnie trwa on 120 sekund, a heartbeat wykonywany jest co 30 sekund.
Claim jest wykonywany atomowo w PostgreSQL. Poniżej skrócona wersja rzeczywistego zapytania:
SELECT pg_advisory_xact_lock(
hashtext('external-commerce:sync-item-claim')
);
WITH candidate AS MATERIALIZED (
SELECT item.id
FROM integration_sync_item item
WHERE item.attempt < item.max_attempts
AND (
(
item.status IN ('pending', 'failed')
AND COALESCE(item.available_at, item.created_at) <= NOW()
)
OR (
item.status = 'running'
AND item.lease_expires_at < NOW()
)
)
ORDER BY
item.priority DESC,
COALESCE(item.available_at, item.created_at),
item.created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE integration_sync_item item
SET
status = 'running',
attempt = item.attempt + 1,
claimed_at = NOW(),
lease_expires_at = NOW() + INTERVAL '120 seconds',
worker_id = $1,
claim_token = $2
FROM candidate
WHERE item.id = candidate.id
RETURNING item.*;FOR UPDATE SKIP LOCKED zabezpiecza sam rekord, natomiast transaction-scoped advisory lock serializuje kontrolę dostępnej przepustowości. Dzięki temu dwa workery nie mogą jednocześnie uznać, że zajmują ostatni wolny slot globalnego limitu.
Heartbeat oraz każda finalizacja wymagają jednocześnie zgodności worker_id, claim_token i aktywnego lease:
UPDATE integration_sync_item
SET
lease_expires_at = NOW() + INTERVAL '120 seconds',
updated_at = NOW()
WHERE id = $1
AND status = 'running'
AND worker_id = $2
AND claim_token = $3
AND lease_expires_at > NOW()
RETURNING *;Operacje complete, skip i fail korzystają z tego samego warunku ownership:
WHERE id = $1
AND status = 'running'
AND worker_id = $2
AND claim_token = $3
AND lease_expires_at > NOW()
Dzięki temu system blokuje finalizację po wygaśnięciu lease, finalizację claimu należącego do innego wykonania oraz starą gałąź workera po ponownym przejęciu tego samego itemu. worker_id zawiera UUID per wykonanie, ale osobny claim_token pozostaje właściwszą, jednoznaczną tożsamością konkretnej generacji claimu.
Nie jest to obietnica exactly-once delivery. Zewnętrzne API i awarie sieci sprawiają, że praktycznym modelem jest at-least-once execution: zadanie może zostać wykonane ponownie, ale nie może zostać niezauważenie utracone. Claim fencing chroni lokalny lifecycle itemu, ale nie cofnie requestu wykonanego już w Shopify, Shoper czy PrestaShop. Dlatego operacje providera nadal muszą być idempotentne albo zabezpieczone mappingiem i stabilnym kluczem idempotencji.
Idempotencja zaczyna się przed workerem
Dla importu produktu klucz idempotencji uwzględnia połączenie, zewnętrzny identyfikator produktu oraz generację importu:
const generationHash = crypto
.createHash('sha256')
.update(generationToken)
.digest('hex')
.slice(0, 16)
const idempotencyKey =
`catalog.import:${externalId}:v1:${generationHash}`Baza wymusza jego unikalność:
CREATE UNIQUE INDEX
"UNQ_integration_sync_item_idempotency"
ON "integration_sync_item" (
"connection_id",
"idempotency_key"
)
WHERE "deleted_at" IS NULL;Generacja nie identyfikuje kliknięcia użytkownika ani pojedynczego requestu HTTP. Jest stabilnym identyfikatorem logicznego cyklu importu produktu. Pierwszy import korzysta z generacji initial; nowa generacja powstaje dopiero po świadomej zmianie stanu, na przykład odłączeniu mappingu albo rozpoczęciu nowego cyklu po oznaczonym błędzie.
Dzięki temu dwa kliknięcia tego samego produktu w tym samym stanie logicznym nie tworzą dwóch produktów. Jeżeli identyczny item już istnieje, backend zwraca wcześniejszą pracę. Payload, taki jak wybrana kategoria lub profil wysyłki, nie jest obecnie częścią klucza. Jeżeli jego zmiana ma oznaczać nową pracę, powinna utworzyć nową generację lub wersję konfiguracji importu.
Priorytet synchronizacji jest częścią poprawności
Jedna wspólna kolejka dla importu katalogu i stanów magazynowych tworzy jeszcze jeden problem. Import 300 produktów nie może zablokować informacji, że właśnie sprzedano ostatnią sztukę produktu.
Dlatego operacje otrzymują różne priorytety:
export const IntegrationSyncPriorities = {
CATALOG_RECONCILIATION: 10,
INVENTORY_RECONCILIATION: 20,
DEFAULT: 50,
CATALOG_IMPORT: 60,
MANUAL: 70,
INVENTORY_EVENT: 90,
ORDER_INVENTORY: 100,
} as constZmiana stanu wynikająca z zamówienia w Artovnii ma priorytet 100. Event magazynowy providera otrzymuje 90, ręczna operacja użytkownika 70, import katalogu 60, a okresowe reconciliation odpowiednio 20 lub 10.
Sam ORDER BY priority DESC nadal nie wystarczałby, gdyby wszystkie workery były już zajęte niskopriorytetowym importem. Claim rezerwuje więc część globalnej przepustowości dla zadań o wysokim priorytecie. Oprócz tego obowiązują osobne limity:
-globalnie dla całego huba
-dla jednego providera
-dla jednego połączenia
osobno dla importu katalogu, który może bezpiecznie korzystać z większej równoległości.
Dzięki temu jeden powolny sklep PrestaShop nie może zająć wszystkich workerów. Limity współbieżności i backoff ograniczają wpływ awarii Shoper na pozostałych providerów, ale pełna izolacja globalnej awarii platformy wymagałaby dodatkowego circuit breakera lub health state na poziomie providera. Dodatkowym niuansem jest to, że zadania wysokiego priorytetu mogą ominąć provider-level limit, choć nadal podlegają limitowi globalnemu. Duży onboarding nowego sprzedawcy nie powinien więc opóźniać synchronizacji stanów pozostałych sklepów, ale izolacja providerów nie jest absolutna.
Retry, Retry-After i circuit breaker
Błąd zewnętrznego API nie oznacza automatycznie błędu trwałego. Timeout, odpowiedź 429, chwilowe 502 albo restart sklepu powinny prowadzić do kolejnej próby, ale bez agresywnego bombardowania providera.
Hub stosuje exponential backoff, ograniczony do jednej godziny:
let retryDelaySeconds = Math.min(
3600,
30 * 2 ** Math.max(0, syncItem.attempt - 1)
)
const providerRetryAfter = Number(
error.retry_after_seconds ??
(Number.isFinite(Number(error.retryAfterMs))
? Number(error.retryAfterMs) / 1000
: NaN)
)
if (Number.isFinite(providerRetryAfter) && providerRetryAfter > 0) {
retryDelaySeconds = Math.max(
retryDelaySeconds,
Math.min(3600, Math.ceil(providerRetryAfter))
)
}Jeżeli provider poda Retry-After, hub respektuje dłuższy z obu czasów. Powtarzające się błędy przejściowe otwierają dodatkowo circuit breaker konkretnego IntegrationConnection. Problem jednego sklepu przestaje wtedy generować kolejne natychmiastowe requesty i nie zużywa bez końca wspólnej przepustowości.
Status failed nie jest terminalny. Oznacza, że ostatnia próba zakończyła się błędem, ale item czeka na ponowne wykonanie po available_at. Dopiero po wyczerpaniu max_attempts element przechodzi do dead_letter:
UPDATE integration_sync_item
SET
status = CASE
WHEN attempt >= max_attempts THEN 'dead_letter'
ELSE 'failed'
END,
available_at = CASE
WHEN attempt >= max_attempts THEN NULL
ELSE NOW() + ($1 * INTERVAL '1 second')
END,
lease_expires_at = NULL,
worker_id = NULL,
claim_token = NULL
WHERE id = $2
AND status = 'running'
AND worker_id = $3
AND claim_token = $4
AND lease_expires_at > NOW();dead_letter nie oznacza, że rekord znika. Zachowuje identyfikator sklepu, providera, operację, liczbę prób i diagnostykę potrzebną do analizy lub kontrolowanego replayu.
Jawny replay resetuje attempt, przywraca status pending, czyści aktywny lease i ownership, zapisuje czas oraz autora ostatniego wznowienia i ponownie otwiera parent run. Pełna historia kolejnych replayów nie jest jeszcze modelowana jako osobny audit log. Automatyczne wznowienie podczas ponownego importu również może przenieść pasujący item dead_letter do pending, ale nie zapisuje aktora w taki sam sposób. Jeżeli dane wejściowe mają się zmienić, powinien powstać nowy item z nową generacją, a nie replay starej operacji.
Backstop i health job domykają model awarii
Event może nie zostać dostarczony. Subscriber może zostać zrestartowany. Worker może pobrać zadanie i zniknąć. Durable architecture musi zakładać wszystkie te przypadki.
Co minutę backstop wyszukuje:
-elementy pending, które są już dostępne
-elementy failed, których backoff się zakończył
-elementy running z wygasłym lease
Następnie ponownie emituje ich identyfikatory do wspólnego subscribera. Nie tworzy nowych elementów, budzi istniejącą pracę zapisaną wcześniej w bazie.
Osobny health job monitoruje:
COUNT(*) FILTER (
WHERE status = 'dead_letter'
) AS dead_letter_count,
COUNT(*) FILTER (
WHERE status = 'running'
AND lease_expires_at < NOW()
) AS expired_lease_count,
COUNT(*) FILTER (
WHERE status IN ('pending', 'failed')
AND attempt < max_attempts
AND COALESCE(available_at, created_at) <= NOW()
) AS ready_countMonitorowany jest również wiek najstarszego gotowego elementu. Sama liczba oczekujących zadań może być normalna podczas dużego importu; element czekający znacznie dłużej niż założony próg oznacza natomiast, że kolejka przestała się opróżniać.
Health job wykrywa również osierocone SyncRun, które nie mają żadnych itemów. Historycznie taki stan mógł powstać, gdy run i item były tworzone w dwóch osobnych operacjach. Obecny flow tworzy oba rekordy atomowo, więc nowy pusty run oznacza naruszenie inwariantu.
Po przekroczeniu grace period health job nie rekonstruuje brakujących itemów ani nie oznacza runu jako sukces. Ustawia:
status = failed
error_code = sync_run_inconsistent
error_message = Sync run has no items and cannot be executed
oraz zapisuje diagnostykę:
{
"empty_run_inconsistent_at": "...",
"empty_run_inconsistent_reason": "no_sync_items_created"
}Generuje również warning i raportuje inconsistent_empty_run_count. Historyczna migracja dawnych pustych runów pozostaje osobnym przypadkiem; nowe naruszenia inwariantu nie zawyżają statystyk sukcesu.
Event-driven, ale nie event-dependent
Dopiero na tej warstwie widać pełny sens połączenia eventów z reconciliation.
Zdarzenie o zamówieniu albo zmianie stanu uruchamia mały, wysokopriorytetowy sync item. Nocny audyt tworzy niskopriorytetowe elementy reconciliation. Oba przechodzą przez ten sam lease, retry, limity współbieżności, circuit breaker i monitoring.
Reconciliation nie konkuruje więc ze zdarzeniami o równych prawach. Ma niższy priorytet i wykorzystuje przepustowość wtedy, gdy kolejka nie obsługuje pilniejszych zmian.
Webhook może zginąć. Sklep może być wyłączony w chwili dostarczenia zdarzenia, a lokalny outbox może przez pewien czas nie móc połączyć się z Artovnią. Event zapewnia małe opóźnienie, natomiast reconciliation zapewnia ostateczne wykrycie rozbieżności.
To jest część architektury, która naprawdę pozostała provider-neutralna. WooCommerce może wejść do huba przez wc-auth, Shopify przez credentials aplikacji sprzedawcy, Shoper przez API Token, a PrestaShop przez moduł PHP i jednorazowy kod. Po utworzeniu sync itemu różnice te przestają mieć znaczenie dla orkiestracji runtime’u, choć nadal pozostają zamknięte w executorze konkretnego providera.
Provider-neutralność nie oznacza, że każdy provider dostarcza eventy tak samo. Oznacza, że po zapisaniu trwałej pracy każdy z nich podlega temu samemu modelowi trwałości, współbieżności, retry i odzyskiwania po awarii.
Podsumowanie
Tych trudności nie dało się dostrzec, czytając wyłącznie dokumentację API. Kluczowe ograniczenia były ukryte w zasadach dystrybucji aplikacji, procesach akceptacji i modelach biznesowych poszczególnych platform, często rozproszonych poza dokumentacją techniczną.
W development zdarzają się rzeczy, na które nie mamy bezpośredniego wpływu i które potrafią podważyć założenia stojące za wieloma tygodniami pracy. Moim podejściem jest jednak nie zatrzymywać się na ograniczeniu, lecz znaleźć rozwiązanie możliwe do utrzymania w produkcji.
W tym przypadku doprowadziło to do rozbudowania i znacznego wzmocnienia całego modułu integracji e-commerce. Hub obsługuje dziś różne modele instalacji i polityki dystrybucji, a jego trwała kolejka potrafi odzyskać pracę po restartach, awariach sieci i czasowej niedostępności zewnętrznych API. Lease, heartbeat i claim_token pilnują ownership aktualnego wykonania, natomiast retry, circuit breaker, priorytety i reconciliation sprawiają, że zadania nie giną po cichu, a problemy jednego sklepu mają ograniczony wpływ na pozostałych.
Co równie ważne, obciążenie backendu jest kontrolowane przez limity współbieżności, priorytety oraz rozłożone w czasie audyty. Przy infrastrukturze celowo ograniczonej do niezbędnego minimum taka przewidywalność jest bezcenna.


