Skip to content

nostr: avoid a String per generic tag key in Filter - #1427

Merged
yukibtc merged 1 commit into
nostrdevkit:masterfrom
JSKitty:filter-codec
Aug 5, 2026
Merged

nostr: avoid a String per generic tag key in Filter#1427
yukibtc merged 1 commit into
nostrdevkit:masterfrom
JSKitty:filter-codec

Conversation

@JSKitty

@JSKitty JSKitty commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Description

Third of the follow-ups mentioned in #1425, after #1426.

Serializing a filter built each map key with format!("#{tag}"), allocating a String per generic tag query. The key is always # followed by a single ASCII letter, so it fits in a two-byte buffer on the stack.

Deserializing had the mirror problem: every key in the map was read into an owned String just to inspect its first two characters. Reading it through a small key type that looks at the borrowed &str and yields the tag directly avoids that.

Measured on an Apple M4, filter with three generic tag queries (178 bytes):

operation before after
Filter::as_json 302 ns, 5 allocs 188 ns, 2 allocs
Filter::from_json 724 ns, 15 allocs 584 ns, 12 allocs

A filter with no generic tags is unchanged (55 -> 52 ns, 119 -> 116 ns, one allocation either way), since neither path allocated for keys in that case. The win is proportional to how many tag queries a filter carries.

Notes to the reviewers

Output is unchanged. Ten filter shapes, their round-trips, and fifteen well-formed and malformed inputs all produce byte-identical results, including which keys are ignored and which are rejected.

The key-shape rules had only partial coverage, and I got two of them wrong while writing the test, so they are now pinned explicitly:

  • # followed by exactly one letter is a tag query.
  • # followed by exactly one non-letter is rejected. This includes "##", which matches the #X shape and then fails the letter check.
  • Anything else is ignored: "#ab", "#", "t", "".

Verified with the full just precommit set plus cargo build --workspace --all-targets and cargo clippy --all-targets -- -D warnings, including the no_std and wasm32 combinations.

That is the last of the near-term follow-ups from #1425. The remaining one is larger and I would rather discuss it before writing anything: Event::from_json spends 43 allocations on a twelve-tag event, 37 of them inside Tags, because Tag holds a Vec<String> and Tags a Vec<Tag>. That is a representation change to a public type, so I will open an issue with the numbers rather than a PR.

Checklist

  • I followed the contribution guidelines
  • I updated the relevant CHANGELOG.md (if applicable)
  • I understand and can explain all code in this PR

@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.75000% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
nostr/src/filter/mod.rs 93.75% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@JSKitty

JSKitty commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Codecov: The three lines are the visitor's expecting, which serde only calls when constructing a type-mismatch error. JSON object keys are always strings, so it isn't reachable through Filter::from_json.

Comment thread nostr/src/filter/mod.rs Outdated
@yukibtc yukibtc added this to the v0.45 milestone Aug 5, 2026
Serializing a filter built the map key with `format!("#{tag}")`, allocating
a `String` for every generic tag query. The key is always `#` followed by a
single ASCII letter, so it fits in a two-byte buffer on the stack.

Deserializing had the mirror problem: every key in the map was read as an
owned `String` just to look at its first two characters. Reading it through
a small key type that inspects the borrowed `&str` and yields the tag
directly avoids that.

Measured on an Apple M4, filter with three generic tag queries (178 bytes):

| operation   | before            | after             |
|-------------|-------------------|-------------------|
| `as_json`   | 302ns, 5 allocs   | 188ns, 2 allocs   |
| `from_json` | 724ns, 15 allocs  | 584ns, 12 allocs  |

A filter with no generic tags is unchanged, since neither path allocated
for keys in that case.

Output is unchanged: ten filter shapes, their round-trips, and fifteen
well-formed and malformed inputs all produce byte-identical results,
including which keys are ignored and which are rejected.

The key shapes had partial coverage, so the rules are now pinned: `#`
followed by exactly one letter is a tag query, `#` followed by exactly one
non-letter is rejected, and anything else is ignored.
@JSKitty
JSKitty requested a review from yukibtc August 5, 2026 10:34

@yukibtc yukibtc left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@yukibtc
yukibtc merged commit 8562877 into nostrdevkit:master Aug 5, 2026
42 checks passed
@JSKitty
JSKitty deleted the filter-codec branch August 5, 2026 10:36
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

Successfully merging this pull request may close these issues.

2 participants