Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal: Changing the Header #51

Closed
2 tasks
jamesmunns opened this issue Oct 8, 2024 · 3 comments
Closed
2 tasks

Proposal: Changing the Header #51

jamesmunns opened this issue Oct 8, 2024 · 3 comments

Comments

@jamesmunns
Copy link
Owner

jamesmunns commented Oct 8, 2024

Currently, the defined header in postcard-rpc is:

  • a [u8; 8] representing the Key of the request/response/publish
  • a varint(u32) representing the sequence number of the request/response/publish

This is generally fine for USB, as the header takes up 9-13 bytes, still often allowing a whole message to fit in a 64 byte frame. However, this amount of overhead is less desirable on low-bandwidth transports, like over a radio, where less is more.

I have been unsure about shortening the key, as it is procedurally generated, and the goal is to practically never require users to think about the chance of a collision. However for many users with a small number of endpoints/topics, one or two bytes is likely enough to be unique. Ideally we'd be able to have devices choose which size they'd like to use at compile time, however we should not feature-gate this, as a host may speak via multiple transports with different settings (e.g. "full fat" USB and "lite" I2C on the same machine).

Additionally for devices with low throughput, a smaller sequence number range is also likely acceptable.

I'd propose the following new format for postcard-rpc headers:

  • One "settings" byte (described below), which controls the length of the following fields
  • 1-8 bytes for Key (NOT a varint)
  • 1-4 bytes for Sequence Number (NOT a varint)

The settings byte would act as the following bitfield values:

  • 2b: lengths
    • 0b00: 1B Key, 1B SN (3 bytes total)
    • 0b01: 2B Key, 2B SN (5 bytes total)
    • 0b10: 4B Key, 4B SN (9 bytes total)
    • 0b11: 8B Key, 4B SN (13 bytes total)
  • 6b: Protocol Version
    • 2b: Major Version
      • 0b00: 1.x
      • 0b01: 2.x
      • 0b10: 3.x
      • 0b11: 4.x
    • 4b: Minor Version
      • 0b0000: x.0
      • 0b0001: x.1
      • ...
      • 0b1110: x.14
      • 0b1111: RESERVED

Alternative uses for the 6b other that protocol version:

  • feature/format flags?
    • "has CRC" - whether messages have a CRC footer
  • Message kind?
    • Endpoint Req, Endpoint Resp, Topic Pub, Forwarding?
    • Special "protocol" kinds like for negotiating link?
  • HTTP-method-alike?
    • GET, POST, DELETE, etc?
  • Multipart number? For E+T streaming responses?
  • Key algorithm kind?

How it works

Right now, I have the following thoughts:

  • The "servers" are the ones that pick what size they prefer
  • All devices must support all sizes, but can negotiate a lower size
  • Communication defaults to the max size
  • For shrinking Keys:
    • XOR folding should be used
    • For 8 bytes, all bytes ABCDEFGH sent
    • For 4 bytes, ABCD ^ EFGH sent
    • For 2 bytes, AB ^ CD ^ EF ^ GH sent
    • For 1 byte, A ^ B ^ C ^ D ^ E ^ F ^ G ^ H sent
  • For shrinking seqno:
    • Wrapping truncation should be used, e.g.:
    • For 4 bytes, all bytes sent little endian
    • For 2 bytes, % 65536
    • For 1 byte, % 256
    • TODO: make host client avoid large seq_no when establishing connections?

This would make keys still natively eight bytes, but the end-devices could choose which key to use as part of their define_dispatch! invocation, e.g.:

define_dispatch! {
    // Dispatcher type
    dispatcher: Dispatcher<...>;
    // Size config             // Options:
    header_size: TwoKeyTwoSeq, // OneKeyOneSeq, TwoKeyTwoSeq,
                               // FourKeyFourSeq, EightKeyFourSeq
    // Endpoints
    PingEndpoint => blocking ping_handler,
    GetUniqueIdEndpoint => blocking unique_id,
}

