$cuts Existing cuts. * @param string $from Chain hash of the row before the removed run. * @param string $to Chain hash of the last removed row. * @return array */ public static function add( array $cuts, string $from, string $to ): array { $cuts[] = array( 'from' => $from, 'to' => $to, ); // Merge until nothing joins: a new cut can bridge two older ones. do { $merged = false; $count = count( $cuts ); for ( $i = 0; $i < $count && ! $merged; $i++ ) { for ( $j = 0; $j < $count; $j++ ) { if ( $i === $j || $cuts[ $i ]['to'] !== $cuts[ $j ]['from'] ) { continue; } // $i ends where $j begins: one run. $cuts[ $i ]['to'] = $cuts[ $j ]['to']; unset( $cuts[ $j ] ); $cuts = array_values( $cuts ); $merged = true; break; } } } while ( $merged ); if ( count( $cuts ) > self::LIMIT ) { $cuts = array_slice( $cuts, -self::LIMIT ); } return $cuts; } /** * Every chain hash a gap after `$from` may legitimately end at: `$from` * itself and everything a chain of recorded cuts leads to. * * @param array $cuts Recorded cuts. * @param string $from The hash of the surviving row before the gap. * @return array Hash => true. */ public static function reachable( array $cuts, string $from ): array { $seen = array( $from => true ); $queue = array( $from ); while ( array() !== $queue ) { $at = array_pop( $queue ); foreach ( $cuts as $cut ) { if ( $cut['from'] === $at && ! isset( $seen[ $cut['to'] ] ) ) { $seen[ $cut['to'] ] = true; $queue[] = $cut['to']; } } } return $seen; } /** * The runs a deletion removes, from the rows it removes. * * Two consecutive removed rows are one run exactly when the second's * `chain_prev` is the first's `chain_hash`: if a surviving row stood * between them the link would name that row instead. So the runs can be * read off the doomed rows alone, in id order, without looking at the * rows that stay. * * @param array $doomed Sealed rows being removed, id ascending. * @return array */ public static function runs( array $doomed ): array { $runs = array(); $open = null; foreach ( $doomed as $row ) { $open = self::feed( $open, $runs, $row['prev'], $row['hash'] ); } if ( null !== $open ) { $runs[] = $open; } return $runs; } /** * One removed row, fed to a stream of them: extends the open run when it * continues it, otherwise closes the run into `$runs` and opens a new * one. The caller closes the last open run itself. Exists so a purge of * a million rows is read in batches without holding them all. * * @param array{from: string, to: string}|null $open The open run. * @param array $runs Closed runs, appended to. * @param string $prev The row's chain_prev. * @param string $hash The row's chain_hash. * @return array{from: string, to: string} The open run. */ public static function feed( ?array $open, array &$runs, string $prev, string $hash ): array { if ( null !== $open && $prev === $open['to'] ) { $open['to'] = $hash; return $open; } if ( null !== $open ) { $runs[] = $open; } return array( 'from' => $prev, 'to' => $hash, ); } /** * A canonical string of a cut list, for signing. * * @param int $floor Rows at or below this id are before the current chain. * @param array $cuts Cuts. */ public static function canonical( int $floor, array $cuts ): string { $out = 'floor=' . $floor; foreach ( $cuts as $cut ) { $out .= ';' . $cut['from'] . '>' . $cut['to']; } return $out; } }