@@ -1130,9 +1130,17 @@ public void deleteEdge(final Edge edge, final RID skipEndpoint, final Vertex.DIR
11301130 */
11311131 private void disconnectEndpoint (final Edge edge , final Vertex .DIRECTION direction , final RID skipEndpoint ,
11321132 final Vertex .DIRECTION skipDirection ) {
1133+ final boolean lightweight = edge instanceof LightEdge || edge .getIdentity ().getPosition () < 0 ;
11331134 if (skipEndpoint != null && (skipDirection == null || skipDirection == direction ) && skipEndpoint .equals (
1134- direction == Vertex .DIRECTION .OUT ? edge .getOut () : edge .getIn ()))
1135+ direction == Vertex .DIRECTION .OUT ? edge .getOut () : edge .getIn ())) {
1136+ if (lightweight && skipDirection != null )
1137+ // THE ITERATOR REMOVES THE ENTRY ITSELF (#9779): IT IS A REMOVAL BY THIS TRANSACTION ALL THE SAME (#10302). It walks
1138+ // the transaction's own view, so the entry is there to remove; the one way it finds none is a delete listener or
1139+ // trigger that took it out first, through this transaction. Counting it then over-counts by one, which can only
1140+ // hide a stale view (the delete lost, as before #10302), never invent a conflict
1141+ lightEdgeEntriesRemoved (skipEndpoint , direction , edge , 1 );
11351142 return ;
1143+ }
11361144
11371145 final VertexInternal endpoint = resolveEndpointToDisconnect (edge , direction );
11381146 if (endpoint == null )
@@ -1157,17 +1165,25 @@ private void disconnectEndpoint(final Edge edge, final Vertex.DIRECTION directio
11571165 edge .getIdentity (), direction , endpoint .getIdentity ());
11581166 return ;
11591167 }
1160- if (list != null && list .removeEdge (edge ))
1168+ final int removed = list == null ? 0 : list .removeEdge (edge );
1169+ if (removed > 0 ) {
1170+ if (lightweight )
1171+ // ONE PER CHAIN THAT HELD A TWIN: A STRIPED LIST CAN REMOVE ONE IN EACH GENERATION (#10314)
1172+ lightEdgeEntriesRemoved (endpoint .getIdentity (), direction , edge , removed );
11611173 return ;
1174+ }
11621175
11631176 if (direction == Vertex .DIRECTION .IN && edge .getType () instanceof EdgeType edgeType && !edgeType .isBidirectional ())
11641177 // A UNIDIRECTIONAL EDGE IS NEVER IN THE IN LIST: NOTHING WAS MISSED
11651178 return ;
11661179
1167- if (edge instanceof LightEdge || edge . getIdentity (). getPosition () < 0 )
1180+ if (lightweight ) {
11681181 // A LIGHTWEIGHT EDGE HAS NO RECORD TO OUTLIVE ITS ENTRY OR TO REUSE ITS RID, AND ITS TWINS (SAME TYPE, SAME NEIGHBOUR)
1169- // ARE INDISTINGUISHABLE: AN ENTRY IN THE COMMITTED LIST CAN BE ONE THIS TRANSACTION HAS ALREADY REMOVED
1182+ // ARE INDISTINGUISHABLE: AN ENTRY IN THE COMMITTED LIST CAN BE ONE THIS TRANSACTION HAS ALREADY REMOVED, SO IT IS
1183+ // COUNTED INSTEAD (#10302)
1184+ checkLightEdgeListNotStale (endpoint , direction , edge );
11701185 return ;
1186+ }
11711187
11721188 if (database .getTransactionIfExists () instanceof TransactionContext tx && tx .isDeletedInTransaction (edge .getIdentity ()))
11731189 // THIS TRANSACTION HAS ALREADY DELETED THE EDGE, AND REMOVED THE ENTRY WITH IT: THE COMMITTED LIST STILL HOLDS IT
@@ -1186,6 +1202,98 @@ private void disconnectEndpoint(final Edge edge, final Vertex.DIRECTION directio
11861202 keepEdgeRecordPageOutOfSlotMerge (edge );
11871203 }
11881204
1205+ /**
1206+ * Refuses, with a retryable {@link ConcurrentModificationException}, the delete of a lightweight edge that the list
1207+ * this transaction reads does not hold while the committed list holds more of its entries than this transaction removed
1208+ * (#10302, the lightweight half of #10263). The transaction read the list before a concurrent commit appended the
1209+ * entry, so nothing was removed and no page was anchored: the commit would go through and the delete would be lost,
1210+ * one lightweight edge too many. A retry reads the list again.
1211+ * <p>
1212+ * The entries of a lightweight edge and of its twins (same type, same neighbour) are indistinguishable, so the two
1213+ * sides are counted rather than matched: an entry in the committed list that this transaction has itself removed is
1214+ * not a sign of a stale view, and deleting more twins than there are stays the no-op it always was.
1215+ * <p>
1216+ * Limit: twins this transaction CREATED and then removed count as removed, though they were never in the committed
1217+ * list. A transaction that creates twins and deletes more of them than it created can therefore miss a twin committed
1218+ * concurrently, and the delete is lost as before. Counting the creations too would cost a map entry per lightweight
1219+ * edge created, on the bulk-load path, for this corner (#10315). The removals are counted for the whole transaction,
1220+ * up to its commit or rollback: a removal undone by a part of the transaction failing and being retried within it stays
1221+ * counted. Both over-count, which can only hide a stale view, never invent a conflict.
1222+ * <p>
1223+ * Cost: only a removal that found nothing gets here, and it reads the committed vertex and walks the committed chain
1224+ * (one stripe chain per generation on a super-node), stopping as soon as it counts more than were removed. A delete
1225+ * of a lightweight edge that is not there at all walks the whole chain, as the regular-edge check does.
1226+ */
1227+ private void checkLightEdgeListNotStale (final VertexInternal endpoint , final Vertex .DIRECTION direction , final Edge edge ) {
1228+ if (!(database .getTransactionIfExists () instanceof TransactionContext tx ))
1229+ return ;
1230+ final RID neighbour = direction == Vertex .DIRECTION .OUT ? edge .getIn () : edge .getOut ();
1231+ if (neighbour == null )
1232+ return ;
1233+ final int removed = tx .getLightEdgeEntriesRemoved (lightEdgeEntry (endpoint .getIdentity (), direction , edge ));
1234+ final int committed = committedLightEdgeCount (endpoint , direction , edge .getIdentity ().getBucketId (), neighbour , removed + 1 );
1235+ if (committed > removed )
1236+ throw new ConcurrentModificationException (
1237+ "Lightweight edge " + edge .getOut () + "->" + edge .getIn () + " of type " + edge .getTypeName () + " is in the committed "
1238+ + direction + " edge list of vertex " + endpoint .getIdentity () + " but not in the one this transaction read: "
1239+ + "the list changed after it was read" );
1240+ }
1241+
1242+ private void lightEdgeEntriesRemoved (final RID vertex , final Vertex .DIRECTION direction , final Edge edge , final int removed ) {
1243+ if (database .getTransactionIfExists () instanceof TransactionContext tx )
1244+ tx .lightEdgeEntriesRemoved (lightEdgeEntry (vertex , direction , edge ), removed );
1245+ }
1246+
1247+ private static TransactionContext .LightEdgeEntry lightEdgeEntry (final RID vertex , final Vertex .DIRECTION direction ,
1248+ final Edge edge ) {
1249+ final boolean out = direction == Vertex .DIRECTION .OUT ;
1250+ return new TransactionContext .LightEdgeEntry (vertex , out , edge .getIdentity ().getBucketId (), out ? edge .getIn () : edge .getOut ());
1251+ }
1252+
1253+ /**
1254+ * How many lightweight edges of the type of {@code edgeTypeBucketId} reaching {@code neighbour} the COMMITTED edge list
1255+ * of {@code vertex} in {@code direction} holds, read as {@link #committedEdgeListHolds} reads it, counting no further
1256+ * than {@code limit}: the caller only needs to know whether there are more than it removed.
1257+ */
1258+ private int committedLightEdgeCount (final VertexInternal vertex , final Vertex .DIRECTION direction , final int edgeTypeBucketId ,
1259+ final RID neighbour , final int limit ) {
1260+ final RID headRID = committedEdgeListHead (vertex , direction );
1261+ if (headRID == null )
1262+ return 0 ;
1263+
1264+ final Binary headContent = committedRecordContent (headRID );
1265+ if (headContent == null )
1266+ return 0 ;
1267+
1268+ if (headContent .getByte (0 ) == StripeDirectory .RECORD_TYPE ) {
1269+ final StripeDirectory directory = new StripeDirectory (database , headRID , headContent );
1270+ int count = 0 ;
1271+ for (int g = 0 ; g < directory .getGenerationCount () && count < limit ; g ++)
1272+ count += committedChainLightEdgeCount (directory .getHead (g , StripeDirectory .stripeOf (neighbour , directory .getStripes (g ))),
1273+ edgeTypeBucketId , neighbour , limit - count );
1274+ return count ;
1275+ }
1276+ return committedChainLightEdgeCount (headRID , edgeTypeBucketId , neighbour , limit );
1277+ }
1278+
1279+ private int committedChainLightEdgeCount (final RID headRID , final int edgeTypeBucketId , final RID neighbour , final int limit ) {
1280+ final ChainCycleGuard guard = new ChainCycleGuard (headRID );
1281+ int count = 0 ;
1282+ RID chunkRID = headRID ;
1283+ while (chunkRID != null && count < limit ) {
1284+ final Binary content = committedRecordContent (chunkRID );
1285+ if (content == null || content .getByte (0 ) != EdgeSegment .RECORD_TYPE )
1286+ return count ;
1287+
1288+ final EdgeSegment chunk = new MutableEdgeSegment (database , chunkRID , content );
1289+ count += chunk .countLightEdges (edgeTypeBucketId , neighbour );
1290+
1291+ final RID previousRID = chunk .getPreviousRID ();
1292+ chunkRID = previousRID == null || previousRID .equals (chunkRID ) || guard .revisits (previousRID ) ? null : previousRID ;
1293+ }
1294+ return count ;
1295+ }
1296+
11891297 /**
11901298 * A removal that found no entry, in the list this transaction reads or in the committed one, leaves two readings: an
11911299 * edge whose entries are genuinely missing (an orphan record, which the delete has to be able to clear), or an edge
@@ -1297,15 +1405,7 @@ private static ConcurrentModificationException concurrentEdgeDelete(final RID ed
12971405 * too, head pointers included (#10304), see {@link LocalBucket#getRecordFromCommittedPage}.
12981406 */
12991407 private boolean committedEdgeListHolds (final VertexInternal vertex , final Vertex .DIRECTION direction , final Edge edge ) {
1300- final RID vertexRID = vertex .getIdentity ();
1301- final Binary vertexContent = committedRecordContent (vertexRID );
1302- if (vertexContent == null || vertexContent .getByte (0 ) != Vertex .RECORD_TYPE )
1303- return false ;
1304-
1305- final ImmutableVertex committedVertex = new ImmutableVertex (database , vertex .getType (), vertexRID , vertexContent );
1306- final RID headRID = direction == Vertex .DIRECTION .OUT ?
1307- committedVertex .getOutEdgesHeadChunk () :
1308- committedVertex .getInEdgesHeadChunk ();
1408+ final RID headRID = committedEdgeListHead (vertex , direction );
13091409 if (headRID == null )
13101410 return false ;
13111411
@@ -1325,6 +1425,17 @@ private boolean committedEdgeListHolds(final VertexInternal vertex, final Vertex
13251425 return committedChainHolds (headRID , edge .getIdentity ());
13261426 }
13271427
1428+ /** The head of the edge list of {@code vertex} in {@code direction} in the COMMITTED vertex record, or null. */
1429+ private RID committedEdgeListHead (final VertexInternal vertex , final Vertex .DIRECTION direction ) {
1430+ final RID vertexRID = vertex .getIdentity ();
1431+ final Binary vertexContent = committedRecordContent (vertexRID );
1432+ if (vertexContent == null || vertexContent .getByte (0 ) != Vertex .RECORD_TYPE )
1433+ return null ;
1434+
1435+ final ImmutableVertex committedVertex = new ImmutableVertex (database , vertex .getType (), vertexRID , vertexContent );
1436+ return direction == Vertex .DIRECTION .OUT ? committedVertex .getOutEdgesHeadChunk () : committedVertex .getInEdgesHeadChunk ();
1437+ }
1438+
13281439 private boolean committedChainHolds (final RID headRID , final RID edgeRID ) {
13291440 final ChainCycleGuard guard = new ChainCycleGuard (headRID );
13301441 RID chunkRID = headRID ;
0 commit comments