Script Bash et fichiers volumineux (bug): l'entrée avec la lecture intégrée à partir d'une redirection donne un résultat inattendu

16

J'ai un problème étrange avec les gros fichiers et bash. Voici le contexte:

  • J'ai un gros fichier: 75G et 400 000 000+ lignes (c'est un fichier journal, mon mauvais, je l'ai laissé grandir).
  • Les 10 premiers caractères de chaque ligne sont des horodatages au format AAAA-MM-JJ.
  • Je veux diviser ce fichier: un fichier par jour.

J'ai essayé avec le script suivant qui n'a pas fonctionné. Ma question concerne ce script qui ne fonctionne pas, pas les solutions alternatives .

while read line; do
  new_file=${line:0:10}_file.log
  echo "$line" >> $new_file
done < file.log

Après le débogage, j'ai trouvé le problème dans la new_filevariable. Ce script:

while read line; do
  new_file=${line:0:10}_file.log
  echo $new_file
done < file.log | uniq -c

donne le résultat ci-dessous (je mets les xes pour garder les données confidentielles, les autres caractères sont les vrais). Remarquez la dhet les chaînes plus courtes:

...
  27402 2011-xx-x4
  27262 2011-xx-x5
  22514 2011-xx-x6
  17908 2011-xx-x7
...
3227382 2011-xx-x9
4474604 2011-xx-x0
1557680 2011-xx-x1
      1 2011-xx-x2
      3 2011-xx-x1
...
     12 2011-xx-x1
      1 2011-xx-dh
      1 2011-xx-x1
      1 208--
      1 2011-xx-x1
      1 2011-xx-dh
      1 2011-xx-x1    
...

Ce n'est pas un problème dans le format de mon fichier . Le script cut -c 1-10 file.log | uniq -cne donne que des horodatages valides. Fait intéressant, une partie de la sortie ci-dessus devient avec cut ... | uniq -c:

3227382 2011-xx-x9
4474604 2011-xx-x0
5722027 2011-xx-x1

Nous pouvons voir qu'après le décompte uniq 4474604, mon script initial a échoué.

Ai-je atteint une limite en bash que je ne connais pas, ai-je trouvé un bug dans bash (cela semble improbable), ou ai-je fait quelque chose de mal?

Mise à jour :

Le problème se produit après la lecture de 2G du fichier. Les coutures readet la redirection n'aiment pas les fichiers plus gros que 2G. Mais toujours à la recherche d'une explication plus précise.

Update2 :

Cela ressemble définitivement à un bug. Il peut être reproduit avec:

yes "0123456789abcdefghijklmnopqrs" | head -n 100000000 > file
while read line; do file=${line:0:10}; echo $file; done < file | uniq -c

