PushX.Instance (PushX v0.14.0)

Copy Markdown View Source

Runtime management of named push notification instances.

Allows starting, stopping, and reconfiguring APNS and FCM instances at runtime, enabling multi-provider setups from a database-backed admin panel.

Usage

# Start an APNS instance
PushX.Instance.start(:apns_prod, :apns,
  key_id: "ABC123",
  team_id: "TEAM456",
  private_key: "-----BEGIN EC PRIVATE KEY-----\n...",
  mode: :prod
)

# Start an FCM instance
PushX.Instance.start(:my_fcm, :fcm,
  project_id: "my-project",
  credentials: %{"type" => "service_account", ...}
)

# Send via instance
PushX.push(:apns_prod, token, msg, topic: "com.example.app")

# Lifecycle management
PushX.Instance.disable(:apns_prod)
PushX.Instance.enable(:apns_prod)
PushX.Instance.reconfigure(:apns_prod, mode: :sandbox)
PushX.Instance.stop(:apns_prod)

Credential Rotation Without Restart

Use reconfigure/2 to hot-swap credentials (e.g., after revoking an APNS .p8 key or rotating an FCM service account). It stops the old pool and starts a fresh one with new credentials. In-flight requests on the old pool get connection errors, which the retry logic handles automatically.

# Load new key from database/file/env
new_key = MyApp.Repo.get_latest_apns_key()

PushX.Instance.reconfigure(:apns_prod,
  key_id: "NEW_KEY_ID",
  private_key: new_key
)

Lifecycle: instances live in memory only

Instances are registered in an ETS table and supervised under PushX's own supervision tree. They are not persisted: after a node restart (deploy, crash, scale-out to a new node) no instances exist until your application starts them again. The idiomatic pattern is to load tenant credentials from your database and call start/3 for each on boot — e.g. from a small worker in your supervision tree placed after your Repo — and again whenever a tenant is provisioned. start/3 is idempotent enough for this: it returns {:error, :already_started} for a name that is running, so a re-run is safe. Each node in a cluster starts its own instances (they are per-VM processes, not cluster-wide).

Stopping an instance (stop/1) or reconfiguring it invalidates its cached APNS JWT; per-instance circuit-breaker state is keyed by name and survives a reconfigure/2 (it is process-independent) but not a node restart.

Summary

Functions

Disables an instance. New pushes are rejected, but the pool stays warm.

Re-enables a disabled instance.

Lists all running instances.

Stops and restarts an instance with updated config.

Restarts the Finch HTTP pool for a named instance.

Resolves an instance name to its info for sending.

Starts a named instance.

Returns the status of a named instance.

Stops a named instance and cleans up all resources.

Functions

disable(name)

@spec disable(atom()) :: :ok | {:error, :not_found}

Disables an instance. New pushes are rejected, but the pool stays warm.

enable(name)

@spec enable(atom()) :: :ok | {:error, :not_found}

Re-enables a disabled instance.

list()

@spec list() :: [map()]

Lists all running instances.

reconfigure(name, new_config)

@spec reconfigure(
  atom(),
  keyword()
) :: {:ok, atom()} | {:error, term()}

Stops and restarts an instance with updated config.

Merges new_config into the existing config. Use this to hot-swap credentials (e.g., after revoking an APNS .p8 key) without restarting the application. The old Finch pool is terminated and a new one starts with fresh connections. In-flight requests on the old pool receive connection errors, which the retry logic handles automatically.

The merged config is validated before the running instance is stopped, so a rotation to an unusable APNS key ({:error, {:invalid_private_key, reason}}) or unusable FCM credentials ({:error, {:invalid_credentials, reason}}) leaves the current instance serving traffic.

Examples

# Rotate APNS key
PushX.Instance.reconfigure(:apns_prod,
  key_id: "NEW_KEY_ID",
  private_key: new_pem_string
)

# Switch APNS environment
PushX.Instance.reconfigure(:apns_prod, mode: :sandbox)

reconnect(name)

@spec reconnect(atom()) :: :ok | {:error, term()}

Restarts the Finch HTTP pool for a named instance.

resolve(name)

@spec resolve(atom()) :: {:ok, map()} | {:error, :not_found | :disabled}

Resolves an instance name to its info for sending.

Returns {:error, :disabled} if the instance exists but is disabled, {:error, :not_found} if it doesn't exist.

start(name, provider, config)

@spec start(atom(), :apns | :fcm, keyword()) :: {:ok, atom()} | {:error, term()}

Starts a named instance.

Arguments

  • name - Unique atom name for this instance (e.g., :apns_prod)
  • provider - :apns or :fcm
  • config - Provider-specific configuration (keyword list)

APNS Config Keys

  • :key_id - (required) Apple Key ID
  • :team_id - (required) Apple Team ID
  • :private_key - (required) PEM string, {:file, path}, or {:system, "ENV_VAR"}. Must be a P-256 (prime256v1) EC key — APNS signs with ES256, and a key on any other curve is rejected at start time.
  • :mode - :prod or :sandbox (default: :prod)
  • :pool_size - Finch pool size (default: 2)
  • :pool_count - Finch pool count (default: 1)
  • :receive_timeout / :pool_timeout / :connect_timeout - per-instance request timeouts in ms (defaults: 15_000 / 5_000 / 10_000)

FCM Config Keys

  • :project_id - (required) Firebase project ID
  • :credentials - (required unless :token_fetcher is set) Service account credentials map or JSON string. Must contain "private_key" (an RSA PEM able to sign RS256) and "client_email"; anything else is rejected at start time. PushX starts a Goth process for the instance from them.
  • :token_fetcher - (optional) bring your own OAuth for this instance: an {module, function, args} tuple invoked as apply(module, function, [goth_name | args]) that returns {:ok, %{token: access_token}} or {:error, reason}. When set, no Goth process is started for the instance and :credentials becomes optional. The global :fcm_token_fetcher config never applies to instances.
  • :pool_size - Finch pool size (default: 2)
  • :pool_count - Finch pool count (default: 1)
  • :receive_timeout / :pool_timeout / :connect_timeout - as for APNS

Returns

  • {:ok, name} on success
  • {:error, :reserved_name} if name is :apns or :fcm
  • {:error, :already_started} if instance already exists
  • {:error, {:missing_config, keys}} if required config is missing
  • {:error, {:invalid_private_key, reason}} if an APNS :private_key cannot sign — a malformed PEM, a key on the wrong curve, a {:file, path} whose file is missing, or a {:system, VAR} that is unset
  • {:error, {:invalid_credentials, reason}} if FCM :credentials are not a service-account map (or its JSON), lack "private_key"/"client_email", or hold a key that cannot sign RS256
  • {:error, {:invalid_token_fetcher, reason}} if an FCM :token_fetcher is not an {module, function, args} tuple

Credentials are verified with a test signature before the instance starts, so a bad key fails here rather than on the first push (APNS) or by crashing the OAuth process after start (FCM).

status(name)

@spec status(atom()) :: {:ok, map()} | {:error, :not_found}

Returns the status of a named instance.

stop(name)

@spec stop(atom()) :: :ok | {:error, :not_found}

Stops a named instance and cleans up all resources.