Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/.bundle/
/.yardoc
/_yardoc/
/coverage/
coverage/
/doc/
/pkg/
/builds/
Expand Down
73 changes: 73 additions & 0 deletions 0.6.0-announcement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Servus v0.6.0 — Named async jobs + per-service `async` DSL

## TL;DR

`.call_async` now enqueues a **named ActiveJob class per service** instead of one generic
`Servus::Extensions::Async::Job` for everything. Your job dashboards (Sidekiq, GoodJob, …) will now
show the actual service that ran. There's also a new `async(...)` DSL to configure a service's job
(queue, priority, retries). **The way you call services is unchanged.**

## What's new

### Named job per service

`Treasury::TransferGold::Service` now gets its own `Treasury::TransferGold::ServiceJob`, generated
automatically — you never write or reference it. Background runners display a meaningful per-service
name, so per-queue metrics, retries, and log filtering finally line up with the service that ran.

### `async(...)` DSL for per-service job config

Declare a service's ActiveJob options right in the class. Keyword shortcuts for the common cases,
plus a block (evaluated in the job's context) for the full ActiveJob API:

```ruby
class Treasury::TransferGold::Service < Servus::Base
async queue: :critical, priority: 10

async do
retry_on Gringotts::Timeout, wait: 5.seconds, attempts: 3
discard_on ActiveJob::DeserializationError
end
end
```

These are **class-level defaults**. Options passed inline to `.call_async` layer on top per enqueue
and win for that one call:

```ruby
# uses :critical by default…
Treasury::TransferGold::Service.call_async(from_account: 1, to_account: 2, gold_dragons: 50)

# …but this single call goes to :low_priority
Treasury::TransferGold::Service.call_async(from_account: 1, to_account: 2, gold_dragons: 50, queue: :low_priority)
```

## Nothing changes in your calling code

`Service.call_async(user_id: 123, queue: :critical, wait: 5.minutes)` works exactly as before — same
method, same signature, same option handling. A service that declares no `async` block still gets a
named job with ActiveJob defaults.

## Upgrade considerations

Two operational notes — neither requires code changes:

1. **Drain your queues before deploying.** The enqueue payload changed from
`perform_later(name:, args:)` to `perform_later(**args)` (the job class now identifies the
service). Jobs enqueued by 0.5.x use the old shape and **will not run** after the upgrade. Let
queues empty before rolling out.

2. **Workers must eager-load their services.** A job is resolved on the worker by its class name;
Servus defines each service's job when the service class loads, so Rails' production eager-loading
(the default) covers this. If you run workers with eager-loading off, make sure services get
referenced before their jobs run.

Only relevant if you reached into extension internals: `Job#perform` now takes `(**args)` instead of
`(name:, args:)`, and `Servus::Extensions::Async::Errors::ServiceNotFoundError` was removed (services
are no longer resolved from a serialized name string).

## Verified

Full test suite green across Ruby 3.2–3.4, and exercised end-to-end in a real Rails 8.1 app —
including confirming that a production worker (`eager_load!`) can resolve each named job from its
serialized class name.
118 changes: 118 additions & 0 deletions 1.0.0-announcement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Servus 1.0.0 — explicit over flexible

PR: https://github.com/zarpay/servus/pull/54

## Why this release exists

Servus accumulated flexibility over three years, and most of it was good judgement
at the time. Schemas could live in three places. Two helpers wrapped `.call` so you
didn't have to write `return result unless result.success?`. The `emits` DSL had
opinions about how many events you should emit.

What changed isn't the code — it's who writes the code. When a model generates a
service, the thing that makes the output correct is **the contract being stated
where the work happens**. A schema resolved reflectively from a constant name, or
sitting in a sibling JSON file, isn't visible at the point of generation. Neither
is a helper that looks like a method call but is actually a non-local jump out of
the method.

So 1.0 has a single thesis: **a service's behaviour should be readable from the
file that implements it.** Everything below follows from that. The flexibility
we're removing wasn't wrong — it just stopped paying for itself.

The second principle, which shows up in the fixes more than the features: **fail
loudly, never silently.** Almost every bug found while doing this was the same
shape — something that *looked* declared but validated nothing.

## What changed

**Schemas are inline-only.** `ARGUMENTS_SCHEMA` constants and `app/schemas/**.json`
files are no longer resolved. Only the `schema` DSL.

**Shared fragments replace the duplication that would otherwise cause.** Register a
fragment once, reference it with a standard JSON Schema `$ref`:

```ruby
Servus::Schema.register('core', { '$defs' => { 'amount' => { 'type' => 'integer' } } })

schema arguments: { properties: { fee: { '$ref' => '#/core/$defs/amount' } } }
```

Lazy, memoized, and lookups never return `nil` — an unregistered key raises, naming
the key, the service, the ref chain, and the nearest match. The registry is
standalone, so controller request/response contracts can live in it too, and
`Servus::Schema.compile_all` emits the whole thing as one JSON asset.

**`call!` and `run_service!` are removed.** Both read like ordinary method calls
while hiding a jump.

**`emits` gained no syntax.** Multiple events per trigger, each with its own
payload, already worked — the DSL's own documentation just told people not to. That
note is gone and the pattern is documented.

**Correctness fixes**, all the same failure class:

- `schema arguments: nil` silently left a service unvalidated
- subclasses didn't inherit schemas
- the validator cache was keyed so `A::B::Service` and `A::B::Other` could share one
- `emits` skipped validation entirely when no Event class was registered, bypassing
`require_event_payload_schema` on exactly the events with no schema

## What this means for core

Numbers below are measured against core, not estimated.

**The schema work costs core nothing.** `ApplicationService.schema_key` already
compiles into the native DSL, so we are pure-inline today. Zero legacy constants,
zero file-based schemas.

**The real work is `call!`: 33 sites across 28 files.** Mechanical, but note the
shape change — `call!` returned `data`, `.call` returns the `Response`:

```ruby
# before
transfer = call!(Treasury::TransferGold::Service, **args)
use(transfer.id)

# after
result = Treasury::TransferGold::Service.call(**args)
return result unless result.success?
use(result.data.id)
```

A blind find-and-replace gives you `NoMethodError` on `Response` — loud, which is
the point. `run_service!` is unused in core, so no work there.

**Seven services will start being validated that weren't before**, because they
subclass a concrete service whose schema they now inherit:

```
digital_cash/exchange_note/prepare_customer_claim
digital_cash/exchange_note/prepare_reclaim
product/enrollments/growth_partner_program_onboarding/skip
zar_gold/swaps/complete
zar_gold/swaps/create_pending_transaction
zar_gold/swaps/create_transaction
zar_gold/swaps/fail
```

If any of those take or return a different shape than their parent declares, they
will raise on the first call. Worth exercising before we ship.

Related: **we subclass concrete services in about 24 places.** That is worth
revisiting independently — services are meant to be composed, not inherited. It is
also why event emissions deliberately do *not* inherit; building that support would
legitimise the pattern.

**Events are clear.** We run `require_event_payload_schema = true`, so the new raise
for unregistered events was the thing most likely to bite us. All 29 emitted event
names resolve to a registered Event class — no exposure. The one thing to know going
forward: an Event class in a file not matching `*_event.rb` will not auto-register,
and will now raise rather than silently skipping validation.

## Separately, not caused by this release

37 of our 236 `schema_key` declarations (~16%) point at keys absent from the compiled
schema, so those services validate nothing today. 32 are a mechanical fix — the key
omits the `<engine>::` prefix the compiler prepends. The other 5 have no schema file
at all. This predates 1.0 and deserves its own ticket.
Loading