perf: parallelize UDP port allocation and NIO creation in UDPLink.create

Previously the two sides' UDP port reservations and NIO tunnel
POSTs were issued sequentially, even though they are independent
once the peer addresses are known — each node talks to its own
compute/uBridge with no shared lock, so the HTTP round-trips can
overlap.

- Port allocation: gather _allocate_port for both computes
- NIO creation: gather both node.post calls together
- Error handling: if either side fails, roll back whichever side
  succeeded (previously only node2-fails→cleanup-node1) before
  re-raising the first error

Batch loading (open/import project) benefits most because links
are created at high concurrency and the per-link wall-clock
time is dominated by the sum of its two sequential NIO POSTs.
This commit is contained in:
YueGuobin 2026-08-10 22:36:41 +08:00
parent 372874c7d5
commit acc9e670af
No known key found for this signature in database

View File

@ -16,6 +16,8 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio
from .controller_error import ControllerError, ControllerNotFoundError from .controller_error import ControllerError, ControllerNotFoundError
from .link import Link, _UNSET from .link import Link, _UNSET
from .node_types import BUILTIN_NODE_TYPES from .node_types import BUILTIN_NODE_TYPES
@ -99,25 +101,25 @@ class UDPLink(Link):
except ValueError as e: except ValueError as e:
raise ControllerError(f"Cannot get an IP address on same subnet: {e}") raise ControllerError(f"Cannot get an IP address on same subnet: {e}")
# Reserve a UDP port on both side # Reserve a UDP port on both sides in parallel. Pre-allocated ports
# Try pre-allocated ports first (used during batch project loading) # (used during batch project loading) are popped from memory; otherwise
port = self._project.pop_preallocated_udp_port(node1.compute.id) # each side falls back to a single HTTP round-trip to its compute.
if port is not None: async def _allocate_port(compute):
self._node1_port = port port = self._project.pop_preallocated_udp_port(compute.id)
else: if port is not None:
response = await node1.compute.post(f"/projects/{self._project.id}/ports/udp") return port
self._node1_port = response.json["udp_port"] response = await compute.post(f"/projects/{self._project.id}/ports/udp")
port = self._project.pop_preallocated_udp_port(node2.compute.id) return response.json["udp_port"]
if port is not None:
self._node2_port = port self._node1_port, self._node2_port = await asyncio.gather(
else: _allocate_port(node1.compute), _allocate_port(node2.compute)
response = await node2.compute.post(f"/projects/{self._project.id}/ports/udp") )
self._node2_port = response.json["udp_port"]
node1_filters, node2_filters = self._get_node_filters(node1, node2) node1_filters, node2_filters = self._get_node_filters(node1, node2)
node1_markers, node2_markers = self._get_node_markers(node1, node2) node1_markers, node2_markers = self._get_node_markers(node1, node2)
# Create the tunnel on both side # Build the tunnel specs for both sides. Index 0 is always node1 so
# that update()/delete() keep addressing self._link_data[0]/[1].
self._link_data.append( self._link_data.append(
{ {
"lport": self._node1_port, "lport": self._node1_port,
@ -129,8 +131,6 @@ class UDPLink(Link):
"suspend": self._suspended, "suspend": self._suspended,
} }
) )
await node1.post(f"/adapters/{adapter_number1}/ports/{port_number1}/nio", data=self._link_data[0], timeout=120)
self._link_data.append( self._link_data.append(
{ {
"lport": self._node2_port, "lport": self._node2_port,
@ -142,14 +142,35 @@ class UDPLink(Link):
"suspend": self._suspended, "suspend": self._suspended,
} }
) )
try:
await node2.post( # Create the NIO tunnel on both sides in parallel. The two ends are
# independent once the ports and peer addresses are known -- each node
# talks to its own compute/uBridge with no shared lock between them --
# so the two POSTs overlap. If either fails, roll back whichever side
# succeeded before re-raising the first error.
results = await asyncio.gather(
node1.post(
f"/adapters/{adapter_number1}/ports/{port_number1}/nio", data=self._link_data[0], timeout=120
),
node2.post(
f"/adapters/{adapter_number2}/ports/{port_number2}/nio", data=self._link_data[1], timeout=120 f"/adapters/{adapter_number2}/ports/{port_number2}/nio", data=self._link_data[1], timeout=120
) ),
except Exception as e: return_exceptions=True,
# We clean the first NIO )
await node1.delete(f"/adapters/{adapter_number1}/ports/{port_number1}/nio", timeout=120) errors = [result for result in results if isinstance(result, Exception)]
raise e if errors:
cleanup = []
if not isinstance(results[0], Exception):
cleanup.append(
node1.delete(f"/adapters/{adapter_number1}/ports/{port_number1}/nio", timeout=120)
)
if not isinstance(results[1], Exception):
cleanup.append(
node2.delete(f"/adapters/{adapter_number2}/ports/{port_number2}/nio", timeout=120)
)
if cleanup:
await asyncio.gather(*cleanup, return_exceptions=True)
raise errors[0]
self._created = True self._created = True
# New links automatically inherit every active project-level marker # New links automatically inherit every active project-level marker
# definition so the user doesn't have to reconfigure. # definition so the user doesn't have to reconfigure.