Migrating from 1.0.0rc14¶
The current native API deliberately breaks the pre-v1 contract published as
1.0.0rc14 (c194597, 2026-09-11), the last released predecessor. It keeps
MQTT 3.1.1/5, all QoS levels, TCP/TLS, WebSocket, Unix, manual acknowledgement
and the memory/SQLite backends. There is no automatic upgrade of applications
or historical databases.
The breaking work began earlier, against main@9ad1f018; that commit remains
the baseline of the historical reports under docs/reports/, but 1.0.0rc14
is the version an application actually upgrades from.
Removed surfaces and replacements¶
| 1.0.0rc14 contract | Current replacement |
|---|---|
mqttium.compat / Paho façade |
A native AsyncClient on the application's event loop |
mqttium.helpers |
Explicit connect, operation and disconnect on AsyncClient |
mqttium.PacketType |
Internal protocol tests can import mqttium.enums.PacketType; applications use native models |
message_delivery="auto" or "both" |
Explicit "iterator" (default) or "callback" |
Async on_message or topic callbacks |
Short synchronous callbacks, or asynchronous processing through messages() |
on_publish |
PublishReceipt / PublishBatchReceipt |
| Connect/publish notifications sharing the message worker | Separate lifecycle hooks; publication uses receipts |
Bounded callback worker: max_pending_callbacks, callback_shutdown_timeout, DeliveryStats.callback_queued/callback_limit, TaskStats.callback_worker |
Callbacks run inline on the reader; count invocations in your own callback if needed |
manual_ack=True with message_delivery="callback" |
ValueError; use messages() with await client.ack(message) |
| Callback route changes during/after connection | Configure before first attempt; a new client is required for different routes |
publish(..., nowait=True) |
Synchronous publish_nowait(...), without await |
publish_backpressure / PublishBackpressure |
Choose publish() or publish_nowait() per operation |
Atomic chunks in publish_many() |
Progressive ordered admission with a receipt for the committed prefix |
Batch chunk_size, nowait, failure_sink |
Removed; max_failure_details is a finite integer, default 128 |
Mutable Properties |
Construct a new immutable Properties(mapping) |
set_auth_handler() / assignment to auth_handler |
Supply auth_handler at construction |
| CONNECT property keys duplicating limit arguments | Use the dedicated constructor arguments |
| Shared mutable reconnect policy | Immutable policy with private state per client |
ReconnectPolicy.follow_server_reference |
Removed; inspect BrokerDisconnectError and explicitly choose a replacement endpoint |
| Delivery small-message diagnostic fields | Exact stats().delivery.iterator_bytes with a finite byte limit; zero byte occupancy/high-water values when max_iterator_bytes=None |
| Custom engine/store/transport integration guarantees | Internal implementation interfaces |
SubscribeResult.from_packet() / UnsubscribeResult.from_packet() |
Use the results returned by subscribe() / unsubscribe(), or construct SubscribeResult(mid=..., reason_codes=...); decoded SUBACK/UNSUBACK packets remain Internal |
Frozen constructor and snapshot vocabulary¶
The constructor names every bound after the thing it bounds and refuses
configuration that would have no effect. The signature and defaults are
recorded in tests/project/test_public_api_surface.py.
| Previous name | Frozen name | Note |
|---|---|---|
local_receive_maximum |
max_inbound_inflight |
Advertised as Receive Maximum on MQTT 5; enforced locally on both protocols |
max_pending_inbound_bytes |
max_inbound_inflight_bytes |
Exceeding either inbound bound ends the connection (DISCONNECT 0x93 / 0x97) |
max_pending_outbound_messages / _bytes |
max_unacknowledged_messages / _bytes |
Admitted QoS 1/2 publications not yet completed, including those awaiting an inflight slot |
max_outbound_messages / _bytes |
max_write_queue_messages / _bytes |
Encoded frames resident in the writer |
max_pending_messages, max_pending_delivery_bytes, delivery_timeout |
max_iterator_messages, max_iterator_bytes, iterator_admission_timeout |
Iterator-only; a non-default value with message_delivery="callback" raises ValueError |
ack_timeout |
subscribe_timeout |
Default SUBACK/UNSUBACK deadline |
ReconnectPolicy.connect_timeout |
AsyncClient(connect_timeout=...) |
One deadline for explicit connect*() calls without timeout and for automatic attempts |
ReconnectPolicy(enabled=False) |
reconnect=None |
Passing a policy enables reconnection |
max_ingress_batch_bytes |
removed | The 1 MiB / 256-packet decode quantum is a fairness constant |
MQTT 5 options accepted by an MQTT 3.1.1 client until connect() |
ProtocolError from the constructor |
connect_properties, will_properties, topic_alias_maximum, auth_handler |
MandatoryResponseTooLargeError importable from mqttium.errors only |
Exported from mqttium |
Local terminal failure; never retried |
ClientStats keeps the same shape (state, epoch, reconnect attempt, one
section per queue or window) with renamed fields and without runtime
scheduling detail:
| Previous field | Frozen field |
|---|---|
stats().tasks (TaskStats) |
removed; state and reconnect_attempt describe recovery |
stats().effects (EffectStats) |
removed |
writer.batches, batched_items, batched_bytes, segmented_writes, enqueue_suspensions, eager_writes, eager_bytes |
removed; queued_*, high_water_*, max_*, waiters, last_outbound remain |
decoder.ingress_batch_limit_bytes |
removed |
outbound.pending_messages / pending_bytes / pending_high_water_* |
outbound.unacknowledged_messages / unacknowledged_bytes / unacknowledged_high_water_* |
outbound.queued_messages, flow_inflight, flow_limit |
outbound.awaiting_slot, inflight, inflight_limit |
inbound.receive_maximum, pending_bytes, pending_high_water_bytes, pending_byte_limit |
inbound.inflight_limit, inflight_bytes, inflight_high_water_bytes, inflight_byte_limit |
delivery.pending_bytes, pending_high_water_bytes, max_bytes |
delivery.iterator_bytes, iterator_high_water_bytes, iterator_byte_limit |
transport.fragmented_read_bytes, pending_control_frames, pending_control_bytes |
removed; buffered_read_bytes is None when total transport backlog is unavailable |
Delivery handles and disconnect diagnostics¶
Manual acknowledgement requires a delivered handle from the active logical
exchange. Reconstructing a Message from its fields no longer authorizes an
acknowledgement; stale and foreign handles raise ProtocolError. Handles survive
a transport reconnect that resumes the same session, including an explicit one.
Nonzero broker DISCONNECT information is available as BrokerDisconnectError
through the existing on_disconnect(error) signature. It carries reason_code
and immutable properties, and replaces only an otherwise generic closure.
Properties¶
from mqttium.api import Properties
properties = Properties({
"content_type": "application/json",
"user_property": [("source", "sensor-1")],
})
updated = Properties({**properties.values, "content_type": "text/plain"})
Input dictionaries, lists and binary buffers are copied into an immutable representation. Repeated properties are tuples, including nested user-property pairs. Mutation of the original input cannot change stored records, encoded packets, received messages or reserved byte counts.
Publication and cancellation¶
receipt = await client.publish("telemetry", b"sample", qos=1)
await receipt.wait()
# In a context that must never suspend:
receipt = client.publish_nowait("telemetry", b"sample", qos=1)
The nonblocking method can raise FlowControlError for protocol/writer pressure,
a pending protocol-effect transfer. A full application-delivery queue alone is
not a publication refusal.
Choose an application retry, rejection or spill policy; never busy-spin.
A cancelled publish() may already be committed. Cancellation stops the Python
wait; it does not undo MQTT admission. Its effect transfer remains owned by the
client. Receipt waits are independent of each other.
For batches, each successful admission remains committed if a later element or
the input iterator fails. Catch PublishBatchError and inspect receipt,
cause, submitted and failure counts. A cancellation propagates unchanged;
the internally registered aggregate is sealed and its admitted exchanges remain
owned by the client. There is no rollback of a committed prefix.
Message delivery and lifecycle hooks¶
Set message_delivery="callback" and register short def handlers before the
first connection attempt. Async message functions and async callable objects
are rejected before changing registration. Returning an awaitable from a sync
handler is an error, not an implicit task handoff. A handler may explicitly
create an application-owned task, but the application must retain it and bound
the amount of pending work.
For asynchronous message processing, move the body to the iterator:
async for message in client.messages():
result = await process(message)
await client.publish("result", result)
This pattern can use outgoing capacity while an unrelated delivery is queued. It is not an unlimited-pressure guarantee: an ACK not yet read can be behind incoming messages whose queue is full. If a sole consumer must await outgoing capacity or receipts during sustained bidirectional traffic, use an independently draining consumer and a bounded application producer with a nonblocking overflow policy, or separate receiving and publishing connections. A bounded queue whose consumer stops draining while waiting for publication can recreate the same dependency. See bidirectional pressure.
Callback messages are no longer queued: matching synchronous callbacks run on
the delivering reader, and the reader decodes no further packet until they
return. max_pending_callbacks and callback_shutdown_timeout are removed from
the constructor; DeliveryStats.callback_queued and callback_limit are
removed without a public replacement, since the snapshot describes retained
state and callback delivery retains nothing. All routes matching one message
run contiguously; the reader yields at a message boundary once its invocation
budget is reached and carries the excess over. manual_ack=True now requires
iterator delivery: synchronous callbacks cannot await ack(), so acknowledge
from the messages() consumer. Iterator delivery charges each message once and
releases the charge when the iterator yields it. A positive
iterator_admission_timeout covers iterator byte reservation and queue
admission; None has no deadline.
MessageDeliveryError reports timeout or an impossible message size.
Replace on_publish with receipt observation. Keep lifecycle setup asynchronous:
async def on_connect(connack):
if not connack.session_present:
await client.subscribe("commands/#", qos=1)
await client.publish("status", b"online")
client.on_connect = on_connect
Lifecycle hooks execute after the triggering effect and connection locks are
released. connect() and disconnect() return after the network operation,
not after the hook; incoming messages do not wait for on_connect to finish.
Use an application readiness signal when necessary. Hooks describe the latest
state: obsolete pending notifications are coalesced, and external lifecycle
operations cancel obsolete active hooks. A lifecycle operation awaited directly
by the hook itself preserves that caller. Automatic retry waits for the current
on_disconnect hook, then rechecks user intent. See the full
hook contract.
auth_handler retains its timeout and protocol-specific async behavior.
SQLite format¶
Use a new database path. The current implementation writes schema 5 and can reopen its own databases. Historical schemas 0–4 containing data, future versions and inconsistent schemas are explicitly refused. Validation precedes write-affecting pragmas. Refusal preserves committed schema and data; SQLite may still recover, checkpoint, or coordinate its main database and journal files. File-byte identity is not promised. There is no migration or silent reset.
The measured payload-last layout, lazy transactions, metadata transitions and paged replay remain. Logical record sizes are persisted at initial admission; there is no historical size backfill. The JSON property representation is canonical, with explicit binary encoding and no tuple compatibility markers.
The store protocol is internal. batch() groups writes; SQLite supplies a
transaction and memory uses a no-op context. The engine still compensates failed
individual publication admissions. Application-wide cross-backend transactions
are not promised.
Qualification¶
The tree must pass protocol, persistence, lifecycle, backpressure, fuzz and real-broker tests. Performance comparisons use exact baseline/candidate commits and the same delivered work and resource bounds. The lean-native diagnostic comparison has no performance acceptance threshold; it does not replace the strict release controls in the benchmarking contract and release procedure. Historical release reports do not qualify this implementation.
Decoder storage and ingress contract¶
The decoder owns packet-boundary bytes; no reusable-buffer view escapes into protocol state or the application. Ingress remains bounded and connection-scoped. The current native API removes the direct QoS 0 adapter path and uses the common engine/effect pipeline for every message.
Transport receive backlog availability¶
The Provisional transport.buffered_read_bytes field is now int | None.
Handle None as unavailable in displays and calculations, rather than coercing
it to zero. Pull streams and WebSocket do not expose complete occupancy through
public asyncio APIs; push transports retain their measured byte count and
disconnected clients report zero. No replacement statistics field is added.
Finite timeout values¶
Timeout defaults and per-call overrides must be finite and positive. Replace
NaN/infinite values with a finite deadline, or use None where the specific
setting documents a default or disabled bound. Zero remains supported for
keepalive and nonnegative reconnect durations, not timeout overrides. Invalid
overrides now raise ValueError before changing the client. A ReconnectPolicy
with a NaN or infinite initial_delay, multiplier, max_delay or
stable_after now raises ValueError when constructed; use max_retries=None
rather than an infinite delay to retry indefinitely.