mais cela fonctionne bien comme solution de contournement (il semble que j'ai trouvé une utilisation utile de cat):

cat file | while read line; do file=${line:0:10}; echo $file; done | uniq -c 

Un bogue a été déposé sur GNU et Debian. Les versions concernées sont bash4.1.5 sur Debian Squeeze 6.0.2 et 6.0.4.

echo ${BASH_VERSINFO[@]}
4 1 5 1 release x86_64-pc-linux-gnu

Update3:

Grâce à Andreas Schwab qui a réagi rapidement à mon rapport de bug, c'est le patch qui est la solution à ce mauvais comportement. Le fichier impacté est lib/sh/zread.ccomme Gilles l'a souligné plus tôt:

diff --git a/lib/sh/zread.c b/lib/sh/zread.c index 0fd1199..3731a41 100644
--- a/lib/sh/zread.c
+++ b/lib/sh/zread.c @@ -161,7 +161,7 @@ zsyncfd (fd)
      int fd; {   off_t off;
-  int r;
+  off_t r;

  off = lused - lind;   r = 0;

La rvariable est utilisée pour conserver la valeur de retour de lseek. Comme lseekrenvoie le décalage depuis le début du fichier, lorsqu'il est supérieur à 2 Go, la intvaleur est négative, ce qui entraîne l' if (r >= 0)échec du test là où il aurait dû réussir.

jfg956
la source
1
Pouvez-vous reproduire le problème avec des ensembles de données d'entrée plus petits? Est-ce toujours les mêmes lignes d'entrée qui entraînent ces problèmes?
larsks
@larks: bonne question. Le problème commence toujours à la ligne # 13.520.918 (deux fois en fait pour les tests que j'ai faits). La taille du fichier avant cette ligne est 2.147.487.726. Il semble qu'il y ait une limite de 32 bits ici, mais pas exactement car nous sommes un peu plus de 2 ^ 31 (2.147.483.648), mais juste à une limite de tampon 4K (2 ^ 31 + 4K = 2.147.487.744). Les lignes précédentes et suivantes sont des lignes normales de 100 à 200 caractères.
jfg956
Testé sur un 2ème fichier (à peu près de la même taille): le problème commence à la ligne # 13.522.712 et le fichier fait 2.147.498.679 octets de large avant cette ligne. Il semble pointer dans le sens d'une limite de l' readinstruction en bash.
jfg956

Réponses:

13

Vous avez trouvé un bug dans bash, en quelque sorte. C'est un bug connu avec un correctif connu.

Les programmes représentent un décalage dans un fichier sous forme de variable dans un type entier avec une taille finie. Autrefois, tout le monde utilisait intà peu près tout, et le inttype était limité à 32 bits, y compris le bit de signe, afin qu'il puisse stocker des valeurs de -2147483648 à 2147483647. De nos jours, il existe différents noms de type pour différentes choses , y compris off_tpour un décalage dans un fichier.

Par défaut, off_test un type 32 bits sur une plate-forme 32 bits (autorisant jusqu'à 2 Go) et un type 64 bits sur une plate-forme 64 bits (autorisant jusqu'à 8EB). Cependant, il est courant de compiler des programmes avec l'option LARGEFILE, qui commute le type off_tsur une largeur de 64 bits et rend le programme appelant des implémentations appropriées de fonctions telles que lseek.

Il semble que vous exécutiez bash sur une plate-forme 32 bits et que votre binaire bash ne soit pas compilé avec un support de fichiers volumineux. Maintenant, lorsque vous lisez une ligne à partir d'un fichier normal, bash utilise un tampon interne pour lire les caractères par lots pour des performances (pour plus de détails, voir la source dans builtins/read.def). Lorsque la ligne est terminée, bash appelle lseekpour rembobiner le décalage du fichier à la position de fin de ligne, au cas où un autre programme se soucierait de la position dans ce fichier. L'appel à lseekse produit dans la zsyncfcfonction dans lib/sh/zread.c.

Je n'ai pas lu la source en détail, mais je présume que quelque chose ne se passe pas bien au point de transition lorsque le décalage absolu est négatif. Donc bash finit par lire les mauvais décalages lorsqu'il remplit son tampon, après avoir franchi la barre des 2 Go.

Si ma conclusion est fausse et que votre bash s'exécute en fait sur une plate-forme 64 bits ou est compilé avec un support de gros fichiers, c'est certainement un bogue. Veuillez le signaler à votre distribution ou en amont .

Un shell n'est de toute façon pas le bon outil pour traiter des fichiers aussi volumineux. Ça va être lent. Utilisez sed si possible, sinon awk.

Gilles 'SO- arrête d'être méchant'
la source
1
Merci Gilles. Excellente réponse: complète, avec suffisamment d'informations pour comprendre le problème, même pour les personnes sans solide expérience CS (32 bits ...). (Les larsks aident également à interroger le numéro de ligne, et il faut le reconnaître.) Après cela, j'ai également pensé à un problème de 32 bits et j'ai téléchargé la source, mais je n'étais pas encore à ce niveau d'analyse. Merci encore, et bonne journée.
jfg956
4

Je ne sais pas mal, mais c'est certainement compliqué. Si vos lignes d'entrée ressemblent à ceci:

YYYY-MM-DD some text ...

Ensuite, il n'y a vraiment aucune raison à cela:

new_file=${line:0:4}-${line:5:2}-${line:8:2}_file.log

Vous faites beaucoup de travail de sous-chaîne pour vous retrouver avec quelque chose qui ressemble ... exactement à ce qu'il ressemble déjà dans le fichier. Que dis-tu de ça?

while read line; do
  new_file="${line:0:10}_file.log"
  echo "$line" >> $new_file
done

Cela prend juste les 10 premiers caractères de la ligne. Vous pouvez également vous dispenser debash entièrement et simplement utiliser awk:

awk '{print > ($1 "_file.log")}' < file.log

Cela saisit la date $1(la première colonne délimitée par des espaces dans chaque ligne) et l'utilise pour générer le nom de fichier.

Notez qu'il est possible qu'il y ait de fausses lignes de journal dans vos fichiers. Autrement dit, le problème peut être lié à l'entrée, pas à votre script. Vous pouvez étendre laawk script pour signaler les fausses lignes comme ceci:

awk '
$1 ~ /[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/ {
    print > ($1 "_file.log")
    next
}

{
    print "INVALID:", $0
}
'

Cela écrit les lignes correspondant YYYY-MM-DDà vos fichiers journaux et signale les lignes qui ne commencent pas par un horodatage sur stdout.

larsks
la source
Pas de fausses lignes dans mon dossier: cut -c 1-10 file.log | uniq -cme donne le résultat attendu. J'utilise ${line:0:4}-${line:5:2}-${line:8:2}parce que je mettrai le fichier dans un répertoire ${line:0:4}/${line:5:2}/${line:8:2}et j'ai simplifié le problème (je mettrai à jour l'énoncé du problème). Je sais awkpeut m'aider ici, mais j'ai rencontré d'autres problèmes en l'utilisant. Ce que je veux, c'est comprendre le problème et bashne pas trouver de solutions alternatives.
jfg956
Comme vous l'avez dit ... si vous "simplifiez" le problème de la question, vous n'obtiendrez probablement pas les réponses que vous souhaitez. Je pense toujours que résoudre ce problème avec bash n'est pas vraiment la bonne façon de traiter ce type de données, mais il n'y a aucune raison que cela ne fonctionne pas.
larsks
Le problème simplifié donne le résultat inattendu que j'ai présenté dans la question, donc je ne pense pas que ce soit une simplification excessive. De plus, le problème simplifié donne un résultat similaire à la cutdéclaration qui fonctionne. Comme je veux comparer des pommes avec des pommes, pas avec des oranges, je dois rendre les choses aussi similaires que possible.
jfg956
1
Je vous ai laissé une question qui pourrait aider à comprendre où les choses
tournent
2

Cela ressemble à ce que vous voulez faire:

awk '
{  filename = substr($0, 0, 10) "_file.log";  # input format same as output format
   if (filename != lastfile) {
       close(lastfile);
       print 'finished writing to', lastfile;
   }
   print >> filename;
   lastfile=filename;
}' file.log

Le closeempêche la table de fichiers ouverte de se remplir.

Arcege
la source
Merci pour la solution awk. Je viens déjà avec quelque chose de similaire. Ma question était de comprendre la limitation de bash, pas de trouver une autre solution.
jfg956