migrate.pl 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. #!/usr/bin/env perl
  2. use v5.36;
  3. use re '/aa';
  4. use FindBin;
  5. use lib "$FindBin::Bin/../lib";
  6. use Getopt::Long qw{GetOptions};
  7. use Time::HiRes ();
  8. use Trog::Config;
  9. use Trog::Data::FlatFile;
  10. use Trog::Data::SQLite;
  11. =head1 SYNOPSIS
  12. Move a site from the FlatFile data model to the SQLite one.
  13. =head2 USAGE
  14. bin/migrate.pl [--dry-run] [--batch N] [--quiet]
  15. Must be run from the tCMS root, as the data paths are relative to it.
  16. --dry-run Say what would be carried over, and write nothing.
  17. --batch N Posts per transaction. Default 500.
  18. --quiet Only complain; say nothing on success.
  19. =head2 WHAT IT DOES
  20. Reads every version of every post out of data/files and writes them into
  21. data/posts.sqlite, oldest version first, so that the version bookkeeping ends up
  22. the way it would have if the posts had been written there all along.
  23. Nothing is deleted. data/files is left exactly as it found it, and so is the
  24. flat file model's tag index in data/posts.db, so the way back is to put
  25. data_model back to FlatFile.
  26. Safe to re-run. Post versions already carried over are skipped rather than
  27. duplicated, which also makes it safe to run once against a live site and again
  28. after a final quiet period to pick up whatever was written in between.
  29. When it has finished, switch the site over in config/main.cfg:
  30. [general]
  31. data_model=SQLite
  32. ...and restart, so the workers build the new model rather than the old one.
  33. =head2 TERMINATION CONDITIONS
  34. Exits non-zero if any post could not be carried over, having written everything
  35. it could. Each batch is a transaction, so a batch that fails is not half
  36. applied, and re-running picks up from what actually landed.
  37. =cut
  38. # So that progress and complaints interleave in the order they happened when
  39. # this is piped to a log rather than watched.
  40. STDOUT->autoflush(1);
  41. STDERR->autoflush(1);
  42. my $usage = "Usage: bin/migrate.pl [--dry-run] [--batch N] [--quiet]\n";
  43. # Printed and exited rather than died: Carp::Always is in the dependency chain,
  44. # and a usage message wearing a stack trace reads like a crash.
  45. sub usage_error (@complaint) {
  46. print STDERR @complaint, $usage;
  47. exit 2;
  48. }
  49. my ( $dry_run, $quiet, $batch_size ) = ( 0, 0, 500 );
  50. GetOptions(
  51. 'dry-run' => \$dry_run,
  52. 'quiet' => \$quiet,
  53. 'batch=i' => \$batch_size,
  54. ) or usage_error();
  55. usage_error("--batch must be a positive number of posts.\n") if $batch_size < 1;
  56. sub say_unless_quiet (@args) {
  57. return if $quiet;
  58. say @args;
  59. return;
  60. }
  61. my $config = Trog::Config::get();
  62. my $from = Trog::Data::FlatFile->new($config);
  63. my $to = Trog::Data::SQLite->new($config);
  64. # raw, so we get the history rather than only the current version of each post,
  65. # and so that no acl filtering stands between a migration and the data it is
  66. # supposed to be moving.
  67. my $started = [ Time::HiRes::gettimeofday() ];
  68. my @posts = $from->get( raw => 1, limit => 0 );
  69. if ( !@posts ) {
  70. print STDERR "No posts found in data/files -- is this a tCMS root, and is it on the FlatFile model?\n";
  71. exit 2;
  72. }
  73. # Oldest first: write() appends, and the version bookkeeping is maintained as it
  74. # goes, so feeding it in order is what makes first_id and latest_id right.
  75. @posts = sort { ( $a->{id} // '' ) cmp ( $b->{id} // '' ) || ( $a->{version} // 0 ) <=> ( $b->{version} // 0 ) } @posts;
  76. # One query rather than one per post.
  77. my $present = $to->versions_present();
  78. # Separately: an array in a list assignment swallows everything after it.
  79. my @batch;
  80. my ( $written, $skipped, $failed ) = ( 0, 0, 0 );
  81. sub flush_batch () {
  82. return unless @batch;
  83. if ($dry_run) {
  84. $written += scalar(@batch);
  85. @batch = ();
  86. return;
  87. }
  88. local $@;
  89. eval {
  90. $to->write( \@batch );
  91. $written += scalar(@batch);
  92. 1;
  93. } or do {
  94. my $error = $@;
  95. $failed += scalar(@batch);
  96. # The batch was a transaction, so none of it landed. Name what was in
  97. # it, or the operator has an error and no idea which posts to look at.
  98. say STDERR "Could not write a batch of " . scalar(@batch) . " post version(s): $error";
  99. say STDERR " the batch held: " . join( ', ', map { "$_->{id}\@" . ( $_->{version} // 0 ) } @batch );
  100. };
  101. @batch = ();
  102. return;
  103. }
  104. foreach my $post (@posts) {
  105. if ( !$post->{id} ) {
  106. say STDERR "Skipping a post with no id, which is not something this can carry over";
  107. $failed++;
  108. next;
  109. }
  110. if ( $present->{ $post->{id} }{ $post->{version} // 0 } ) {
  111. $skipped++;
  112. next;
  113. }
  114. push( @batch, $post );
  115. flush_batch() if @batch >= $batch_size;
  116. }
  117. flush_batch();
  118. my $elapsed = Time::HiRes::tv_interval($started);
  119. if ($dry_run) {
  120. say_unless_quiet( sprintf( "Would carry over %d post version(s); %d already present.", $written, $skipped ) );
  121. say_unless_quiet("Nothing was written. Re-run without --dry-run to do it.");
  122. exit( $failed ? 1 : 0 );
  123. }
  124. # Worth stating plainly rather than trusting the counters: they say what this
  125. # thought it did, and the databases say what is actually there.
  126. my $expected = scalar( grep { $_->{id} } @posts );
  127. my $have = 0;
  128. $have += scalar( keys %$_ ) foreach values %{ $to->versions_present() };
  129. say_unless_quiet( sprintf( "Carried over %d post version(s), skipped %d already present, in %.1fs.", $written, $skipped, $elapsed ) );
  130. say_unless_quiet( sprintf( "data/files holds %d post version(s); data/posts.sqlite now holds %d.", $expected, $have ) );
  131. if ( $failed || $have < $expected ) {
  132. say STDERR sprintf( "%d post version(s) did not make it across. Fix what the errors above name and re-run; what landed will be skipped.", $failed || ( $expected - $have ) );
  133. exit 1;
  134. }
  135. say_unless_quiet("Set general.data_model to SQLite in config/main.cfg and restart to serve from it.");
  136. exit 0;