This will configure the Dispatcher and Sender to use the appropriate types, particularly for the const-time check for collisions (here).

The dispatch machinery will still require accepting messages with a longer-than-preferred key/seq, by shrinking as defined above. Perhaps we also carry the config of requests and use the same size as response?

Needs definition

  • Do we want to use the 6 header bits for something other than version?
  • How to "negotiate" link size?
    • Accept all, prefer specific?
    • Special negotiation sequence?
    • Accept all, respond specific? (this would be tricky when listening for (key, seq) response
@CBJamo
Copy link

CBJamo commented Oct 8, 2024

What all is the seqno used for? A system might have a small number of message types, but send them frequently. A use case we currently have is sending led commands, which are sometimes animations sent at 30hz and run for hours. That's on an i2c transport.

If the seqno is only used to disambiguate in-flight requests, then that application would only need a u8 (probably only a single bit, actually). It also suggests that it might be fine to always have a u8 seqno? Or perhaps each side could keep an internal seqno of usize but only send a u8 on the wire, handling rollovers so it appears as a usize to the user.

@jamesmunns
Copy link
Owner Author

Yes, sequence number is used to disambiguate in-flight requests. It is possible that you have multiple in flight requests at once. I would agree it is not very likely that you have >256 in flight.

jamesmunns added a commit that referenced this issue Oct 27, 2024
This is a major change to postcard-rpc. This is a **very breaking wire change!**

In particular, this PR changes the following:

## Server Rework

Signifcantly reworks how "servers" are implemented, removing the previous embassy-usb-specific one in favor of more reusable parts, that allow for implementing a server generically over different transports. This new version still has an implementation for embassy-usb 0.3, but ALSO provides a channel-based implementation for testing, and I am likely to port a TCP-based one I have in `poststation` as well. This unlocks the ability to reuse the bulk of the existing code for supporting other transports, like UART, SPI, I2C, Ethernet, or even over radio.

## `define_dispatch` macro rework

As part of the Server Rework, I also mostly rewrote the `define_dispatch!` macro, which now can be used with ANY transport, not just embassy-usb.

This change also now allows servers to define topic handlers, so incoming published messages can be dispatched similar to endpoint dispatching. CC #15 

## Automatic Key Shrinking

Previously, we would always send the full 8-byte "hash of the path and schema" ID in every message, as well as checking at compile time whether there is a collision or not.

This PR takes that up a notch, now calculating the *smallest* hash key we can use (1, 2, 4, or 8 bytes) automatically at compile time, and uses that as our "native" mapping. This is similar to the concept of "Perfect Hash Functions".

## Variable Sequence Number size

Additionally, the client can now send sequence numbers of 1, 2, or 4 bytes. Previously, sequence numbers were a `varint(u32)`. Servers will always respond back with the same sequence number they received when replying to requests.

## Completely redo message headers

As we now have variable sized keys and sequence numbers, headers can now scale dynamically to the necessary size. CC #51 

Before, we had 8 byte keys (fixed) and 1-5 bytes (`varint(u32)`), meaning headers were between **9-13 bytes**.

Now, we use one byte as a discriminant, containing the key and seqno len, as well as a 4-bit version field, as well as the variable key and variable sequence number. This now means that headers are **3-13 bytes** (1B discriminant + 1/2/4/8B key + 1/2/4B sequence number), and will be **3 bytes in many common cases** where it is not necessary to disambiguate more than 256 in-flight messages (via sequence numbers) or 256 endpoints (though the liklihood of having a collision at 8 bits is higher than that due to the birthday problem).

When a Client first connects to a Server, it will always start by sending an 8B key. If the Server replies with a shorter key, the Client will then switch to using keys of that size. It is not necessary to ever hardcode what size keys are necessary, as this is calculated when the Server is compiled, and is automatically detected by the Client.

In general:

* The CLIENT (usually the PC) is in charge of picking the size of the sequence number
* The SERVER (usually the MCU) is in charge of picking the size of the message keys
@jamesmunns
Copy link
Owner Author

Merged in #53

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants