hailer 0.1.0-x86_64-linux-gnu → 0.2.0-x86_64-linux-gnu

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4b5680804091cbda1da8dc731274fe818b95c1cc0b08d621ea7e0796fb90a11c
4
- data.tar.gz: d95a723c7b4f66bf200857c3c451de10e7c2ed0cbb7ad57c78d125e08754cf6a
3
+ metadata.gz: 0a964adac0d7271e72ad3e812ddb0d4f7e8c500db607d42cd63bd2714bcb715f
4
+ data.tar.gz: dc46d7e0ff86ef454ff21f27f93f2149d8879ba1552d8f5b72e261f7c0963965
5
5
  SHA512:
6
- metadata.gz: e4725a9543134f83ee74146ce0710683ab4628052dc83c67e92eb6e2ef5372c2d8828dfd092684ddb9019720b69bf56bc5c39c8730a6bb91ad38764f61eb56dc
7
- data.tar.gz: bdc2b42fda7487949ccb9b5a807f7f676a453600b7a0c7e240e513e3f525989512f1b7e525c33d6246bf4f18068d9de4d78ebf4c85743040557432723cdbf3dc
6
+ metadata.gz: 4ce86cc5d91bf6d626a9b236680131e8a6df58b7a78209ae7cfbb00acd99776a2f608a0cbaa862ff2aa349c71314f75e5df733fcf5ab7f07e9001634748145ee
7
+ data.tar.gz: 306ca32328b32236cbcca86358458dd2430b5676197a041cf0686cc9ed755b69c47f281ce0c6e48c948b4c7153b0fab48bbf78ff9bacfb44e90b877a6bd7fe43
data/CHANGELOG.md CHANGED
@@ -1,6 +1,15 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0 (unreleased)
3
+ ## 0.2.0 (unreleased)
4
+
5
+ - PostgreSQL-only live state, with atomic feedback and commit-safe cursors.
6
+ - Resumable, read-only AWS suppression reconciliation scoped by account/region.
7
+ - Pure behavior admission circuit, per-process throttle, and SES circuit breaker.
8
+ - Single-attempt sends, explicit ambiguous outcomes, and typed rate-limit errors.
9
+ - Private health/readiness sensors, supervised workers, and bounded graceful drain.
10
+ - Explicit read-only migration of stopped 0.1 redb state into an empty database.
11
+
12
+ ## 0.1.0
4
13
 
5
14
  - Rails 8.1+/Ruby 4.0+ ActionMailer delivery with automatic defaults.
6
15
  - Typed provider-neutral feedback client and Rails callback consumer.
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Rails writes the email. Hailer deals with sending it.
4
4
 
5
- This repository ships two things at version **0.1.0**:
5
+ This repository prepares two things at version **0.2.0** (not published yet):
6
6
 
7
7
  - **`hailer` gem** — an ActionMailer adapter, feedback client, and executable
8
8
  launcher. Platform gems bundle the Rust binary. No Ruby AWS SDK or native extension.
@@ -78,7 +78,7 @@ name = "hailer"
78
78
  command = "hailer"
79
79
  workers = 0
80
80
  threads = 2
81
- routes = [{ bind = "mail", pattern = "^/internal/mail/send$" }]
81
+ routes = [{ bind = "mail", pattern = "^/internal/mail/(send|events|health|ready)$" }]
82
82
  ```
83
83
 
84
84
  Mothership exports `MS_PID`, `MS_SHIP`, and `MS_SOCKET_DIR`. Hailer binds its
@@ -90,6 +90,31 @@ Give the **service**, not the Rails adapter, AWS region and credentials through
90
90
  the standard AWS credential chain. Its identity needs `ses:SendEmail`, and the
91
91
  SES account/identities must be configured for sending.
92
92
 
93
+ ### PostgreSQL
94
+
95
+ PostgreSQL is the **only live backend** in 0.2. Hailer owns a `hailer` schema,
96
+ not Rails tables. Use a separate database/role where practical:
97
+
98
+ ```sh
99
+ export HAILER_DATABASE_URL='postgres://hailer:password@db/hailer?sslmode=verify-full'
100
+ hailer migrate
101
+ ```
102
+
103
+ Supply the database CA through the normal PostgreSQL connection options.
104
+ Use certificate verification in deployment; a private network is not TLS.
105
+ Keep the URL in a secret manager, not TOML or a committed initializer.
106
+ Startup also applies embedded, versioned migrations. The bootstrap identity
107
+ needs database `CREATE` and ownership of its schema/tables. Runtime startup
108
+ currently runs that same bootstrap/migrator, so provision the service role for
109
+ it too; a separate DML-only runtime mode is not implemented.
110
+ The runtime never needs Rails schema ownership. Back up the database with your
111
+ normal PostgreSQL backup/restore tooling.
112
+
113
+ Configuration defaults: pool size 5 (range 1–64), shutdown timeout 30 seconds
114
+ (range 1–300). Every replica shares PostgreSQL; each has its own pool and optional
115
+ rate budget. Set your supervisor/container stop grace longer than Hailer's
116
+ shutdown timeout. PostgreSQL 18 is exercised locally and in CI.
117
+
93
118
  ## Bounces, complaints, and suppressions
94
119
 
95
120
  ```text
