feat(webhook): v2 webhooks foundation — DB schema + JPA entities
Phase 1 v2 фичи Webhooks: HTTP callback delivery параллельно с Kafka publishing для consumer'ов которые не могут Kafka subscribe (геопортал PHP, legacy admin-ui'и, BI-системы). Migrations: - 0013-webhook-subscriptions: таблица + check constraints (URL scheme, scope enum) + partial index по active=true для hot poller path - 0014-webhook-deliveries: tracking каждой попытки доставки (один subscription × event = uniq), partial index due (status IN pending, retrying), DLQ index, FK на outbox_events с CASCADE delete JPA entities: - WebhookSubscription: TEXT[] filters (scope, dictionary, event_type), HMAC-SHA256 hex secret, active flag, audit fields - WebhookDelivery: lifecycle pending → retrying → delivered/dlq, exponential backoff schedule (1m → 5m → 15m → 1h → 6h → 24h capped), MAX_ATTEMPTS=1000 (consistent с outbox), markDelivered/markFailed/ resetDlq business methods Repositories: - WebhookSubscriptionRepository: findActive() через partial index - WebhookDeliveryRepository: findDue() для poller, INSERT ON CONFLICT DO NOTHING для idempotency, DLQ counts для метрик Ещё впереди: WebhookDispatcher service (HTTP POST + HMAC + SSRF guard), REST API CRUD (admin-only RBAC), admin-ui /webhooks page, тесты.
This commit is contained in:
+153
@@ -0,0 +1,153 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.domain.webhook;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook delivery — одна попытка доставки event'а подписчику. Один (subscription_id,
|
||||||
|
* outbox_event_id) row на match (uniq index). Жизненный цикл:
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* pending → retrying → delivered (2xx response)
|
||||||
|
* ↓
|
||||||
|
* dlq (после attempt_count >= 1000)
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>Backoff schedule: 1m, 5m, 15m, 1h, 6h, 24h (exponential), capped 24h.
|
||||||
|
* Computed в {@link #scheduleNextAttempt()}.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "webhook_deliveries")
|
||||||
|
public class WebhookDelivery {
|
||||||
|
|
||||||
|
public enum Status {
|
||||||
|
pending,
|
||||||
|
retrying,
|
||||||
|
delivered,
|
||||||
|
dlq
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backoff schedule in seconds. attemptCount-1 индексирует. После — last value. */
|
||||||
|
private static final long[] BACKOFF_SECONDS = {
|
||||||
|
60L, // 1 min
|
||||||
|
5 * 60L, // 5 min
|
||||||
|
15 * 60L, // 15 min
|
||||||
|
60 * 60L, // 1 h
|
||||||
|
6 * 60 * 60L, // 6 h
|
||||||
|
24 * 60 * 60L // 24 h
|
||||||
|
};
|
||||||
|
|
||||||
|
/** После этого attempt_count → DLQ flag. Совпадает с outbox cap. */
|
||||||
|
public static final int MAX_ATTEMPTS = 1000;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
@Column(name = "id")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "subscription_id", nullable = false)
|
||||||
|
private UUID subscriptionId;
|
||||||
|
|
||||||
|
@Column(name = "outbox_event_id", nullable = false)
|
||||||
|
private Long outboxEventId;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "status", nullable = false, length = 16)
|
||||||
|
private Status status = Status.pending;
|
||||||
|
|
||||||
|
@Column(name = "attempt_count", nullable = false)
|
||||||
|
private int attemptCount = 0;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "next_attempt_at", nullable = false)
|
||||||
|
private OffsetDateTime nextAttemptAt;
|
||||||
|
|
||||||
|
@Column(name = "last_attempt_at")
|
||||||
|
private OffsetDateTime lastAttemptAt;
|
||||||
|
|
||||||
|
@Column(name = "delivered_at")
|
||||||
|
private OffsetDateTime deliveredAt;
|
||||||
|
|
||||||
|
@Column(name = "last_error", columnDefinition = "TEXT")
|
||||||
|
private String lastError;
|
||||||
|
|
||||||
|
@Column(name = "last_status_code")
|
||||||
|
private Integer lastStatusCode;
|
||||||
|
|
||||||
|
@Column(name = "dlq_at")
|
||||||
|
private OffsetDateTime dlqAt;
|
||||||
|
|
||||||
|
protected WebhookDelivery() {}
|
||||||
|
|
||||||
|
public WebhookDelivery(UUID subscriptionId, Long outboxEventId) {
|
||||||
|
this.subscriptionId = subscriptionId;
|
||||||
|
this.outboxEventId = outboxEventId;
|
||||||
|
OffsetDateTime now = OffsetDateTime.now();
|
||||||
|
this.createdAt = now;
|
||||||
|
this.nextAttemptAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markDelivered(int statusCode) {
|
||||||
|
this.status = Status.delivered;
|
||||||
|
this.deliveredAt = OffsetDateTime.now();
|
||||||
|
this.lastAttemptAt = this.deliveredAt;
|
||||||
|
this.lastStatusCode = statusCode;
|
||||||
|
this.lastError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Отметить попытку как failed и запланировать retry, либо DLQ если cap. */
|
||||||
|
public void markFailed(String error, Integer statusCode) {
|
||||||
|
this.attemptCount++;
|
||||||
|
this.lastAttemptAt = OffsetDateTime.now();
|
||||||
|
this.lastError = error;
|
||||||
|
this.lastStatusCode = statusCode;
|
||||||
|
|
||||||
|
if (this.attemptCount >= MAX_ATTEMPTS) {
|
||||||
|
this.status = Status.dlq;
|
||||||
|
this.dlqAt = this.lastAttemptAt;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.status = Status.retrying;
|
||||||
|
scheduleNextAttempt();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset DLQ flag — поллер снова возьмётся (e.g. оператор fixed receiver URL). */
|
||||||
|
public void resetDlq() {
|
||||||
|
this.status = Status.retrying;
|
||||||
|
this.dlqAt = null;
|
||||||
|
this.attemptCount = 0;
|
||||||
|
this.nextAttemptAt = OffsetDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scheduleNextAttempt() {
|
||||||
|
int idx = Math.min(this.attemptCount - 1, BACKOFF_SECONDS.length - 1);
|
||||||
|
long seconds = BACKOFF_SECONDS[idx];
|
||||||
|
this.nextAttemptAt = OffsetDateTime.now().plusSeconds(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public UUID getSubscriptionId() { return subscriptionId; }
|
||||||
|
public Long getOutboxEventId() { return outboxEventId; }
|
||||||
|
public Status getStatus() { return status; }
|
||||||
|
public int getAttemptCount() { return attemptCount; }
|
||||||
|
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public OffsetDateTime getNextAttemptAt() { return nextAttemptAt; }
|
||||||
|
public OffsetDateTime getLastAttemptAt() { return lastAttemptAt; }
|
||||||
|
public OffsetDateTime getDeliveredAt() { return deliveredAt; }
|
||||||
|
public String getLastError() { return lastError; }
|
||||||
|
public Integer getLastStatusCode() { return lastStatusCode; }
|
||||||
|
public OffsetDateTime getDlqAt() { return dlqAt; }
|
||||||
|
|
||||||
|
public boolean isDlq() { return status == Status.dlq; }
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.domain.webhook;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface WebhookDeliveryRepository extends JpaRepository<WebhookDelivery, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Due for retry: status IN (pending, retrying) AND next_attempt_at <= now().
|
||||||
|
* Использует partial index {@code idx_webhook_deliveries_due}. Limit через
|
||||||
|
* Pageable чтобы dispatcher не выгребал >1000 за тик.
|
||||||
|
*/
|
||||||
|
@Query("""
|
||||||
|
SELECT d FROM WebhookDelivery d
|
||||||
|
WHERE d.status IN ('pending','retrying')
|
||||||
|
AND d.nextAttemptAt <= :now
|
||||||
|
ORDER BY d.nextAttemptAt ASC
|
||||||
|
""")
|
||||||
|
List<WebhookDelivery> findDue(@Param("now") OffsetDateTime now, Pageable pageable);
|
||||||
|
|
||||||
|
/** DLQ листинг для admin UI. Partial index {@code idx_webhook_deliveries_dlq}. */
|
||||||
|
Page<WebhookDelivery> findByStatus(WebhookDelivery.Status status, Pageable pageable);
|
||||||
|
|
||||||
|
/** Метрика {@code nsi_webhook_dlq_count}. */
|
||||||
|
long countByStatus(WebhookDelivery.Status status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotent insert (sub × event = uniq). На race между dispatcher tick'ами
|
||||||
|
* вторая вставка попадёт в conflict, ON CONFLICT DO NOTHING. Native query
|
||||||
|
* чтобы не тащить ON CONFLICT через JPA.
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Query(value = """
|
||||||
|
INSERT INTO webhook_deliveries (subscription_id, outbox_event_id, status,
|
||||||
|
attempt_count, created_at, next_attempt_at)
|
||||||
|
VALUES (:subscriptionId, :outboxEventId, 'pending', 0, now(), now())
|
||||||
|
ON CONFLICT (subscription_id, outbox_event_id) DO NOTHING
|
||||||
|
""", nativeQuery = true)
|
||||||
|
int insertIfAbsent(
|
||||||
|
@Param("subscriptionId") UUID subscriptionId,
|
||||||
|
@Param("outboxEventId") Long outboxEventId);
|
||||||
|
|
||||||
|
/** История доставок одного event'а — для drilldown в UI. */
|
||||||
|
List<WebhookDelivery> findByOutboxEventIdOrderByCreatedAtDesc(Long outboxEventId);
|
||||||
|
|
||||||
|
/** История доставок одной подписки. */
|
||||||
|
Page<WebhookDelivery> findBySubscriptionIdOrderByCreatedAtDesc(
|
||||||
|
UUID subscriptionId, Pageable pageable);
|
||||||
|
}
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.domain.webhook;
|
||||||
|
|
||||||
|
import cloud.nstart.terravault.ordinis.domain.DataScope;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import org.hibernate.annotations.JdbcTypeCode;
|
||||||
|
import org.hibernate.type.SqlTypes;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook subscription — HTTP callback delivery параллельно с Kafka publishing.
|
||||||
|
* Для consumer'ов которые не могут Kafka subscribe (геопортал PHP, legacy admin-ui'и
|
||||||
|
* заказчиков, BI-системы).
|
||||||
|
*
|
||||||
|
* <p>Filter arrays: NULL/empty = match all events. Match logic в
|
||||||
|
* {@code WebhookDispatcher} — event matches subscription iff
|
||||||
|
* (scope_filter is null OR scope IN scope_filter) AND
|
||||||
|
* (dictionary_filter is null OR dictionary_name IN dictionary_filter) AND
|
||||||
|
* (event_type_filter is null OR event_type IN event_type_filter).
|
||||||
|
*
|
||||||
|
* <p>HMAC secret hex-encoded 64 chars (32 bytes random). Generated server-side
|
||||||
|
* на create, returned ONCE в response, потом hidden — admin-ui показывает
|
||||||
|
* только маскированный preview {@code sk_****abc123}.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "webhook_subscriptions")
|
||||||
|
public class WebhookSubscription {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "id")
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "name", nullable = false, length = 128)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(name = "url", nullable = false, length = 2048)
|
||||||
|
private String url;
|
||||||
|
|
||||||
|
/** TEXT[] postgres → List<String> via SqlTypes.ARRAY. */
|
||||||
|
@JdbcTypeCode(SqlTypes.ARRAY)
|
||||||
|
@Column(name = "scope_filter", columnDefinition = "TEXT[]")
|
||||||
|
private List<DataScope> scopeFilter;
|
||||||
|
|
||||||
|
@JdbcTypeCode(SqlTypes.ARRAY)
|
||||||
|
@Column(name = "dictionary_filter", columnDefinition = "TEXT[]")
|
||||||
|
private List<String> dictionaryFilter;
|
||||||
|
|
||||||
|
@JdbcTypeCode(SqlTypes.ARRAY)
|
||||||
|
@Column(name = "event_type_filter", columnDefinition = "TEXT[]")
|
||||||
|
private List<String> eventTypeFilter;
|
||||||
|
|
||||||
|
@Column(name = "hmac_secret", nullable = false, length = 128)
|
||||||
|
private String hmacSecret;
|
||||||
|
|
||||||
|
@Column(name = "active", nullable = false)
|
||||||
|
private boolean active = true;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "created_by", nullable = false, length = 128)
|
||||||
|
private String createdBy;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private OffsetDateTime updatedAt;
|
||||||
|
|
||||||
|
@Column(name = "description", columnDefinition = "TEXT")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
protected WebhookSubscription() {}
|
||||||
|
|
||||||
|
public WebhookSubscription(
|
||||||
|
String name,
|
||||||
|
String url,
|
||||||
|
String hmacSecret,
|
||||||
|
String createdBy) {
|
||||||
|
this.id = UUID.randomUUID();
|
||||||
|
this.name = name;
|
||||||
|
this.url = url;
|
||||||
|
this.hmacSecret = hmacSecret;
|
||||||
|
this.createdBy = createdBy;
|
||||||
|
OffsetDateTime now = OffsetDateTime.now();
|
||||||
|
this.createdAt = now;
|
||||||
|
this.updatedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void touch() {
|
||||||
|
this.updatedAt = OffsetDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getId() { return id; }
|
||||||
|
public String getName() { return name; }
|
||||||
|
public String getUrl() { return url; }
|
||||||
|
public List<DataScope> getScopeFilter() { return scopeFilter; }
|
||||||
|
public List<String> getDictionaryFilter() { return dictionaryFilter; }
|
||||||
|
public List<String> getEventTypeFilter() { return eventTypeFilter; }
|
||||||
|
public String getHmacSecret() { return hmacSecret; }
|
||||||
|
public boolean isActive() { return active; }
|
||||||
|
public OffsetDateTime getCreatedAt() { return createdAt; }
|
||||||
|
public String getCreatedBy() { return createdBy; }
|
||||||
|
public OffsetDateTime getUpdatedAt() { return updatedAt; }
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
|
||||||
|
public void setName(String v) { this.name = v; touch(); }
|
||||||
|
public void setUrl(String v) { this.url = v; touch(); }
|
||||||
|
public void setScopeFilter(List<DataScope> v) { this.scopeFilter = v; touch(); }
|
||||||
|
public void setDictionaryFilter(List<String> v) { this.dictionaryFilter = v; touch(); }
|
||||||
|
public void setEventTypeFilter(List<String> v) { this.eventTypeFilter = v; touch(); }
|
||||||
|
public void setActive(boolean v) { this.active = v; touch(); }
|
||||||
|
public void setDescription(String v) { this.description = v; touch(); }
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.domain.webhook;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface WebhookSubscriptionRepository extends JpaRepository<WebhookSubscription, UUID> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Активные подписки. Использует partial index {@code idx_webhook_subscriptions_active}
|
||||||
|
* (where active = true). Hot path в WebhookDispatcher.
|
||||||
|
*/
|
||||||
|
@Query("SELECT w FROM WebhookSubscription w WHERE w.active = true")
|
||||||
|
List<WebhookSubscription> findActive();
|
||||||
|
|
||||||
|
/** Метрика {@code nsi_webhook_subscriptions_active}. */
|
||||||
|
long countByActiveTrue();
|
||||||
|
}
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Webhook subscriptions (v2 feature) — HTTP callback delivery параллельно
|
||||||
|
с Kafka publishing. Для consumer'ов которые не могут Kafka subscribe
|
||||||
|
(геопортал PHP, legacy admin-ui'и заказчиков, BI-системы).
|
||||||
|
|
||||||
|
Архитектура:
|
||||||
|
- WebhookDispatcher (@Scheduled @1s) читает outbox_events × subscriptions
|
||||||
|
→ создаёт webhook_deliveries → POST URL → mark delivered.
|
||||||
|
- Параллельно с OutboxPoller (Kafka), не вместо.
|
||||||
|
- HMAC-SHA256 signing: header `X-Ordinis-Signature: sha256=<hex>`,
|
||||||
|
key = subscriptions.hmac_secret (random 32 bytes generated на create).
|
||||||
|
- SSRF guard: deny private CIDR (app-level, не БД).
|
||||||
|
-->
|
||||||
|
|
||||||
|
<changeSet id="0013-1-webhook-subscriptions" author="ordinis">
|
||||||
|
<createTable tableName="webhook_subscriptions">
|
||||||
|
<column name="id" type="UUID" defaultValueComputed="gen_random_uuid()">
|
||||||
|
<constraints primaryKey="true" nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="name" type="VARCHAR(128)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="url" type="VARCHAR(2048)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<!-- Filter arrays: NULL/empty = match all. PG TEXT[] для удобства
|
||||||
|
index-aware queries (ANY/= operators). -->
|
||||||
|
<column name="scope_filter" type="TEXT[]"/>
|
||||||
|
<column name="dictionary_filter" type="TEXT[]"/>
|
||||||
|
<column name="event_type_filter" type="TEXT[]"/>
|
||||||
|
<!-- HMAC secret hex-encoded 64 chars (32 bytes random). Generated
|
||||||
|
server-side, returned ONCE на create response, потом hidden. -->
|
||||||
|
<column name="hmac_secret" type="VARCHAR(128)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="active" type="BOOLEAN" defaultValueBoolean="true">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="created_at" type="TIMESTAMPTZ" defaultValueComputed="now()">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="created_by" type="VARCHAR(128)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="updated_at" type="TIMESTAMPTZ" defaultValueComputed="now()">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="description" type="TEXT"/>
|
||||||
|
</createTable>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0013-2-webhook-subscriptions-checks" author="ordinis">
|
||||||
|
<comment>
|
||||||
|
URL должен начинаться с https:// (production) или http:// (dev).
|
||||||
|
Scope values constrained тот же enum что DataScope в Java.
|
||||||
|
</comment>
|
||||||
|
<sql>
|
||||||
|
ALTER TABLE webhook_subscriptions
|
||||||
|
ADD CONSTRAINT chk_webhook_url_scheme
|
||||||
|
CHECK (url ~ '^https?://[^\s]+$');
|
||||||
|
|
||||||
|
ALTER TABLE webhook_subscriptions
|
||||||
|
ADD CONSTRAINT chk_webhook_scope_filter
|
||||||
|
CHECK (scope_filter IS NULL OR scope_filter <@ ARRAY['PUBLIC','INTERNAL','RESTRICTED']::TEXT[]);
|
||||||
|
</sql>
|
||||||
|
<rollback>
|
||||||
|
<sql>
|
||||||
|
ALTER TABLE webhook_subscriptions DROP CONSTRAINT IF EXISTS chk_webhook_url_scheme;
|
||||||
|
ALTER TABLE webhook_subscriptions DROP CONSTRAINT IF EXISTS chk_webhook_scope_filter;
|
||||||
|
</sql>
|
||||||
|
</rollback>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0013-3-webhook-subscriptions-active-index" author="ordinis">
|
||||||
|
<comment>
|
||||||
|
Partial index — dispatcher poller сканит только активные subscriptions
|
||||||
|
(горячий path). Inactive sit без overhead.
|
||||||
|
</comment>
|
||||||
|
<sql>
|
||||||
|
CREATE INDEX idx_webhook_subscriptions_active
|
||||||
|
ON webhook_subscriptions (created_at)
|
||||||
|
WHERE active = true;
|
||||||
|
</sql>
|
||||||
|
<rollback>
|
||||||
|
<sql>DROP INDEX IF EXISTS idx_webhook_subscriptions_active;</sql>
|
||||||
|
</rollback>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
+117
@@ -0,0 +1,117 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Webhook deliveries — tracking каждой попытки доставки event'а
|
||||||
|
подписчику. Один (subscription_id, outbox_event_id) row на match,
|
||||||
|
с retry counter и DLQ flag.
|
||||||
|
|
||||||
|
Lifecycle:
|
||||||
|
1. Dispatcher видит unprocessed event matching subscription → INSERT row
|
||||||
|
(status='pending', attempt_count=0).
|
||||||
|
2. POST URL. Success (2xx) → status='delivered', delivered_at=now().
|
||||||
|
3. Failure → status='retrying', attempt_count++, last_error=...,
|
||||||
|
next_attempt_at = now() + backoff(attempt_count).
|
||||||
|
4. После attempt_count >= 1000 → status='dlq', исключается из poller.
|
||||||
|
|
||||||
|
Backoff: 1m, 5m, 15m, 1h, 6h, 24h (exponential), capped 24h.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<changeSet id="0014-1-webhook-deliveries" author="ordinis">
|
||||||
|
<createTable tableName="webhook_deliveries">
|
||||||
|
<column name="id" type="BIGINT" autoIncrement="true">
|
||||||
|
<constraints primaryKey="true" nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="subscription_id" type="UUID">
|
||||||
|
<constraints nullable="false"
|
||||||
|
foreignKeyName="fk_webhook_deliveries_subscription"
|
||||||
|
references="webhook_subscriptions(id)"
|
||||||
|
deleteCascade="true"/>
|
||||||
|
</column>
|
||||||
|
<column name="outbox_event_id" type="BIGINT">
|
||||||
|
<constraints nullable="false"
|
||||||
|
foreignKeyName="fk_webhook_deliveries_event"
|
||||||
|
references="outbox_events(id)"
|
||||||
|
deleteCascade="true"/>
|
||||||
|
</column>
|
||||||
|
<!-- pending / retrying / delivered / dlq -->
|
||||||
|
<column name="status" type="VARCHAR(16)" defaultValue="pending">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="attempt_count" type="INTEGER" defaultValueNumeric="0">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="created_at" type="TIMESTAMPTZ" defaultValueComputed="now()">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="next_attempt_at" type="TIMESTAMPTZ" defaultValueComputed="now()">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="last_attempt_at" type="TIMESTAMPTZ"/>
|
||||||
|
<column name="delivered_at" type="TIMESTAMPTZ"/>
|
||||||
|
<column name="last_error" type="TEXT"/>
|
||||||
|
<column name="last_status_code" type="INTEGER"/>
|
||||||
|
<column name="dlq_at" type="TIMESTAMPTZ"/>
|
||||||
|
</createTable>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0014-2-webhook-deliveries-status-check" author="ordinis">
|
||||||
|
<sql>
|
||||||
|
ALTER TABLE webhook_deliveries
|
||||||
|
ADD CONSTRAINT chk_webhook_delivery_status
|
||||||
|
CHECK (status IN ('pending','retrying','delivered','dlq'));
|
||||||
|
</sql>
|
||||||
|
<rollback>
|
||||||
|
<sql>
|
||||||
|
ALTER TABLE webhook_deliveries DROP CONSTRAINT IF EXISTS chk_webhook_delivery_status;
|
||||||
|
</sql>
|
||||||
|
</rollback>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0014-3-webhook-deliveries-poller-index" author="ordinis">
|
||||||
|
<comment>
|
||||||
|
Hot poller path: WHERE status IN ('pending','retrying') AND
|
||||||
|
next_attempt_at <= now(). Partial index покрывает обе колонки.
|
||||||
|
</comment>
|
||||||
|
<sql>
|
||||||
|
CREATE INDEX idx_webhook_deliveries_due
|
||||||
|
ON webhook_deliveries (next_attempt_at)
|
||||||
|
WHERE status IN ('pending', 'retrying');
|
||||||
|
</sql>
|
||||||
|
<rollback>
|
||||||
|
<sql>DROP INDEX IF EXISTS idx_webhook_deliveries_due;</sql>
|
||||||
|
</rollback>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0014-4-webhook-deliveries-dlq-index" author="ordinis">
|
||||||
|
<comment>Для UI listing DLQ + reset операций.</comment>
|
||||||
|
<sql>
|
||||||
|
CREATE INDEX idx_webhook_deliveries_dlq
|
||||||
|
ON webhook_deliveries (dlq_at DESC)
|
||||||
|
WHERE status = 'dlq';
|
||||||
|
</sql>
|
||||||
|
<rollback>
|
||||||
|
<sql>DROP INDEX IF EXISTS idx_webhook_deliveries_dlq;</sql>
|
||||||
|
</rollback>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0014-5-webhook-deliveries-subscription-event-uniq" author="ordinis">
|
||||||
|
<comment>
|
||||||
|
Idempotency: один subscription × event = один row. Если dispatcher
|
||||||
|
ловит retry где event/subscription уже создавал delivery — INSERT
|
||||||
|
ON CONFLICT DO NOTHING.
|
||||||
|
</comment>
|
||||||
|
<sql>
|
||||||
|
CREATE UNIQUE INDEX uniq_webhook_deliveries_subscription_event
|
||||||
|
ON webhook_deliveries (subscription_id, outbox_event_id);
|
||||||
|
</sql>
|
||||||
|
<rollback>
|
||||||
|
<sql>DROP INDEX IF EXISTS uniq_webhook_deliveries_subscription_event;</sql>
|
||||||
|
</rollback>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
@@ -22,5 +22,7 @@
|
|||||||
<include file="changes/0010-envers-tables.xml" relativeToChangelogFile="true"/>
|
<include file="changes/0010-envers-tables.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="changes/0011-outbox-dlq.xml" relativeToChangelogFile="true"/>
|
<include file="changes/0011-outbox-dlq.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="changes/0012-audit-log-ipaddress-varchar.xml" relativeToChangelogFile="true"/>
|
<include file="changes/0012-audit-log-ipaddress-varchar.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changes/0013-webhook-subscriptions.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changes/0014-webhook-deliveries.xml" relativeToChangelogFile="true"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
Reference in New Issue
Block a user