Merge pull request #2767 from vitorpamplona/claude/connection-scaling-plan-YVjc8
Scale relay to 10k+ concurrent connections with streaming JSON parsing
This commit is contained in:
+6
-10
@@ -24,7 +24,6 @@ import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.core.JsonToken
|
||||
import com.fasterxml.jackson.databind.DeserializationContext
|
||||
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.EventDeserializer
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.ManualFilterDeserializer
|
||||
@@ -52,9 +51,9 @@ class CommandDeserializer : StdDeserializer<Command>(Command::class.java) {
|
||||
val filters = mutableListOf<Filter>()
|
||||
|
||||
while (jp.nextToken() != JsonToken.END_ARRAY) {
|
||||
val filterObj: ObjectNode = jp.codec.readTree(jp)
|
||||
val filter = ManualFilterDeserializer.fromJson(filterObj)
|
||||
filters.add(filter)
|
||||
// currentToken is now START_OBJECT for each filter;
|
||||
// the streaming parser consumes through END_OBJECT.
|
||||
filters.add(ManualFilterDeserializer.fromJson(jp))
|
||||
}
|
||||
|
||||
ReqCmd(
|
||||
@@ -68,9 +67,7 @@ class CommandDeserializer : StdDeserializer<Command>(Command::class.java) {
|
||||
val filters = mutableListOf<Filter>()
|
||||
|
||||
while (jp.nextToken() != JsonToken.END_ARRAY) {
|
||||
val filterObj: ObjectNode = jp.codec.readTree(jp)
|
||||
val filter = ManualFilterDeserializer.fromJson(filterObj)
|
||||
filters.add(filter)
|
||||
filters.add(ManualFilterDeserializer.fromJson(jp))
|
||||
}
|
||||
|
||||
CountCmd(
|
||||
@@ -102,9 +99,8 @@ class CommandDeserializer : StdDeserializer<Command>(Command::class.java) {
|
||||
|
||||
NegOpenCmd.LABEL -> {
|
||||
val subId = jp.nextTextValue()
|
||||
jp.nextToken()
|
||||
val filterObj: ObjectNode = jp.codec.readTree(jp)
|
||||
val filter = ManualFilterDeserializer.fromJson(filterObj)
|
||||
jp.nextToken() // advance to filter's START_OBJECT
|
||||
val filter = ManualFilterDeserializer.fromJson(jp)
|
||||
val initialMessage = jp.nextTextValue()
|
||||
|
||||
NegOpenCmd(
|
||||
|
||||
+140
-1
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.quartz.nip01Core.relay.filters
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.core.JsonToken
|
||||
import com.fasterxml.jackson.databind.DeserializationContext
|
||||
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode
|
||||
@@ -32,11 +33,149 @@ class FilterDeserializer : StdDeserializer<Filter>(Filter::class.java) {
|
||||
override fun deserialize(
|
||||
jp: JsonParser,
|
||||
ctxt: DeserializationContext,
|
||||
): Filter = ManualFilterDeserializer.fromJson(jp.codec.readTree(jp))
|
||||
): Filter = ManualFilterDeserializer.fromJson(jp)
|
||||
}
|
||||
|
||||
class ManualFilterDeserializer {
|
||||
companion object {
|
||||
/**
|
||||
* Streaming filter parser. Reads field-by-field off [jp] without
|
||||
* materializing an intermediate `JsonNode` tree — same shape as
|
||||
* [com.vitorpamplona.quartz.nip01Core.jackson.EventDeserializer],
|
||||
* which is what makes high-fan-out REQ traffic cheap on the
|
||||
* relay-inbound path.
|
||||
*
|
||||
* Caller must position the parser so [jp.currentToken] is the
|
||||
* `START_OBJECT` opening the filter. On return, [jp.currentToken]
|
||||
* is the matching `END_OBJECT`.
|
||||
*
|
||||
* Tolerant by design — invalid array entries (wrong type, JSON
|
||||
* null) are silently dropped, mirroring the
|
||||
* `mapNotNull { it.asTextOrNull() }` behavior of the tree-based
|
||||
* overload below. Unknown top-level fields are skipped via
|
||||
* [JsonParser.skipChildren].
|
||||
*/
|
||||
fun fromJson(jp: JsonParser): Filter {
|
||||
var ids: MutableList<String>? = null
|
||||
var authors: MutableList<String>? = null
|
||||
var kinds: MutableList<Int>? = null
|
||||
var tags: MutableMap<String, List<String>>? = null
|
||||
var tagsAll: MutableMap<String, List<String>>? = null
|
||||
var since: Long? = null
|
||||
var until: Long? = null
|
||||
var limit: Int? = null
|
||||
var search: String? = null
|
||||
|
||||
while (jp.nextToken() != JsonToken.END_OBJECT) {
|
||||
val name = jp.currentName()
|
||||
jp.nextToken() // advance to value
|
||||
when {
|
||||
name == "ids" -> {
|
||||
ids = readStringArray(jp)
|
||||
}
|
||||
|
||||
name == "authors" -> {
|
||||
authors = readStringArray(jp)
|
||||
}
|
||||
|
||||
name == "kinds" -> {
|
||||
kinds = readIntArray(jp)
|
||||
}
|
||||
|
||||
name == "since" -> {
|
||||
if (jp.currentToken != JsonToken.VALUE_NULL) since = jp.longValue
|
||||
}
|
||||
|
||||
name == "until" -> {
|
||||
if (jp.currentToken != JsonToken.VALUE_NULL) until = jp.longValue
|
||||
}
|
||||
|
||||
name == "limit" -> {
|
||||
if (jp.currentToken != JsonToken.VALUE_NULL) limit = jp.intValue
|
||||
}
|
||||
|
||||
name == "search" -> {
|
||||
if (jp.currentToken != JsonToken.VALUE_NULL) search = jp.text
|
||||
}
|
||||
|
||||
name.length > 1 && name[0] == '#' -> {
|
||||
val map = tags ?: mutableMapOf<String, List<String>>().also { tags = it }
|
||||
map[name.substring(1)] = readStringArray(jp)
|
||||
}
|
||||
|
||||
name.length > 1 && name[0] == '&' -> {
|
||||
val map = tagsAll ?: mutableMapOf<String, List<String>>().also { tagsAll = it }
|
||||
map[name.substring(1)] = readStringArray(jp)
|
||||
}
|
||||
|
||||
else -> {
|
||||
jp.skipChildren()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Filter(
|
||||
ids = ids,
|
||||
authors = authors,
|
||||
kinds = kinds,
|
||||
tags = tags,
|
||||
tagsAll = tagsAll,
|
||||
since = since,
|
||||
until = until,
|
||||
limit = limit,
|
||||
search = search,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a string array off [jp]. Drops non-string entries and
|
||||
* JSON nulls — matches the `mapNotNull { it.asTextOrNull() }`
|
||||
* tolerance of the tree-based path. Returns an empty list when
|
||||
* the value is anything other than a `START_ARRAY` (incl.
|
||||
* `null`), so callers don't have to special-case that.
|
||||
*/
|
||||
private fun readStringArray(jp: JsonParser): MutableList<String> {
|
||||
val out = mutableListOf<String>()
|
||||
if (jp.currentToken == JsonToken.START_ARRAY) {
|
||||
while (jp.nextToken() != JsonToken.END_ARRAY) {
|
||||
if (jp.currentToken == JsonToken.VALUE_STRING) {
|
||||
out.add(jp.text)
|
||||
} else if (jp.currentToken == JsonToken.START_OBJECT || jp.currentToken == JsonToken.START_ARRAY) {
|
||||
jp.skipChildren()
|
||||
}
|
||||
}
|
||||
} else if (jp.currentToken == JsonToken.START_OBJECT) {
|
||||
jp.skipChildren()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an int array off [jp]. Same tolerance rules as
|
||||
* [readStringArray] — non-numeric entries are dropped.
|
||||
*/
|
||||
private fun readIntArray(jp: JsonParser): MutableList<Int> {
|
||||
val out = mutableListOf<Int>()
|
||||
if (jp.currentToken == JsonToken.START_ARRAY) {
|
||||
while (jp.nextToken() != JsonToken.END_ARRAY) {
|
||||
when (jp.currentToken) {
|
||||
JsonToken.VALUE_NUMBER_INT, JsonToken.VALUE_NUMBER_FLOAT -> out.add(jp.intValue)
|
||||
JsonToken.START_OBJECT, JsonToken.START_ARRAY -> jp.skipChildren()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
} else if (jp.currentToken == JsonToken.START_OBJECT) {
|
||||
jp.skipChildren()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Tree-based overload kept for callers that already have an
|
||||
* `ObjectNode` in hand (e.g. cross-format adapters). New code on
|
||||
* the relay-inbound path should use the streaming overload —
|
||||
* this one materializes the full filter tree first.
|
||||
*/
|
||||
fun fromJson(jsonObject: ObjectNode): Filter {
|
||||
val tagsIn = mutableListOf<String>()
|
||||
jsonObject.fieldNames().forEach {
|
||||
|
||||
Reference in New Issue
Block a user