@@ -97,7 +122,7 @@ SES → SNS → dedicated SQS queue → Hailer → durable events + suppressions
97
122
  ```
98
123
 
99
124
  Hailer atomically commits each event, deduplication key, and suppression change
100
- to a local redb database **before** acknowledging SQS. Failed acknowledgements
125
+ to PostgreSQL **before** acknowledging SQS. Failed acknowledgements
101
126
  can be retried without duplicating stored events. Invalid/untrusted messages
102
127
  are left for SQS's dead-letter policy.
103
128
 
@@ -113,14 +138,16 @@ are left for SQS's dead-letter policy.
113
138
  Copy [hailer.example.toml](hailer.example.toml), set the real queue/topic, and
114
139
  pass `--config /path/to/hailer.toml` or `HAILER_CONFIG`. Without `[feedback]`,
115
140
  the consumer is disabled, but previously stored suppressions are still enforced.
116
- `HAILER_STATE_PATH` overrides `state_path`; the default is `data/hailer.redb`.
117
- Use a persistent, private volume in production.
141
+ `HAILER_DATABASE_URL` is required, even with feedback disabled.
142
+ The old `state_path`, `--state-path`, and `HAILER_STATE_PATH` are not supported.
143
+ Replicas may consume the same dedicated queue when they share the same Hailer
144
+ database. Commits serialize cursor allocation so a reader cannot skip an earlier
145
+ transaction that has not committed yet.
118
146
 
119
- **One Hailer process owns one state file and one dedicated queue.** Run the
120
- bay with `workers = 0`; this is not a distributed suppression database.
121
- Do not run competing consumers with separate state files. Back up the state
122
- file while Hailer is stopped; event history and deduplication currently have
123
- no automatic retention/pruning.
147
+ Event history and deduplication have **no automatic retention/pruning**.
148
+ Do not delete events casually: event IDs also carry the SQS deduplication
149
+ contract, and Rails consumers may still need their cursors. Plan retention
150
+ against consumer lag and redelivery windows before adding a pruning policy.
124
151
 
125
152
  Hailer also exposes a provider-neutral, nondestructive event feed:
126
153
 
@@ -157,7 +184,59 @@ updates in one application database transaction. Retried pages require
157
184
  idempotent handlers; no hidden poller, scheduling, or checkpoint database is
158
185
  started by the gem. Reading does not acknowledge or erase events.
159
186
 
160
- See [feedback setup and migration](docs/feedback.md) before removing a receiver.
187
+ The SQS policy must allow only the intended SNS topic, using `aws:SourceArn`
188
+ and account restrictions. Subscribe with `RawMessageDelivery=false`, restrict
189
+ direct `sqs:SendMessage`, set a dead-letter queue, and monitor its depth.
190
+ The SNS topic allowlist in TOML is defense in depth, not signature verification.
191
+ Hailer needs `sqs:ReceiveMessage` and `sqs:DeleteMessage` on that queue, plus
192
+ applicable KMS decrypt permissions if customer-managed encryption is used.
193
+
194
+ ### Import AWS suppressions
195
+
196
+ ```sh
197
+ hailer sync-suppressions
198
+ # If AWS expires a saved pagination token:
199
+ hailer sync-suppressions --restart
200
+ # Existing application export, JSON array:
201
+ hailer import-suppressions --file suppressions.json
202
+ ```
203
+
204
+ The service identity needs `ses:ListSuppressedDestinations`; STS
205
+ `GetCallerIdentity` identifies the account (AWS does not require an explicit
206
+ allow for that operation). The configured region is part of the source identity.
207
+ These commands **read AWS only**; they never mutate the account suppression list.
208
+ Missing AWS entries do not unsuppress local entries, and a bounce never
209
+ downgrades a complaint. Imports do not fabricate delivery-history events.
210
+ Account-level AWS data does not include every possible global SES suppression.
211
+
212
+ Enable `[suppression_sync]` as in the example to reconcile on startup and hourly.
213
+ The first complete sync gates send readiness. Later errors mark health degraded,
214
+ retain committed pages, and retry safe reads with bounded backoff. A token is
215
+ stored with its page in one transaction. Replicas coordinate a source-scoped
216
+ advisory lock; they cannot advance the same checkpoint concurrently.
217
+ An expired token deliberately requires the operator's `--restart`. Omitting
218
+ the section disables scheduled sync, not existing suppression enforcement.
219
+
220
+ ### Control plane
221
+
222
+ The behavior circuit evaluates **pure admission decisions**: input validity,
223
+ service/storage readiness, suppression, and local rate admission. PostgreSQL,
224
+ AWS calls, clocks, and token reservations remain owned by the async host.
225
+ The SES circuit breaker counts transient/ambiguous failures, not bad addresses
226
+ or permanent request rejections. An optional `[rate_limit]` budgets envelope
227
+ recipients **per process**, not per account; it does not discover SES quotas.
228
+
229
+ Private `GET /internal/mail/health` returns sanitized component states and
230
+ counters. `GET /internal/mail/ready` returns 503 during initial sync or storage
231
+ failure, otherwise 200. A provider circuit can be degraded while readiness
232
+ remains true so protected half-open probes can recover it. No credentials,
233
+ recipient addresses, message bodies, or pagination tokens appear in these sensors.
234
+ They are operational signals, not a guarantee SES will accept a message.
235
+
236
+ TERM/ctrl-c stops admission and SQS receives, then drains owned requests and
237
+ the current feedback commit/ack within the configured deadline. Idle links
238
+ close immediately. Deadline expiry aborts remaining tasks and exits nonzero;
239
+ an interrupted send can have an unknown outcome.
161
240
 
162
241
  **Security:** the route currently has no application-level authentication.
163
242
  `/internal/` is a name, not an access control. Keep the bind private. The client
@@ -172,6 +251,8 @@ it. A successful response must include a nonblank `message_id`.
172
251
  | --- | --- |
173
252
  | HTTP 400 / 422 | `Hailer::ValidationError` |
174
253
  | HTTP 502 | `Hailer::RejectedError` |
254
+ | HTTP 429 | `Hailer::RateLimitedError` |
255
+ | HTTP 503, `code: "outcome_unknown"` | `Hailer::AmbiguousDeliveryError` |
175
256
  | HTTP 503 | `Hailer::UnavailableError` |
176
257
  | Other non-200 status | `Hailer::DeliveryError` |
177
258
  | Invalid/oversized response, missing message ID | `Hailer::ProtocolError` |
@@ -182,18 +263,35 @@ The gem does not retry. A lost response can happen **after** SES accepted the
182
263
  message; retrying may duplicate it. There is no durable **outbound** queue or
183
264
  send-idempotency store yet; feedback deduplication does not deduplicate sends.
184
265
  Rails jobs can also retry, so configure them deliberately.
185
- The current service maps all SES SDK send errors to HTTP 502; that status is
186
- not yet a reliable distinction between permanent and transient AWS failures.
266
+ The SES SDK makes **one send attempt**. AWS throttling/transient failures return
267
+ 503, permanent service rejections 502, and disconnects/timeouts/missing message
268
+ IDs carry the explicit unknown-outcome code. None of these responses authorizes
269
+ an automatic retry of an ambiguous send.
187
270
 
188
271
  ## Status and migration
189
272
 
190
273
  The delivery contract and Hailer-side feedback processing are implemented. CommonShare's
191
274
  existing sender is the compatibility reference, not a runtime dependency.
192
- See [the contract](docs/delivery-contract.md).
275
+
276
+ ### Upgrade from 0.1 redb
277
+
278
+ 1. Stop all old Hailer processes cleanly. Keep the source file and a backup.
279
+ 2. Provision an **empty** Hailer PostgreSQL database and set its URL.
280
+ 3. Run `hailer migrate-redb --file /private/path/state.redb`.
281
+ 4. Check reported counts, event cursors, and suppression enforcement before
282
+ starting 0.2. Keep the source backup; do not restart old writers after cutover.
283
+
284
+ Migration opens redb read-only, checks event/dedup mappings and suppression keys,
285
+ and copies bounded batches in **one destination transaction**. It preserves
286
+ event IDs, sequence values, suppression metadata, and next-cursor continuity.
287
+ It rejects missing/corrupt sources and nonempty targets without wiping anything.
288
+ An unclean redb file may require recovery with the old version against a backup
289
+ before migration. There is no implicit fallback and no dual-write period.
193
290
 
194
291
  Before switching an existing app:
195
292
 
196
- 1. Run Hailer on a private Mothership bind and verify real SES delivery.
293
+ 1. Provision PostgreSQL, run Hailer on a private Mothership bind, check readiness,
294
+ and verify real SES delivery using a deliberate test.
197
295
  2. Add the gem, set `HAILER_URL`, and remove the app's old delivery-method
198
296
  override so Hailer's default takes effect.
199
297
  3. Subscribe a dedicated SQS queue before exporting and importing existing
@@ -202,22 +300,22 @@ Before switching an existing app:
202
300
  and any provider-neutral app history consumer have been verified.
203
301
  5. Only then remove the old sender and AWS-specific feedback handling.
204
302
 
205
- CommonShare's local integration is tested with its real application, database,
206
- Mothership, packaged Hailer binary, and a loopback SES fixture. Its existing AWS
303
+ CommonShare's local 0.2 integration is tested with its real application,
304
+ test database, Mothership, the optimized binary from the macOS gem, its installed 0.1 Ruby
305
+ client, and a loopback SES fixture. Its existing AWS
207
306
  receivers remain until the real subscription and suppression cutover is verified.
208
307
  No deployed AWS routing has been changed.
209
- Account diagnostics, validation/behavior plugins,
210
- distributed state, rate limiting, graceful draining, and reliable send-error
211
- classification are later work. No plugin API is promised yet.
308
+ Validation/behavior plugins, a durable outbound queue, distributed account-wide
309
+ quotas, and send idempotency remain later work. No plugin API is promised yet.
212
310
 
213
311
  ## Development
214
312
 
215
313
  ```sh
216
314
  bundle install
217
315
  bundle exec rake
218
- cargo test --locked
316
+ HAILER_TEST_DATABASE_URL=postgres://test_user:password@127.0.0.1/hailer_test cargo test --locked
219
317
  cargo fmt --all -- --check
220
- gem build hailer.gemspec --output /tmp/hailer-0.1.0.gem
318
+ gem build hailer.gemspec --output /tmp/hailer-0.2.0.gem
221
319
  bundle exec rake native:package
222
320
  ```
223
321
 
@@ -225,17 +323,72 @@ The Ruby suite exercises real local HTTP exchanges and a booted Rails app.
225
323
  The Rust suite checks the same JSON fixture and the docking exchange with a
226
324
  fake provider. Feedback tests cover redelivery, acknowledgement ordering,
227
325
  atomic rollback, restart persistence, imports, and suppression enforcement.
228
- Neither suite sends real mail or requires AWS credentials.
326
+ Rust tests require an isolated PostgreSQL server and a test role with `CREATEDB`;
327
+ each test creates and drops its own database. Never point this variable at a
328
+ customer or Rails production database. Neither suite sends real mail or requires
329
+ AWS credentials.
330
+
331
+ For the opt-in CommonShare compatibility smoke, create a disposable Hailer
332
+ database, then run from that application's checkout:
333
+
334
+ ```sh
335
+ RAILS_ENV=test HAILER_SMOKE_BINARY=/path/to/hailer/target/debug/hailer \
336
+ HAILER_DATABASE_URL=postgres://test_user:password@127.0.0.1/hailer_smoke \
337
+ bundle exec rails runner /path/to/hailer/test/commonshare_smoke.rb
338
+ ```
339
+
340
+ It refuses a non-test app database, points the AWS SDK at a loopback fixture,
341
+ and rolls back application-history changes. It does not change the app's
342
+ dependency lockfile or send external mail.
229
343
 
230
344
  `native:package` builds a locked release and writes
231
- `pkg/hailer-0.1.0-<platform>.gem`. Each platform gem contains exactly its host
345
+ `pkg/hailer-0.2.0-<platform>.gem`. Each platform gem contains exactly its host
232
346
  binary and a version/platform/SHA-256 manifest. The launcher verifies that
233
347
  manifest before `exec`, preserving arguments, exit status, and signal handling.
234
348
  `HAILER_BINARY` is an explicit, version-checked source-build override; there is
235
349
  no download or PATH fallback.
236
350
 
237
- Build on each supported release host before publishing its artifact. Only
238
- `arm64-darwin` has been built and exercised in this checkout. Linux GNU builds
239
- must use your intended minimum-glibc build environment; a native build on a
240
- newer distro is not automatically portable to older distributions. Nothing in
241
- the packaging task publishes a gem.
351
+ Build on each supported release host before publishing its artifact. Nothing
352
+ in the packaging task publishes a gem.
353
+
354
+ ## Linux releases and RubyGems
355
+
356
+ The **Linux binary gems** GitHub Actions workflow builds `x86_64-linux-gnu`
357
+ and `aarch64-linux-gnu` natively against Debian 12 (glibc 2.36). These artifacts
358
+ require glibc 2.36 or newer; they are not Alpine/musl builds.
359
+
360
+ ```sh
361
+ gh workflow run linux-gems.yml --repo seuros/hailer -f tag=v0.2.0
362
+ ```
363
+
364
+ The workflow checks out the requested release tag, runs the Ruby and Rust
365
+ suites against an isolated PostgreSQL 18 service, and installs each binary gem
366
+ in a separate slim Debian container. Pushes/PRs also verify the branch without
367
+ creating a release or publishing anything; only explicit workflow dispatch
368
+ can attach assets to an existing release.
369
+ It verifies the installed platform, bundled-binary manifest, executable version,
370
+ and PostgreSQL suppression import before attaching either Linux gem to the
371
+ existing release. Existing gem assets are never overwritten; `SHA256SUMS` is
372
+ updated to cover all release gems.
373
+
374
+ Publishing a GitHub release does **not** publish to RubyGems. Like Shuttlebay,
375
+ release assets can be fetched and published with Rake:
376
+
377
+ ```sh
378
+ bundle exec rake gems:fetch
379
+ bundle exec rake gems:push_all
380
+ # Or select an explicit release:
381
+ bundle exec rake 'gems:push_all[v0.2.0]'
382
+ ```
383
+
384
+ `gems:push_all` fetches fresh assets, checks every archive's SHA-256,
385
+ name/version/platform, and publishes only that verified set. It skips
386
+ already-published platforms only when RubyGems reports the identical archive
387
+ hash, so an interrupted publication can be resumed without replacing artifacts.
388
+ `gems:push` is an alias. No release task rebuilds or silently republishes gems.
389
+
390
+ RubyGems may require MFA or a suitably scoped API key. Keep credentials out of
391
+ the repository. Publishing makes the packaged Ruby code and binaries public,
392
+ even when the GitHub repository is private. Once published, Bundler selects
393
+ the matching native gem; do not set `force_ruby_platform` in deployment if
394
+ you want the bundled executable.
data/hailer.example.toml CHANGED
@@ -1,6 +1,16 @@
1
- # Keep this file and its state volume private. One Hailer process owns this file.
2
- state_path = "/var/lib/hailer/state.redb"
1
+ # Set HAILER_DATABASE_URL separately; do not commit credentials.
3
2
  threads = 2
3
+ database_pool_size = 5
4
+ shutdown_timeout_seconds = 30
5
+
6
+ # Opt in: initial AWS account/region sync gates send readiness.
7
+ [suppression_sync]
8
+ interval_seconds = 3600
9
+
10
+ # Optional PER-PROCESS budget, not an account-wide SES quota.
11
+ # [rate_limit]
12
+ # recipients_per_second = 10.0
13
+ # burst = 50
4
14
 
5
15
  [feedback]
6
16
  # Dedicated queue; subscribe the SNS topic with RawMessageDelivery=false.
data/lib/hailer/errors.rb CHANGED
@@ -21,4 +21,7 @@ module Hailer
21
21
  class ValidationError < DeliveryError; end
22
22
  class RejectedError < DeliveryError; end
23
23
  class UnavailableError < DeliveryError; end
24
+ class RateLimitedError < DeliveryError; end
25
+ # SES might have accepted the message. Automatic retries can duplicate it.
26
+ class AmbiguousDeliveryError < DeliveryError; end
24
27
  end
@@ -10,7 +10,7 @@ module Hailer
10
10
  DEFAULTS = { open_timeout: 5, read_timeout: 30, write_timeout: 30 }.freeze
11
11
  MAX_RESPONSE_BYTES = 65_536
12
12
  HTTP_ERRORS = {
13
- 400 => ValidationError, 422 => ValidationError,
13
+ 400 => ValidationError, 422 => ValidationError, 429 => RateLimitedError,
14
14
  502 => RejectedError, 503 => UnavailableError
15
15
  }.freeze
16
16
 
@@ -72,6 +72,9 @@ module Hailer
72
72
  unless status == 200
73
73
  message = document['error']
74
74
  message = "Hailer returned HTTP #{status}" unless message.is_a?(String) && !message.empty?
75
+ if status == 503 && document['code'] == 'outcome_unknown'
76
+ raise AmbiguousDeliveryError.new(message, status: status)
77
+ end
75
78
  raise HTTP_ERRORS.fetch(status, DeliveryError).new(message, status: status)
76
79
  end
77
80
  document
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hailer
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
5
5
  end
Binary file
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.1.0",
2
+ "version": "0.2.0",
3
3
  "platform": "x86_64-linux-gnu",
4
4
  "target": "x86_64-unknown-linux-gnu",
5
- "sha256": "515f60bbc3199aa2c980ca2b6e175a1ddfbf5d7592ee2c2d2ce3650685347320"
5
+ "sha256": "582548c40fdf336e1fac3db860be8e15d81ae9727b1ef8dc11be278fbae3db95"
6
6
  }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hailer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: x86_64-linux-gnu
6
6
  authors:
7
7
  - Abdelkader Boudih