Skip to content

Commit b880a86

Browse files
committed
Week 3 Day 19: Implement HEAD-based sync and missing message retrieval with causal ordering
1 parent 21f41f4 commit b880a86

4 files changed

Lines changed: 220 additions & 7 deletions

File tree

backend/p2p-node/discovery.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,15 @@ func (n *discoveryNotifee) HandlePeerFound(pi peer.AddrInfo) {
3838
} else {
3939
log.Printf("[Discovery] Peer connected: %s (addrs: %v)", pi.ID.String(), pi.Addrs)
4040

41-
// Broadcast sync_request to the network
41+
// Broadcast head_exchange to the network
4242
syncReq := NetworkEnvelope{
43-
MsgType: "sync_request",
44-
Payload: map[string]interface{}{},
43+
MsgType: "head_exchange",
44+
Payload: map[string]interface{}{
45+
"heads": n.psm.gossipLog.Heads(),
46+
},
4547
}
4648

47-
log.Printf("[Discovery] SYNC_REQUEST_SENT: Requesting state sync after connecting to peer %s", pi.ID.String())
49+
log.Printf("[Discovery] HEADS_SENT: Requesting state sync after connecting to peer %s", pi.ID.String())
4850
if err := n.psm.Broadcast(syncReq); err != nil {
4951
log.Printf("[Discovery] Failed to broadcast sync_request: %v", err)
5052
}

mobile_app/lib/services/gossip_log_service.dart

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,29 @@ class GossipLogService {
4242
/// Snapshot of current HEAD message IDs (unmodifiable copy).
4343
Set<String> get heads => Set.unmodifiable(_heads);
4444

45+
/// Returns a snapshot of the current HEAD message IDs (messages with no children)
46+
List<String> getHeads() => _heads.toList();
47+
48+
/// Returns peer heads that are missing locally
49+
List<String> findMissingMessages(List<String> peerHeads) {
50+
return peerHeads.where((h) => !_log.containsKey(h) && !_pending.containsKey(h)).toList();
51+
}
52+
53+
/// Fetches the requested messages and their ancestors if needed, sorted topologically.
54+
List<NetworkEnvelope> fetchAndSortMessages(List<String> requestedIds) {
55+
final result = <NetworkEnvelope>[];
56+
for (final id in requestedIds) {
57+
if (_log.containsKey(id)) {
58+
result.add(_log[id]!);
59+
} else if (_pending.containsKey(id)) {
60+
result.add(_pending[id]!);
61+
}
62+
}
63+
// Topological sort by Lamport clock
64+
result.sort((a, b) => a.clock.compareTo(b.clock));
65+
return result;
66+
}
67+
4568
/// Number of messages in the log (applied).
4669
int get logSize => _log.length;
4770

@@ -55,7 +78,9 @@ class GossipLogService {
5578
///
5679
/// Updates the Lamport clock using max(local, received)+1, then either
5780
/// applies the message immediately (if deps satisfied) or queues it pending.
58-
void receive(NetworkEnvelope env) {
81+
/// Day-19: Returns a list of missing dependency IDs, if any.
82+
List<String> receive(NetworkEnvelope env) {
83+
final missingDeps = <String>[];
5984
// Lamport clock update: max(local, received) + 1
6085
if (env.clock > _clock) {
6186
_clock = env.clock;
@@ -68,7 +93,7 @@ class GossipLogService {
6893

6994
// Skip if already known (extra safety beyond P2PService dedup)
7095
if (_log.containsKey(env.msgId) || _pending.containsKey(env.msgId)) {
71-
return;
96+
return missingDeps;
7297
}
7398

7499
if (_canApply(env)) {
@@ -78,7 +103,13 @@ class GossipLogService {
78103
_pending[env.msgId] = env;
79104
debugPrint(
80105
'[GossipLog] MESSAGE_PENDING: msg_id=${env.msgId} waiting for deps=${env.prevMsgIds}');
106+
for (final depId in env.prevMsgIds) {
107+
if (depId.isNotEmpty && !_log.containsKey(depId) && !_pending.containsKey(depId)) {
108+
missingDeps.add(depId);
109+
}
110+
}
81111
}
112+
return missingDeps;
82113
}
83114

84115
/// Records a locally-originated (sent) message into the log without emitting

mobile_app/lib/services/p2p_service.dart

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,19 @@ class P2PService {
144144
// Forward the raw envelope for any listeners that want it
145145
_envelopeStreamController.add(envelope);
146146

147+
if (envelope.msgType == 'head_exchange') {
148+
_handleHeadExchange(envelope);
149+
return;
150+
}
151+
if (envelope.msgType == 'message_request') {
152+
_handleMessageRequest(envelope);
153+
return;
154+
}
155+
if (envelope.msgType == 'message_response') {
156+
_handleMessageResponse(envelope);
157+
return;
158+
}
159+
147160
// Day-18: sync_request and sync_response bypass GossipLog.
148161
// They are bulk state transfer meta-messages, not causal operations.
149162
if (envelope.msgType == 'sync_request') {
@@ -157,12 +170,115 @@ class P2PService {
157170

158171
// All other message types go through GossipLog for causal ordering.
159172
// _gossipLogSub will call _routeValidEnvelope when deps are satisfied.
160-
_gossipLog.receive(envelope);
173+
final missingDeps = _gossipLog.receive(envelope);
174+
if (missingDeps.isNotEmpty) {
175+
debugPrint('[P2P] MISSING_DETECTED: requesting missing deps $missingDeps');
176+
_sendMessageRequest(missingDeps);
177+
}
161178
} catch (e) {
162179
debugPrint('[P2P] Failed to parse incoming message: $e');
163180
}
164181
}
165182

183+
void _handleHeadExchange(NetworkEnvelope envelope) {
184+
debugPrint('[P2P] HEADS_RECEIVED: from ${envelope.originPeer}');
185+
final peerHeads = (envelope.payload['heads'] as List<dynamic>? ?? [])
186+
.map((e) => e.toString())
187+
.toList();
188+
189+
final missing = _gossipLog.findMissingMessages(peerHeads);
190+
if (missing.isNotEmpty) {
191+
debugPrint('[P2P] MISSING_DETECTED: requesting ${missing.length} messages');
192+
_sendMessageRequest(missing);
193+
}
194+
}
195+
196+
void _handleMessageRequest(NetworkEnvelope envelope) {
197+
final requestedIds = (envelope.payload['requested_ids'] as List<dynamic>? ?? [])
198+
.map((e) => e.toString())
199+
.toList();
200+
201+
debugPrint('[P2P] MESSAGE_REQUEST_RECEIVED: for ${requestedIds.length} messages');
202+
203+
final messagesToSend = _gossipLog.fetchAndSortMessages(requestedIds);
204+
if (messagesToSend.isNotEmpty) {
205+
_sendMessageResponse(messagesToSend);
206+
}
207+
}
208+
209+
void _handleMessageResponse(NetworkEnvelope envelope) {
210+
debugPrint('[P2P] MESSAGE_RESPONSE_RECEIVED: from ${envelope.originPeer}');
211+
final messagesList = envelope.payload['messages'] as List<dynamic>? ?? [];
212+
213+
for (final msgData in messagesList) {
214+
try {
215+
final Map<String, dynamic> msgMap =
216+
msgData is Map<String, dynamic>
217+
? msgData
218+
: Map<String, dynamic>.from(msgData as Map);
219+
220+
final msgEnvelope = NetworkEnvelope.fromJson(msgMap);
221+
222+
// Add to deduplication cache to prevent re-processing
223+
if (_messageCache.isDuplicate(msgEnvelope.msgId)) {
224+
continue;
225+
}
226+
227+
// Process through gossip log
228+
final missingDeps = _gossipLog.receive(msgEnvelope);
229+
230+
if (missingDeps.isNotEmpty) {
231+
debugPrint('[P2P] MISSING_DETECTED: requesting missing deps $missingDeps');
232+
_sendMessageRequest(missingDeps);
233+
}
234+
} catch (e) {
235+
debugPrint('[P2P] Failed to parse message in response: $e');
236+
}
237+
}
238+
}
239+
240+
Future<void> _sendMessageRequest(List<String> requestedIds) async {
241+
if (requestedIds.isEmpty) return;
242+
243+
final envelope = NetworkEnvelope(
244+
msgId: 'msg_req_${DateTime.now().millisecondsSinceEpoch}',
245+
msgType: 'message_request',
246+
originPeer: '',
247+
timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
248+
payload: {
249+
'requested_ids': requestedIds,
250+
},
251+
);
252+
253+
debugPrint('[P2P] MESSAGE_REQUEST_SENT: requesting ids $requestedIds');
254+
255+
// Add to our own dedup cache to prevent self-echo
256+
_messageCache.isDuplicate(envelope.msgId);
257+
258+
await _sendEnvelopeHttp(envelope);
259+
}
260+
261+
Future<void> _sendMessageResponse(List<NetworkEnvelope> messages) async {
262+
final payloadMessages = messages.map((m) => m.toJson()).toList();
263+
264+
final envelope = NetworkEnvelope(
265+
msgId: 'msg_resp_${DateTime.now().millisecondsSinceEpoch}',
266+
msgType: 'message_response',
267+
originPeer: '',
268+
timestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
269+
payload: {
270+
'messages': payloadMessages,
271+
},
272+
);
273+
274+
debugPrint('[P2P] MESSAGE_RESPONSE_SENT: sending ${messages.length} messages');
275+
276+
// Add to our own dedup cache to prevent self-echo
277+
_messageCache.isDuplicate(envelope.msgId);
278+
279+
await _sendEnvelopeHttp(envelope);
280+
}
281+
166282
/// Routes a causally-validated envelope to the appropriate downstream handler.
167283
///
168284
/// Called by the GossipLog subscription once all dependencies are satisfied.

mobile_app/test/gossip_log_service_test.dart

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,5 +162,69 @@ void main() {
162162
// clock = max(6, 3) + 1 = 7
163163
expect(service.clock, 7);
164164
});
165+
166+
test('Day 19: findMissingMessages correctly identifies unknown peer heads', () async {
167+
final m1 = _makeEnvelope(msgId: 'msg_1', clock: 1);
168+
service.receive(m1);
169+
170+
final missing = service.findMissingMessages(['msg_3', 'msg_1']);
171+
expect(missing, ['msg_3']);
172+
});
173+
174+
test('Day 19: receive returns missing dependencies', () async {
175+
final m3 = _makeEnvelope(msgId: 'msg_3', clock: 3, prevMsgIds: ['msg_2']);
176+
177+
final missingDeps = service.receive(m3);
178+
expect(missingDeps, ['msg_2']);
179+
expect(service.pendingSize, 1);
180+
});
181+
182+
test('Day 19: fetchAndSortMessages returns topologically sorted messages', () async {
183+
final m1 = _makeEnvelope(msgId: 'msg_1', clock: 1);
184+
final m2 = _makeEnvelope(msgId: 'msg_2', clock: 2, prevMsgIds: ['msg_1']);
185+
final m3 = _makeEnvelope(msgId: 'msg_3', clock: 3, prevMsgIds: ['msg_2']);
186+
187+
service.receive(m1);
188+
service.receive(m2);
189+
service.receive(m3);
190+
191+
// Request in reverse order to ensure sorting works
192+
final fetched = service.fetchAndSortMessages(['msg_3', 'msg_2']);
193+
expect(fetched.length, 2);
194+
expect(fetched[0].msgId, 'msg_2', reason: 'm2 has lower clock, should be first');
195+
expect(fetched[1].msgId, 'msg_3', reason: 'm3 has higher clock, should be second');
196+
});
197+
198+
test('Day 19 Integration: Missing message retrieval in causal order', () async {
199+
// B starts with M1
200+
final m1 = _makeEnvelope(msgId: 'm1', clock: 1);
201+
service.receive(m1);
202+
await pumpEventQueue();
203+
expect(received.length, 1);
204+
205+
// B detects missing M3 (from head_exchange)
206+
final missingFromHeads = service.findMissingMessages(['m3']);
207+
expect(missingFromHeads, ['m3']);
208+
209+
// B receives M3, depends on M2
210+
final m3 = _makeEnvelope(msgId: 'm3', clock: 3, prevMsgIds: ['m2']);
211+
final missingDeps = service.receive(m3);
212+
213+
expect(missingDeps, ['m2']); // B detects missing M2
214+
await pumpEventQueue();
215+
expect(received.length, 1, reason: 'm3 should be pending');
216+
217+
// B requests and receives M2
218+
final m2 = _makeEnvelope(msgId: 'm2', clock: 2, prevMsgIds: ['m1']);
219+
final missingDeps2 = service.receive(m2);
220+
221+
expect(missingDeps2, isEmpty); // M1 is known
222+
await pumpEventQueue();
223+
224+
// M2 unblocks M3
225+
expect(received.length, 3);
226+
expect(received[1].msgId, 'm2');
227+
expect(received[2].msgId, 'm3');
228+
});
165229
});
166230
}

0 commit comments

Comments
 (0)