[1.5.0] - 2026-10-12
Minor release. Durable event delivery, optional Kafka, scripted HA promote, and per-rule gateway sync. VERSION is 1.5.0. This commit does not push the git tag. Images are published when release.yml runs on v1.5.0.
Upgrade
- Postgres migrations
020(event_outbox) and021(gateway sync schedule) run on startup. Bolt opens the outbox buckets when the database opens. A v1.4.x binary leaves them in place and ignores them. The down migrations drop the table and its events, or the schedule columns. - The no-op Kafka and RabbitMQ sinks are removed. Kafka delivers only when
STORAGE_KAFKA_BROKERSis set. An empty value does not dial. Webhook and NATS settings are unchanged. These sinks are an external event feed, not the cluster interconnect. - Lab ports moved. Erasure defaults are console 8084, S3 9004, Postgres 5444. Site A defaults are console 8086, S3 9006, Postgres 5446. Delete an old
.env.erasureor.env.site-aand copy the example again. A copied file keeps the previous ports. - Automatic storage-leader takeover waits out the current lease. A 10s lease takes about 9s. The default 30s lease takes about 26s. A process that already held the lease and then lost it does not acquire it again.
Guides: events EN · RU · HA EN · RU · upgrade EN · RU.
Added
-
Gateway sync schedule (opt-in). A replication rule with an empty
sync_schedulestill followsSTORAGE_GATEWAY_FULL_SYNC_INTERVAL(default1h;STORAGE_GATEWAY_CONTINUOUS_SYNC=trueshortens that global scan to 5 minutes). That scan queues objects and does not write a sync job.offstops the scheduled full scan for that rule; object events still replicate. A duration from1mto168hcopies only that rule, writes a sync job, and stores the last run (last_sync_at,last_sync_status,last_sync_error,last_sync_objects). The console rule card shows the schedule, the last run, and the error.PATCH /api/v1/gateway/replication/{id}changes the schedule. Postgres migration021. This is not continuous multi-destination sync. -
HA lab port split. Erasure defaults are console 8084, S3 9004, Postgres 5444. Site A defaults are console 8086, S3 9006, Postgres 5446. Both overlays override the storage port, so they no longer publish
:9000. Site A’s cluster endpoint defaults to its own S3 port. Vault stays 9001 / 5434 / 8200. HA stays 8082 / 9002 / 5442, with standby HTTP 19011 and 19013. The HA NATS profile publishes 14222 and 18222.scripts/ha/*.ps1read the stand.envwhen it exists. An older copied.env.erasureor.env.site-akeeps its previous ports until it is recreated from the example. -
Promote drill without host Python.
bash scripts/ha/run-promote-drill.shruns its helpers inside the webhook container. A static Docker client is downloaded once into the drill state directory and mounted there. Git Bash on Windows does not need Python installed. Linux state stays/tmp/datasafe-promote-drill. The SSH lab gate still uses hostpython3. -
Scripted promote and a DR evidence pack.
scripts/ha/promote.shandscripts/ha/promote.ps1fence the old Postgres primary, promote the standby, and on a fresh promote clearha_leader_lock. A second run is safe and leaves a live lease in place.scripts/cluster/promote.shand.ps1are the Patroni switchover with the same exit codes. Dry-run prints the plan and changes nothing.scripts/collect-dr-evidence.shand.ps1write a folder,summary.md,evidence.json, and a zip (timing, lag, healthz, object write/read, outbox, dispatcher on the new leader, loss, at-least-once duplicates, versions).bash scripts/ha/run-promote-drill.shis the one-command lab; CI jobha-promote-drillruns it. Host ports default away from:9000and:9001. This is scripted promote, not automatic multi-AZ failover. See EN · RU. -
Event delivery observability. The overview dashboard has an Event delivery row: outbox pending, delivered/failed/dead rates by destination, dropped rate by reason, delivery lag p50/p95/p99, the degraded gauge, and Kafka bridge restarts (
datasafe_events_bridge_restarts_total,datasafe_events_sink_degraded). Example alerts aredeploy/docker/prometheus/alerts.yml(pending above 1000 or growing for 10 minutes, any destination degraded for 5 minutes, dead deliveries increasing, pending-cap drops increasing). Compose loads that file. CI jobevent-observabilityrunspromtool check rulesand provisions Grafana13.2.3to read dashboarddatasafe-overview. Activity writesevents_sink_degradedon the first real failure of a destination andevents_sink_recoveredwhen it connects again; retries do not add rows. The console Activity filter lists those actions andevents_outbox_dropped. Before the first successful probe, delivery status isdegradedwithlast_errorwaiting for first successful connection. See EN · RU. -
Kafka sink (opt-in).
STORAGE_KAFKA_BROKERSregisters a franz-go producer (pure Go, no CGO) withacks=all, the default idempotent producer, record keytenant/bucket/key, and topicSTORAGE_KAFKA_TOPIC(defaultdatasafe.events). TLS CA/cert/key files, SASL PLAIN (refused unless TLS is on), and SCRAM-SHA-256/512. Username and password accept*_FILE. An empty broker list does not create a client, a goroutine, or a connection. The body is S3-notification JSON (Records[],eventName,s3.bucket.name,s3.object.key/size/eTag/versionId) plusdatasafe(tenant_id,event_id,schema_version: 1). Webhook and NATS keep their envelopes. Put, copy, and multipart storeetag,size, and the object version id from the committed object on the outbox row at insert. The dispatcher holds per-object order for every sink: a later row for the same tenant/bucket/key is not published to a sink while an earlier row is still pending on that sink. One stuck key blocks only that key. When the earlier attempt goesdead, later rows proceed and the dead row is a gap. Settings → Event delivery shows connected/degraded/disabled, last error, and queue depth; Send test event is administrator-only (GET /api/v1/events/delivery,POST /api/v1/events/test). A broker-list change between starts is Activitykafka_brokers_changed. Lab overlaydeploy/compose/docker-compose.kafka.yml(apache/kafka:3.9.2) and installer profilekafkaare not in the default profiles. See EN · RU. -
Single-node regression gate. CI job
single-node-gate(pull requests intomain, and pushes tomain) builds the storage-server image and runs only that container the way installer profilecoredoes: Bolt,STORAGE_DEV=false, no NATS or Kafka, Postgres hostname set but no Postgres process. Smoke covers/healthz, admin login and first-run setup, S3 put/get/list with the AWS CLI, Activity, inventory create/list/download, and an evidence-pack zip. It fails on broker dials in logs or outbound TCP, and when the outbox pending metric is not zero with no sinks. Idle RSS and startup-to-healthy are medians of five interleaved runs againstghcr.io/direktorbani/datasafe-storage-server:v1.4.1on the same runner; the job fails above baseline +10% RSS or baseline +1s startup../install.sh --yes --profiles coreruns the same smoke..\install.ps1 -Yes -Profiles coreis the Windows command; CI parses that script and does not boot Docker on Windows. See EN · RU. -
Durable event outbox. Admin notification events (object, bucket, user, share, multipart, trash) are stored in the metadata backend before delivery: bolt buckets
event_outboxandevent_outbox_due, or Postgres tableevent_outbox(migration020). One dispatcher per process delivers to webhook and NATS with exponential backoff and jitter, per-sink attempts, anddeadafterSTORAGE_EVENTS_MAX_ATTEMPTS. Delivered rows expire afterSTORAGE_EVENTS_RETENTION(default24h); dead and cap-dropped rows afterSTORAGE_EVENTS_DEAD_RETENTION(default168h).STORAGE_EVENTS_MAX_PENDING(default100000) drops the oldest pending rows, incrementsdatasafe_events_dropped_total, and writes Activityevents_outbox_dropped. Object writes are not failed by that cap. In HA only the Postgres leader dispatches. None of theSTORAGE_EVENTS_*variables are required. See EN · RU. ADR-0002 is Accepted.
Changed
- Event delivery page. Each sink shows delivered and failed counts from
datasafe_events_delivered_totalanddatasafe_events_failed_total(GET /api/v1/events/delivery). Those counters are process memory and reset to zero when storage-server restarts. The sentence that Kafka stays off untilSTORAGE_KAFKA_BROKERSis set is shown only while that sink is disabled. The console sidebar shows the server version fromGET /api/v1/health. GET /healthzandGET /api/v1/healthincludeversionandcommit, set with-ldflagsfromVERSIONand the git commit indeploy/docker/Dockerfile.release.ymlpasses both when it publishes the tag.- Webhook and NATS delivery goes through the outbox.
STORAGE_NATS_URLand console webhook settings are unchanged. An empty NATS URL does not register a sink and does not dial. A webhook sink is registered only while at least one webhook is enabled. With no configured sink, admin mutations do not write outbox rows, and events from that period are not replayed later. Each console webhook keeps one delivery-history row per outbox event; retries update that row. - Removed the no-op RabbitMQ and Kafka sinks from process wiring. There is one
events.EventSinkinterface. Kafka is a real sink only whenSTORAGE_KAFKA_BROKERSis set. The container image runs franz-go in/usr/lib/datasafe/datasafe-kafka-bridgeand starts it only then. Ago buildwithout-tags datasafe_sinkexeclinks it into the binary and still does not dial when brokers are empty. - S3 SigV4 object events (put, copy, delete including delete markers, multipart complete) write outbox rows in the same metadata transaction when a sink is registered. With no sink and no matching bucket notification, those PUTs do not grow the outbox.
- Bucket-notification webhooks are delivered by the outbox dispatcher. Optional prefix/suffix filters apply when the row is inserted. The POST body stays
{"event","bucket","key","size","time"}. - Webhook retry classification. Transport errors, timeouts, HTTP 5xx, 408, and 429 are retried.
Retry-Afteris honoured and capped at 5 minutes. Any other 4xx marks that sink dead immediately;last_errorcontains the status. - NATS JetStream. If the connected server has JetStream and a stream covers
STORAGE_NATS_SUBJECT.<event>, publish waits for an ack (failure is retried). Otherwise core publish is used. There is no extra environment variable.
Fixed
-
Fresh install console. Default Compose copies
/srv/consolefromDATASAFE_CONSOLE_IMAGE(ghcr.io/direktorbani/datasafe-console:v1.5.0) into theconsole-distvolume and Caddy serves that volume. Agit archivetree has noweb/console/dist, so the old bind mount was an empty directory andGET /was 404 while/healthzstill returned 200.install.shandinstall.ps1now requireGET /200 and theid="root"marker on the edge (portPUBLIC_HTTP_PORT, default 8080). Hostweb/console/distis the overlaydeploy/compose/docker-compose.local-console.yml. -
Simultaneous start on empty Postgres. Several nodes no longer exit with
user already exists. Bootstrap of the admin user runs under the migration advisory lock, and an existing admin is success. -
Webhook delivery after HA takeover. A follower reloads sinks from the database when it becomes leader and about every 2 seconds, so a webhook created after that process started is registered. An outbox row for a sink that is not registered yet stays pending and does not count attempts toward dead. Takeover waits out the current lease (about 9s for a 10s lease, about 26s for the default 30s lease).
-
Promote-drill Docker client on Git Bash. The helper looked for an executable bit. Git Bash does not set that on the Linux client, so every run downloaded the 82 MB archive again. The script now keeps a non-empty file, writes the download to a partial tarball, and resumes with
curl -C - --retry 5 --retry-delay 3, including throughHTTPS_PROXY. -
Gateway connection create response.
POST /api/v1/gateway/connectionsno longer returnssecret_key. The list endpoint already omitted it. The stored credential is unchanged. -
Concurrent PUT of one key. Writers on one node no longer share
<key>.tmpor insert a second unversioned row. Temps are unique, BoltPutand PostgresON CONFLICTkeep the last writer, and that writer's bytes, etag, and size agree. Twenty parallel PUTs of one key all return 200. -
Parallel Postgres migrate. Nodes that start together on an empty database take a session advisory lock around migrations, so
CREATE EXTENSIONin001_initdoes not hitpg_extension_name_index. -
Short HA leader lease. A lease under 15 seconds used to renew on the same interval as the lease. The renew interval is now at most one third of the lease.
-
Follower lease takeover. A storage node that has never held the lease keeps trying and takes it only after the previous lease expires. A process that already led and then failed renew still drops the lease immediately and does not acquire it again. This is not Postgres failover and not a power fence.
-
Gateway rule card. The console shows the rule name when the rule has one.
-
Event bridge restarts during a broker outage. A produce that outlasted the parent deadline used to kill the child and increment
datasafe_events_bridge_restarts_total. The child now answers within its own deadline and stays up. The counter moves only when the process exits. -
Dispatcher republish after a read-only primary. A promote that fenced Postgres used to leave the old leader publishing the same outbox row on every tick until renew failed, because the webhook had already been sent and the delivered mark could not be written. A read-only or not-leader error from the store now stops the dispatcher and drops the lease. That process does not publish the row again. The new leader may deliver it once.
-
Single-node Prometheus scrape. The shipped
cluster-nodes.jsontarget list is empty, so jobdatasafe-cluster-nodesdoes not scrapestorage-server:9000a second time. Add extra nodes there when you have them. -
Windows installer stderr.
install.ps1runs everydocker/docker composecall throughInvoke-NativeCommand. Windows PowerShell 5.1 with$ErrorActionPreference = 'Stop'was treating compose progress on stderr as a terminating error when the script output was redirected (*> log), so-Yes -Profiles coreexited 1 while the stack was already up. The helper keepsErrorActionPreferenceat Continue, setsPSNativeCommandUseErrorActionPreferenceto false on PowerShell 7.3+, returns stderr as text, and uses$LASTEXITCODEas the only success check.-Yesstill waits forhttp://127.0.0.1:8080/healthzand exits 0 when that responds. CI onwindows-latestruns that helper under redirected output in Windows PowerShell 5.1 and PowerShell 7. The Docker install itself stays on the Linux gate. -
Console Playwright
buckets.spec.tsno longer matches both the bucket-table search and the command palette. In Russian both placeholders start with «Поиск бакетов». The spec now matches onlySearch buckets.../Поиск бакетов....gateway.spec.tsaccepts the English, Russian, and French page titles, and the Russian empty state «Подключения шлюза не настроены».
Honesty
- Promote is a script. Nothing in this tree elects a new Postgres primary on its own, and nothing fails a node over between availability zones. A storage follower that shares the database retries the leader lease and takes it only after expiry. A process that already held the lease and then lost it does not try again. The fence is
default_transaction_read_onlyon the old primary plus an immediate drop of the storage-server leader lease when renew fails. A superuser can turn that GUC off in a session. The script does not rebuild the old primary as a standby. Object bytes stay on the node that wrote them; the drill's post-promote read is a new object on the new leader. Delivery during the drill is at-least-once: one extra post is allowed for a row that was published and then could not be marked delivered. The compose drill runs the image fromdeploy/docker/Dockerfile. CI jobha-promote-drillloads thedocker-buildartifact instead of building that image a second time. Node-a is started and becomes leader before node-b starts. The SSH lab gate runs the promote dry-run and a healthz evidence pack. The timed Patroni step is still the existing kill-leader drill. - Container images for this release are published when
release.ymlruns on the git tagv1.5.0. This commit does not push that tag.GET /healthzandGET /api/v1/healthreportversionandcommitfrom the image ldflags. The single-node gate still compares RSS and startup with the publishedv1.4.1image, becausev1.5.0is not on GHCR until the tag is pushed. The installer step retags the pull-request server image and builds the console image asv1.5.0locally so Compose uses the pull-request bits. A freshgit archiveplusinstall.sh --yes --profiles coremust returnGET /200. datasafe_events_sink_degradedis 1 from registration until the first successful probe, so a destination that has not connected yet looks degraded. The 5-minute alert does not fire for a probe that succeeds immediately. Activity does not record the waiting sentence, only a real error and the later recovery. Dropped events have a reason label, not a destination label. Compose pins Grafana13.2.3and Prometheusv3.15.0. Helm Prometheus and Grafana image tags invalues.yamlstayv1.2.0. The DataSafeS3 server and console tags in that file arev1.5.0. The Helm overview JSON includes the same Event delivery row, and Helm Prometheus does not mount the example alert file.- The gate's resource limits can still fail on a noisy GitHub runner when most PR samples are slow. The step summary prints min and max beside the medians. The job does not widen the +10% or +1s limits.
- GHCR login in
single-node-gateis optional. It is skipped for fork pull requests and when no token is present, and a failed login does not fail the job.ghcr.io/direktorbani/datasafe-storage-server:v1.4.1is a public image. - The installer step retags the PR server image as
ghcr.io/direktorbani/datasafe-storage-server:v1.5.0and tags a locally builtghcr.io/direktorbani/datasafe-console:v1.5.0so Compose uses the pull request bits. It does not push those tags. The resource baseline stays the publishedv1.4.1image. Outbox emptiness is the Prometheus gauge, not a Bolt key count. The TCP check does not see UDP DNS. - Object bytes are written before the metadata transaction. The S3 outbox row commits with the object or delete-marker row. Soft-delete commits the event with the original-object delete, after the trash copy and trash row. Multipart completion deletes the upload record in a following transaction and does not enqueue replication. Creating a tenant member writes the user, membership, and groups first, then inserts the event. Bucket create is one transaction with the bucket row; visibility and team updates after that are separate.
- Bucket notification
timeis the object record time at commit, not the HTTP send time.eventstaysputordelete(multipart postsput).bucketstays the storage key. The stored payload includes_webhook_url, which is stripped before the POST. There is no console delivery-history row for a per-bucket URL. Copy and put share the event valueput, so a Copy-only filter also matches a normal PUT. Delete and delete-marker sharedelete.PutBucketNotificationConfigurationstill keeps one URL (last topic, then last queue).s3:ObjectCreated:*now matches the posted eventput; previously the generic:*check prevented that. - A bucket notification that fails is retried. The previous HTTP path discarded the error. 3xx is terminal, same as 4xx other than 408 and 429. The console webhook sink retries every matching hook together: one hook's 429 retries hooks that already returned 2xx. The sink is dead immediately only when every failure on that attempt is terminal.
- NATS core publish does not confirm subscriber delivery. A JetStream ack confirms the stream stored the message, not that a consumer processed it. The mode is cached until reconnect.
- Delivery is at-least-once. A second leader that appears while a publish is in flight can deliver the same row again. Followers do not dispatch. Bolt has no leader lock, so the single process always dispatches. Per-object order is per sink and per non-empty key. It does not stop a duplicate of an earlier
event_id. An earlier row that goesdeadis not published; later rows for that key are. Empty keys (UserCreated, bucket events) are not ordered against each other. Kafka consumers should dedupe byevent_id. Idempotent produce covers retries inside one client session, not a process restart. Put, copy, and multipart copyetag,size, andversionIdfrom the committed object into the outbox row at insert.versionIdis empty when versioning is off. Webhookdataand the NATSpayloadobject includeetagandsizefor those events; the envelopes stay{"event","timestamp","data"}and{"event","payload"}. Bucket-notification posts stay{"event","bucket","key","size","time"}.ObjectCreatedis mapped tos3:ObjectCreated:Put, including copies, because the stored type does not distinguish copy. The object key in JSON is the raw key, not AWS URL-encoding. "Connected" on the delivery page is a cached status: the sink is registered, a probe or a publish has succeeded, and the last publish did not fail.GET /api/v1/events/deliverydoes not ping the broker. A background probe uses a 2s timeout, and publish failures update the same cache, so a down broker returnsdegradedwith the dial or topic error in under a second. It is not a consumer-lag probe. - The storage-server image does not link franz-go into
/storage-server. The client is the static child/usr/lib/datasafe/datasafe-kafka-bridge, started only whenSTORAGE_KAFKA_BROKERSis set. The image builds the server with-tags datasafe_sinkexec(no CGO). The child inherits the server environment; it is not a separate security boundary.go testand a normalgo buildlink the client in-process. If the child exits, the server logsevent bridge exited, marks the sink degraded, leaves the rows pending, and restarts the child with backoff. The child speaks only on stdin and stdout; it has no TCP listener, and its stderr is copied to the server log. It exits when the server exits. Restarting the child starts a new idempotent producer session, the same as restarting the server. The child produces one record at a time. Produce and the status ping are bounded so a stopped broker cannot hold a request for minutes. Built here with the same Docker builder, the image is 19,719,481 bytes of compressed content versus 15,872,560 for a rebuild oforigin/main(530eec9), a delta of +3,846,921 bytes (+24.2%).docker imagesshows 75.6MB versus 62.1MB. The publishedghcr.io/direktorbani/datasafe-storage-server:v1.4.1image is 15,801,193 bytes (61.8MB). The child binary in the image is 9,511,074 bytes. The sink never creates topics. There is noSTORAGE_KAFKA_AUTO_CREATE_TOPIC. A missing topic stays retriable andlast_erroristopic does not existplusUNKNOWN_TOPIC_OR_PARTITION. The lab overlay createsdatasafe.eventswithkafka-topics.shand setsauto.create.topics.enable=false. The integration test creates its own topic the same way, through the Kafka protocol, and does not rely on broker auto-create. - Default single-node still opens no broker connection. With no NATS URL, no enabled webhook, and no bucket notification it writes no outbox rows, including for S3 PUTs. Events from that period are not replayed. Deleting every bucket notification and restarting leaves queued bucket posts without a sink.
- Internal object writes (trash copy, inventory, lifecycle) do not emit global
ObjectCreated/ObjectDeletedrows. They still enqueue a bucket notification when the bucket rule matches.
Container images
ghcr.io/direktorbani/datasafe-storage-server:v1.5.0ghcr.io/direktorbani/datasafe-console:v1.5.0
CycloneDX SBOM files and cosign signatures are attached. See SECURITY.md for cosign verify instructions.